Sume asset upload errors: 413, 409 size mismatch, expired

Direct asset uploads on Sume fail with 413 asset_too_large or 409 asset_upload_incomplete, asset_size_mismatch and asset_upload_expired. Fixes for each.

5 min readSume
All posts

A Sume direct upload is three steps: POST /v1/assets/upload-url, a PUT of the bytes, then POST /v1/assets/{id}/complete. Errors map to a step. 413 asset_too_large is the declared size. 409 asset_upload_incomplete is a missing object. 409 asset_size_mismatch is bytes that differ from the declared size. 409 asset_upload_expired is a PUT after the window closed.

Error to cause to fix

Source: apps/api/src/routes/assets.ts and server.assets.test.ts on main, read 2026-10-05
CodeStepCauseFix
413 asset_too_largeupload-url or completeDeclared or stored size is over the limit (default 2 GiB)Compress or split, then request a new URL
409 asset_upload_incompletecompleteNo object at the key yetPUT the file first, then call complete
409 asset_size_mismatchcompleteStored length differs from the declared size_bytesRe-request a URL with the true size and upload again
409 asset_upload_expiredcompleteThe upload URL window closed (default 900 seconds)Request a fresh URL; the old object is deleted
409 asset_checksum_mismatchcompleteThe completion checksum differs from the declared oneSend the same checksum_sha256 or omit it

Why the PUT is picky

The signed URL pins the content length to the size_bytes you declared, so storage refuses a PUT of any other size. At completion Sume trusts only the length that storage reports for the stored object, not the number in the completion body. A rejected completion deletes the object, and the row stays pending_upload, so a retry means a new upload-url call.

Abandoned uploads are cleaned up

If you PUT and never call complete, a reaper picks up the row once it is more than an hour past its upload window. It deletes the object first and only then marks the row failed. A failed delete leaves the row pending for the next tick. The default interval is 10 minutes. An object that another live row still points at, for example after a thread fork, is kept.

The checksum is a label, not proof

checksum_sha256 on an uploaded asset is the value you declared at upload-url. Storage does not verify SHA-256 on a single PUT and Sume does not hash the bytes, so treat it as caller-declared metadata. Completion never adopts a checksum that first appears in the completion request.

When you do not need uploads

Generation fields such as input.image_url take a public HTTPS URL directly. Reserve the upload flow for files that are not public. Registering a remote URL with POST /v1/assets stores unverified metadata only: status registered, no first-party URL, and no fetch or dimension check.

A retry pattern that works

Treat each upload as an atomic attempt. Stat the file, call upload-url with the true size, PUT with the exact bytes and the content type you declared, then call complete. On any 409 from complete, start again from upload-url rather than re-calling complete, because a rejected completion removes the object. On 413, change the file, not the code. Keep the returned asset id only after complete succeeds, since a pending_upload row is not usable as a generation input and will eventually be reaped. Download links are short-lived as well, with a 300 second default, so fetch them when you need them.

Operators can change three knobs through environment variables: the maximum upload bytes, the upload URL lifetime in seconds, and the reaper interval in milliseconds, where zero turns the reaper off. As a client you cannot set them, so read the max_bytes value in the 413 body rather than hard-coding 2 GiB. The same body returns your size_bytes, which makes the gap obvious in a log line.

Related posts

More in Developers

All Developers posts

Written by Sume