The Problem
We had a Rails app that uploads scanned PDF documents through an API. Some documents exceeded the 10MB size limit, causing an error.
The solution: automatically compress oversized PDFs and retry, reducing by 10% each attempt, up to 3 times.
This Blog Post shows my iterations on the problem with Claude and why I don’t really understand how can people possibly think, in March 2026, that coding is dead. I love being assisted by AI in my daily work, but somehow the quality level is not there yet.
But I am sure I just need yet another prmpt, or yet a better CLAUDE.md file.
What follows is a real-world example of iterating with Claude Code to build a feature. The AI produced working code on the first attempt, but it took 10+ rounds of feedback to shape it into something I was happy with. Every version of the code is included - simplified - to show how the design evolved.
Version 1: Introducing the PdfCompressor
The first version put all logic in PdfCompressor — the retry loop, error detection, and compression. It used
Ghostscript with DPI downsampling.
This is the original code:
# `app/models/ibm_document.rb`
def self.create(pdf_pdf_file_bytes)
api_client.documents.create(pdf_pdf_file_bytes)
end
And here it’s the first - working - solution from Claude Code
# `app/models/ibm_document.rb` — v1 (create method)
def self.create(pdf_file_bytes)
PdfCompressor.with_compression_retry(pdf_file_bytes) do |bytes|
api_client.documents.create(bytes)
end
end
# `app/services/pdf_compressor.rb` — v1
class PdfCompressor
MAX_RETRIES = 3
SCALE_FACTOR = 0.9
EXCEEDED_MESSAGE_SIZE_ERROR = "Access Denied due to exceeded message size"
def self.with_compression_retry(pdf_file_bytes, &block)
new(pdf_file_bytes).with_compression_retry(&block)
end
def initialize(pdf_file_bytes)
@pdf_file_bytes = pdf_file_bytes
end
def with_compression_retry
current_bytes = @pdf_file_bytes
retries = 0
loop do
response = yield(current_bytes)
return response if response.success?
break response unless exceeded_message_size?(response)
break response if retries >= MAX_RETRIES
retries += 1
current_bytes = compress(current_bytes, retries)
end
end
private
def exceeded_message_size?(response)
response.data.to_s.include?(EXCEEDED_MESSAGE_SIZE_ERROR)
end
def compress(pdf_file_bytes, attempt)
scale = SCALE_FACTOR ** attempt
dpi = (300 * scale).round
Tempfile.create(["input", ".pdf"]) do |input|
Tempfile.create(["output", ".pdf"]) do |output|
input.binmode
input.write(pdf_file_bytes)
input.flush
system(
"gs", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4",
"-dColorImageResolution=#{dpi}", "-dGrayImageResolution=#{dpi}",
"-dMonoImageResolution=#{dpi}",
"-dDownsampleColorImages=true", "-dDownsampleGrayImages=true", "-dDownsampleMonoImages=true",
"-dNOPAUSE", "-dBATCH", "-dQUIET",
"-sOutputFile=#{output.path}", input.path,
exception: true
)
File.binread(output.path)
end
end
end
end
Although the first version worked, the code felt just wrong.
My main concern, at this stage, was the separation of concern: I did not like at all that the new PdfCompressor did way more than just compressing the PDF file.
In particular, it knew about the specific error message or the fact that there’s a response object: that has nothing to do with the task of compressing a PDF.
So here it comes a second version.
Version 2: Separation of concerns — compress only
My feedback: “The pdf_compressor is implementing a nice loop and retry mechanism, but it should just take care of the compression and not know anything about the error or why it has to be compressed.”
Moved the retry logic to IbmDocument, made PdfCompressor only know about compression.
# `app/models/ibm_document.rb` — v2 (create method)
EXCEEDED_MESSAGE_SIZE_ERROR = "Access Denied due to exceeded message size"
MAX_COMPRESSION_RETRIES = 3
def self.create(pdf_file_bytes)
current_bytes = pdf_file_bytes
MAX_COMPRESSION_RETRIES.times do |attempt|
response = api_client.documents.create(current_bytes)
return response if response.success?
break unless exceeded_message_size?(response)
current_bytes = PdfCompressor.compress(current_bytes, attempt: attempt + 1)
end
response = api_client.documents.create(current_bytes)
response
end
def self.exceeded_message_size?(response)
response.data.to_s.include?(EXCEEDED_MESSAGE_SIZE_ERROR)
end
# `app/services/pdf_compressor.rb` — v2
class PdfCompressor
SCALE_FACTOR = 0.9
def self.compress(pdf_file_bytes, attempt:)
new(pdf_file_bytes, attempt: attempt).compress
end
def initialize(pdf_file_bytes, attempt:)
@pdf_file_bytes = pdf_file_bytes
@attempt = attempt
end
def compress
dpi = (300 * (SCALE_FACTOR ** @attempt)).round
run_ghostscript(input.path, output.path, dpi)
end
private
def run_ghostscript(input_path, output_path, dpi)
system(
"gs", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4",
"-dColorImageResolution=#{dpi}", "-dGrayImageResolution=#{dpi}", "-dMonoImageResolution=#{dpi}",
"-dDownsampleColorImages=true", "-dDownsampleGrayImages=true", "-dDownsampleMonoImages=true",
"-dNOPAUSE", "-dBATCH", "-dQUIET",
"-sOutputFile=#{output_path}", input_path,
exception: true
)
end
end
We now got a clear separation of concern, but the code became unreadable: it’s a huge mess and it’s also broken.
Claude failed completely in keeping concerns separated but extracting the right logic in the right place.
A couple of concrete notes:
IbmDocumentis now responsible of looping and compressing further the document. This is exactly what the PdfCompressor should take care of! We had it in version 1 with the methodwith_compression_retryand now it’s lost. I want the cake, and I want to eat it too!- The
IbmDocumentis now looping three times, but if the API errors for a different reason and breaks the loop, it performs another call. - The
PDfCompressor#compressmethod now takes anattemptargument. This is just so wrong! This method should just know about how much it should compress, not be aware of any loop strategy. - I like that it extracted the ghostscript call
Let’s try to fix this in the next version.
Version 3: Loop back in PdfCompressor, percentage-based compress
My feedback: “I liked that the PdfCompressor was also offering a method to retry a certain number of times, so the ‘looping’ logic stays in there, while the implementation details and continue or break in the caller. I think the compress method should take pdf_file_bytes and a percentage of compression, not ‘attempts’, the attempts logic stays in the loop.”
I brought the loop back as progressively_compress (I decided the name!!) and made compress take a percentage instead
of attempt number.
# `app/models/ibm_document.rb` — v3 (create method)
response = api_client.documents.create(current_bytes)
if !response.success? && exceeded_message_size?(response)
PdfCompressor.progressively_compress(pdf_file_bytes, max_retries: MAX_COMPRESSION_RETRIES) do |compressed_bytes|
response = response = api_client.documents.create(compressed_bytes)
break if response.success?
break unless exceeded_message_size?(response)
end
end
response
# `app/services/pdf_compressor.rb` — v3
class PdfCompressor
SCALE_FACTOR = 0.9
def self.compress(pdf_bytes, percentage:)
new(pdf_bytes).compress(percentage: percentage)
end
def self.progressively_compress(pdf_bytes, max_retries: 3, step: 10, &)
new(pdf_bytes).progressively_compress(max_retries: max_retries, step: step, &)
end
def initialize(pdf_bytes)
@pdf_bytes = pdf_bytes
end
def compress(percentage:)
dpi = (300.0 * percentage / 100).round
run_ghostscript(input.path, output.path, dpi)
end
def progressively_compress(max_retries: 3, step: 10)
percentage = 100
max_retries.times do
percentage -= step
@pdf_bytes = compress(percentage: percentage)
yield(@pdf_bytes)
end
end
private
def run_ghostscript(input_path, output_path, dpi)
# ...
end
end
I feel like we are slowly getting there: my urge of taking over is big now, but I wanted to see how many prompts it would take to get it right. Consider that at this point I already did at least two manual changes:
- Move constants definition above the methods where they were used
- Rename
with_compression_retriesintoprogressively_compress
I now wanted to get rid of the instance method of PdfCompressor and just stay with class level methods. It made no sense to have both.
Also, I don’t want named parameters when they are not optional, so the percentage: named parameter should be changed.
Version 4: Class-only methods
My feedback: “The PdfCompressor can have only class level methods. I don’t see a need for having also instance methods. Also, the compress method takes a percentage parameter. Don’t use a named parameter. It’s not optional.”
Removed the instance methods and constructor. Everything is a class method now.
# `app/services/pdf_compressor.rb` — v4
class PdfCompressor
def self.compress(pdf_bytes, percentage)
dpi = (300.0 * percentage / 100).round
run_ghostscript(input.path, output.path, dpi)
end
def self.progressively_compress(pdf_bytes, max_retries: 3, step: 10)
percentage = 100
max_retries.times do
percentage -= step
pdf_bytes = compress(pdf_bytes, percentage: percentage)
yield(pdf_bytes)
end
end
def self.run_ghostscript(input_path, output_path, dpi)
# ...
end
end
As a Software Engineer, I think our job is to call things by their name. That’s why step in a progressively_compress
method makes no sense. Let’s call ti by its name: progressive_compression_percentage.
Version 5: Renamed step to compression_percentage
My feedback: “I don’t like the variable ‘step’, this is the progressive_compression_percentage.”
Simple rename of the parameter.
# `app/services/pdf_compressor.rb` — v5 (progressively_compress)
def self.progressively_compress(pdf_bytes, max_retries: 3, compression_percentage: 10)
percentage = 100
max_retries.times do
percentage -= compression_percentage
pdf_bytes = compress(pdf_bytes, percentage)
yield(pdf_bytes)
end
end
Let’s now focus on the IbmDocument#create method:
response = api_client.documents.create(current_bytes)
if !response.success? && exceeded_message_size?(response)
PdfCompressor.progressively_compress(pdf_file_bytes, max_retries: MAX_COMPRESSION_RETRIES) do |compressed_bytes|
response = response = api_client.documents.create(compressed_bytes)
break if response.success?
break unless exceeded_message_size?(response)
end
end
response
Using the PdfCompressor this way is just ugly. You need to perform a first call and then, if it fails, start the
compression.
That’s not what I had in mind: I want the PdfCompressor to take care of everything in the loop.
Version 6: First iteration yields original bytes
My feedback: “I don’t like that the caller needs to execute the call before the block. Adapt the compressor so it does not immediately decrease in the first loop iteration, so it’s easier for the caller.”
The first yield now returns the original bytes, so the caller doesn’t need a separate call before the block.
# `app/models/ibm_document.rb` — v6 (create method, just the loop)
response = nil
PdfCompressor.progressively_compress(pdf_file_bytes, max_retries: MAX_COMPRESSION_RETRIES) do |bytes|
response = submit(bytes)
break if response.success?
break unless exceeded_message_size?(response)
end
response
# `app/services/pdf_compressor.rb` — v6 (progressively_compress)
def self.progressively_compress(pdf_bytes, max_retries: 3, compression_percentage: 10)
percentage = 100
(max_retries + 1).times do
yield(pdf_bytes)
percentage -= compression_percentage
pdf_bytes = compress(pdf_bytes, percentage)
end
end
And here I realise that talking about retries has been misleading for Claude all the times until now.
I said “3 retries”, but what I meant was “3 attempts”. That’s why the loop looks so ugly.
Version 7: Attempts instead of retries
My feedback: “I want ‘attempts’ not retries, so if I say 3, it tries 3 times, not 4.”
Changed the semantics: attempts: 3 means exactly 3 yields, not 3 retries + 1 initial.
# `app/models/ibm_document.rb` — v7
MAX_COMPRESSION_ATTEMPTS = 3
PdfCompressor.progressively_compress(pdf_file_bytes, attempts: MAX_COMPRESSION_ATTEMPTS) do |bytes|
# ...
end
# `app/services/pdf_compressor.rb` — v7 (progressively_compress)
def self.compress(pdf_bytes, percentage)
return pdf_bytes if percentage.zero? # I added this as well!
dpi = (300.0 * percentage / 100).round
run_ghostscript(input.path, output.path, dpi)
end
def self.progressively_compress(pdf_bytes, attempts: 3, compression_percentage: 10)
percentage = 100
attempts.times do
yield(pdf_bytes)
percentage -= compression_percentage
pdf_bytes = compress(pdf_bytes, percentage)
end
end
This looks like a fantastic implementation. With “just” seven iterations the code looks like I wanted it.
Claude was very bad at naming things and doing a proper sepration of concerns and good architecture, but at least it gave me the right ghostscript commands to reduce the PDF size, which would have taken me ages to find out.
Also, I let it write tests in the meantime, and they were all green.
Except that, at a closer look, nothing worked.
At this point the tests revealed a fundamental problem: Ghostscript’s single-pass DPI downsampling doesn’t reliably
compress by a given percentage. For already-compressed PDFs, a single Ghostscript pass at 90% DPI actually inflated
the output by 40%+. The pdfwrite device decompresses and re-encodes everything.
Original: 899'685
90% (270 DPI): 1'258'729 (+40% LARGER!)
80% (240 DPI): 1'072'671 (+19% LARGER!)
70% (210 DPI): 893'270 (finally smaller)
60% (180 DPI): 722'807
50% (150 DPI): 555'228
What I found annoying is that I had to explicitly look into the tests to spot this. They were all green, because it just verified that it worked, but not that the actual generated file size was 10% lower of the previous one. Also, it used a fake generated PDF which behaves completely different from a real one: the fake one had just some text, while a real-world one contained a large image: this changed completely the output.
And this was my entire business logic here!
At this point I let Claude run for ~10 minutes. 10 minutes to figure out the problem.
Until I decided to finally take over and tell Claude how to do it.
Version 9 (Final): Multi-pass Ghostscript until target is reached
My feedback: “The approach using ghostscript is ok, but the PdfCompressor should take care of invoking ghostscript multiple times until the desired compression is reached.”
The key insight: a single Ghostscript pass may inflate, but iterating at progressively lower DPI will eventually reach
the target. The compress method now loops internally, stepping down DPI by 10 each iteration, until the output is
small enough.
# `app/models/ibm_document.rb` — Final version (create method)
MAX_COMPRESSION_ATTEMPTS = 3
def self.create(pdf_file_bytes)
response = nil
PdfCompressor.progressively_compress(pdf_file_bytes, attempts: MAX_COMPRESSION_ATTEMPTS) do |bytes|
response = submit(bytes)
break if response.success? || !exceeded_message_size?(response)
end
response
end
# `app/services/pdf_compressor.rb` — Final version
class PdfCompressor
def self.compress(pdf_bytes, percentage)
return pdf_bytes if percentage.zero?
tolerance = 1
target_size = (pdf_bytes.bytesize * (100 - percentage + tolerance) / 100.0).round
current_bytes = pdf_bytes
dpi = BASE_DPI
loop do
dpi -= DPI_STEP
break current_bytes if dpi < MIN_DPI
current_bytes = run_ghostscript_on(current_bytes, dpi)
break current_bytes if current_bytes.bytesize <= target_size
end
end
def self.progressively_compress(pdf_bytes, attempts: 3, compression_percentage: 10)
percentage = 0
attempts.times do
yield(pdf_bytes)
percentage += compression_percentage
pdf_bytes = compress(pdf_bytes, percentage)
end
end
end
Key takeaways
-
Separation of concerns needs taste — the AI initially put everything in one class, then fully separated when asked, but extracted the wrong logic into the wrong place. It took several rounds to find the right middle ground:
PdfCompressorowns the loop and compression, the caller owns the break condition. The AI never pushed back or proposed alternatives — it just did what was asked. -
Naming matters more than you think — “step” vs “compression_percentage”, “retries” vs “attempts”, keyword vs positional arguments. These seem like nitpicks, but imprecise naming led to wrong semantics: saying “retries” made Claude implement
(max_retries + 1).timesinstead of the simplerattempts.times. -
AI-written tests can be dangerously green — the tests passed, but they used a fake PDF with just text and only checked that the output was valid — not that it was actually smaller. With a real scanned document, Ghostscript inflated the file by 40%. The AI never tested the actual business requirement.
-
Domain knowledge is irreplaceable — the AI didn’t know that Ghostscript’s
pdfwritedecompresses and re-encodes everything, causing inflation at high quality levels. It spent 10 minutes trying alternatives before I told it the simple fix: loop at progressively lower DPI until the target is reached. -
AI is great at what you’d rather not learn — despite all the iteration on architecture and naming, the Ghostscript command-line flags were correct from the start. That’s the part that would have taken me the longest to figure out on my own.