Mistakes we made
Every item here really happened while we built uploads, serving and moderation. Each one has: what happened, why, and what to do instead. Read this before you start, and add to it when something new bites.
The pattern behind most of them
Signing needs no permission, checks no object, and knows nothing about CORS. So most upload and serving bugs are silent on the server. The fix is always the same: test the real flow in a real browser, in every environment, and make the client report the real error.
Uploads
-
The SDK's default checksum broke uploads, twice. What: presigned uploads returned 403, first for simple PUTs, later for every multipart chunk. Why: AWS SDK v3 by default adds an
x-amz-checksum-crc32to a presigned URL, computed from an empty body. S3 compares the real body to it and refuses. Instead: build every presigning S3 client withrequestChecksumCalculation: "WHEN_REQUIRED", and leave a comment saying why. See Presigned PUT. -
Content-Type was believed to be signed. It was not. What: code comments said the signature locked the content type. Why: the presigner does not sign
Content-Typeunless you list it insignableHeaders. A URL check showedX-Amz-SignedHeaders=host. Instead: passsignableHeaders, then print a URL and read the signed headers. -
Size limits were a client promise. What: early presigned PUTs did not sign the size. A caller could declare 1 KB and upload much more. Why: the size was checked before signing, but never bound to the request. Instead: sign
Content-Length, or use a POST policy withcontent-length-range. See Presigned POST. -
A seven-file form lost a submission. What: in production, six files uploaded, one did not, and the submit never ran. The person saw "check your connection". Six orphan files stayed in the bucket. Why: each file did its own bot check and presign, serially. One failure ended the flow, and nothing cleaned up. Instead: one presign call for all files, one bot check, an upload ticket bound to the keys, bounded parallel uploads, retries on the same URL, and a lifecycle rule for the scratch prefix.
-
The rate limit was charged per file. What: 30 presigns an hour per IP meant about 4 registrations an hour, shared by everyone behind one office or carrier NAT. Why: the limiter ran inside the per-file loop. Instead: charge the limit once per call.
-
Every failure showed the same message. What: ten different causes all became "check your connection". Why: the client collapsed every error into one code. Instead: pass the server's real error key to the UI and to logs.
CORS, IAM and config drift
-
The production bucket had no CORS rules. What: every browser upload in production failed at the preflight. Why: dev had CORS in Terraform, staging had it added by hand, prod had none. Server tests and curl passed, because CORS only applies in browsers. Instead: CORS in Terraform for every environment. Test with an
OPTIONSpreflight after every apply. See Serving files. -
CORS lists went stale after a hostname rename. What: uploads failed from the new web hostname. One staging list even had the production origin in it. Why: the rename did not include the bucket CORS lists, and someone fixed the wrong bucket. Instead: list every place a hostname appears (CORS, CloudFront, OAuth, CSP) and change them together.
-
Bucket rules lived only in the live bucket. What: a CORS rule and a lifecycle rule existed in production but not in Terraform. Why: they were added by hand and never copied into Terraform. Instead: codify every hand fix the same day. An apply from stale config would have deleted both and broken every upload.
-
Read grants listed prefixes one by one. What: production showed text but blank images. Why: the role could read 6 of 13 prefixes. Presigning still worked, so the API returned 200 and the image URLs 403'd. Nothing was logged. Instead: read on the whole bucket, writes scoped by prefix.
-
Moderation permissions existed in dev only. What: the moderation code was ready, but the role permission it needed was applied to dev alone. Why: IAM lives in the infrastructure repo and is applied per environment, by hand. Code promotion does not carry it. Instead: list the IAM changes in the feature's promotion checklist, and apply them in an environment before the code reaches it.
Serving
-
"CDN or presigned" was decided by a bucket-name match. What: production served every asset as a presigned S3 GET. The CDN was never used, so its CORS policy did nothing. Why: the code checked whether the bucket name contained a marker string. The marker matched no bucket in production. Instead: make public or private an explicit setting per asset type.
-
The CDN 403'd every key. What: the CloudFront distribution worked for no file. Why: it had Origin Access Control, but no bucket policy for CloudFront and no KMS decrypt grant for the bucket's key. Instead: OAC needs all three: the OAC, a bucket policy with
AWS:SourceArn, and KMS decrypt if the bucket uses a customer key. -
Rows pointed at missing objects. What: some catalog images 404'd. Why: the database rows and the bucket had drifted apart. Presigning does not check that an object exists. Instead: copy objects with the data, and check every key with
HeadObject. -
An overwritten file stayed stale at the edge. What: a re-uploaded bundle kept serving the old copy. Why: it was cached for a year at a fixed name. Instead: a new file name per version, or a CloudFront invalidation.
Video
-
Phone videos were unreadable for moderation. What: iPhone clips were rejected as unreadable. Why: iPhones record HEVC by default, and the video moderation service only reads H.264. The original file was served and moderated as uploaded. Instead: transcode every upload on the server to H.264 and AAC, then moderate the clean copy. See Video.
-
A note claimed the client converted video. What: the API docs said HEVC was converted on the client. Why: it was a plan, never built. The client sent
clientTranscoded: false. Instead: check the client code before you trust a note about it. -
A key pattern blocked a new file type. What: staff could not play any video. Every URL came back "not found". Why: the staff URL signer's key pattern allowed only image and PDF extensions. Instead: when a feature gains a file type, search for every key pattern that guards it.
Moderation
-
A fail-soft moderator caught nothing. What: photo moderation was on, but nothing was ever flagged. Why: the role had no permission to call the service. Every call failed and the code moved on. Instead: grant permission, switch on, then test with a known-bad sample in that environment. See Moderation.
-
The review console could not close a case. What: every case stayed open, and every author showed zero strikes. Why: remove and ban ran, but nothing marked the case actioned. Strikes count actioned cases. Instead: closing the case is part of the action. Test the whole loop.
-
No undo, and no notices. What: there was no unban anywhere. A "notify by email" box in the console did nothing. Why: both were assumed to exist. Neither was ever built. Instead: every action has an undo. Check the back end behind every UI control.
-
One threshold table for two providers. What: distress posts passed the cheap first check. Why: one provider gave calibrated probabilities, the other gave round numbers. The same text scored 0.08 and 0.9. Instead: a threshold table per provider, tuned on real samples in every supported language.
-
A permanent error was treated as temporary. What: a check with no permission, and another with a daily quota of zero, were retried on every request, adding seconds per post. Why: the errors looked like throttling. Instead: permission and daily-quota errors open the circuit breaker at once.
-
A store rejection blamed reporting, but login was broken. What: the store said there was no way to report content. Why: the reviewer could not log in, so never saw the controls. Instead: test the exact build the reviewer gets, and send a screen recording of report and block.
Checklist before you ship a file feature
- Tested the real upload in a real browser, in every environment.
- Printed a presigned URL and checked its signed headers and that it has no checksum parameter.
- Bucket CORS and lifecycle rules are in Terraform for every environment.
- IAM for this prefix is applied in every environment the code will reach.
- Opened a served file as a real viewer, not only checked the API response.
- The client shows the real error.
- Moderation tested with a known-bad sample, and the review loop closes a case.