Blog

All posts
John Damask · 2026-03-23
architectureawsdevlog

Someone tried to upload a 449-page machine learning textbook -- Shalev-Shwartz and Ben-David's Understanding Machine Learning: From Theory to Algorithms -- and got a cryptic "Upload failed" error. No explanation, no useful feedback, just a dead end.

A 449-page ML textbook that broke the upload flow

The browser console told the real story: a 503 error. And when they refreshed, a misleading message about "maximum allowed size" that wasn't even accurate. Not great.

The 29-Second Wall

The bug wasn't a size limit. It was a timeout. Here's what was happening: after a user uploads a PDF, the confirm step was doing three things synchronously -- verifying the file exists in S3, downloading and screening it with Haiku for prompt injection, and then handing it off to the processing Lambda. That screening step involves downloading the PDF, potentially truncating it for large files, and making an API call to Claude Haiku. For a 449-page textbook, that easily blows past API Gateway's hard 29-second timeout.

The architecture had a blind spot. I'd designed the async handoff (confirm → process Lambda) specifically to avoid API Gateway's timeout for the generation step, which takes 30-60 seconds. But I'd left screening in the synchronous path, assuming it would always be fast. It usually is -- for a 10-page paper. Not for a textbook.

Moving Screening to Async

The fix was straightforward once I saw the problem: move screen_pdf() out of the confirm Lambda and into the process Lambda. The confirm Lambda now just does a quick head_object check to verify the PDF exists and isn't too large, then immediately kicks off the async process Lambda. Screening happens there, with a comfortable 15-minute timeout.

This had a nice side effect -- the confirm Lambda went from needing 256 MB of memory and a 90-second timeout (for PDF downloading and Haiku API calls) down to 128 MB and 10 seconds. It also no longer needs to fetch the Anthropic API key from Secrets Manager, so I removed the key ARN and screening config references from its CloudFormation environment.

Adding Real Size Limits

While I was fixing the timeout, I also added proper size validation. The frontend now checks file.size the moment you select a file -- anything over 32 MB gets an instant error with the actual file size, and the upload button stays disabled. The backend confirm Lambda double-checks via head_object in case someone bypasses the frontend. Three layers of validation: client-side, server-side, and the hard limit constant in the upload Lambda for documentation.

The size hint text went from the vague "Works best with files under 10 MB" to the explicit "Maximum: 32 MB."

Fixing the Infinite Spinner

There was a second bug hiding in the same flow. When screening now happens asynchronously in the process Lambda, a rejected PDF gets its status set to rejected in DynamoDB. But the frontend's polling loop only checked for complete and error -- it didn't know about rejected. So if Haiku flagged a document, the user would see a spinner that never stopped.

Added rejected as a terminal status in the polling handler. Now it displays the rejection reason and re-enables the upload button, same as any other error.

What I Learned

The 29-second timeout is one of those constraints you think you've accounted for until you haven't. I'd specifically designed around it for generation but forgot that screening could also be slow. The lesson: if a Lambda is behind API Gateway, every code path needs to be fast -- not just the ones you're thinking about when you first build it. Anything that might take more than a few seconds belongs in the async path.

As for the 32 MB limit -- I'd like to raise it. The AI models are starting to support larger file inputs, but that capability is still in beta and not reliable enough for a production app to depend on. Once large file support stabilizes, I'll bump the limit so you can upload your favorite 449-page textbook without hitting a wall.