Multipart complete rejects your parts list: which failure is it?
Two different multipart failures share one name. How to tell an unknown upload id from a real etag mismatch, and the guard that stops a retry loop.
If every part returned 200 and the assemble step still refuses, you’re almost certainly looking at one of two unrelated failures wearing similar names. On Infrai, a literal STORAGE_MULTIPART_INCONSISTENT (HTTP 409, not retryable) means the upload id is unknown — completed already, aborted, or never yours — and says nothing about your parts. A genuine part mismatch comes back as HTTP 503 with (InvalidPart) buried in the message text and retryable: true, which is the misleading one, because no amount of retrying will fix it.
So before changing any code, read which of the two you actually got. Everything below was probed against api.infrai.cc on 26 July 2026 with POST /v1/storage/multipart/complete/{upload_id}, one deliberate mistake at a time.
The triage table
| What you did | HTTP | Code / message fragment | Retry helps? |
|---|---|---|---|
| Upload id unknown or expired | 409 | STORAGE_MULTIPART_INCONSISTENT — upload '…' not found | No |
| Completed this upload already | 503 | (NoSuchUpload) | No — the object exists |
| One etag wrong, or part never uploaded | 503 | (InvalidPart) | No |
| Same part number twice, or descending | 503 | (InvalidPartOrder) | No |
parts array empty | 503 | (MalformedXML) | No |
part_number sent as a string | 503 | Parameter validation failed … valid types: <class 'int'> | No |
A part object with no etag key | 503 | storage dispatch failed: 'etag' | No |
| Left a part out of the list | 200 | none — object is silently short | n/a |
That last row deserves its own paragraph, and gets one below.
Why 503 is the wrong signal
Every vendor-side complaint is currently wrapped as VENDOR_DOWN with retryable: true. It reads like a transient outage and it isn’t:
{
"ok": false,
"error": {
"code": "VENDOR_DOWN",
"http_status": 503,
"message": "storage dispatch failed: An error occurred (InvalidPart) when calling the CompleteMultipartUpload operation: One or more of the specified parts could not be found.The part might not have been uploaded, or the specified entity tag might not have matched the part's entity tag.",
"retryable": true,
"code_detail": "live_vendor"
}
}
A worker that branches on error.retryable will sit in a backoff loop until it gives up. Branch on the message instead — InvalidPart, InvalidPartOrder and NoSuchUpload are all terminal — and treat that as a caveat of the current error mapping rather than something your retry policy can absorb. The STORAGE_MULTIPART_INCONSISTENT code documented for wrong-etag conditions is the one you get for an unknown upload id, not for a bad part list.
The most common cause: you already finished
An upload id is single-use. Complete it once and it’s gone from the vendor’s table, so a job that crashes after a successful complete and restarts from its journal gets NoSuchUpload on a request that actually worked the first time. The object is sitting in the bucket.
Check before you retry. GET /v1/storage/object/head/{bucket}/{key} is free and answers in a couple of hundred milliseconds:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/head/app-exports/exports/2026-07/report.zip" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "exports/2026-07/report.zip",
"size_bytes": 8388608,
"etag": "44d19aa4a7bebf6f5d98a1a21d184e54-2",
"content_type": "application/zip",
"last_modified": "2026-07-26T01:00:42Z"
}
}
Note the -2 suffix on the etag. A multipart object’s etag is a hash of the part hashes plus a part count, so that suffix tells you how many parts the finished object was built from — a cheap sanity check against how many you meant to send.
Where the etags come from
The second most common cause is capturing the etag from the wrong place. The part upload does not go through Infrai; you sign each part and PUT the bytes at the returned URL, and the etag arrives as a response header:
UPLOAD_ID="178502764256cd70440cc8f876ae59026d1492f3346ea8da57197f6f8fb90cba4a3bc998f1"
PART_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/multipart/presign_part/${UPLOAD_ID}/1" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS -X PUT --data-binary @part-1.bin -D - -o /dev/null "$PART_URL" \
| tr -d '\r' | awk 'tolower($1)=="etag:"{print $2}'
Three details that trip people up, all verified live:
- Quotes around the etag are optional.
"2278358e…"and2278358e…are both accepted; Infrai strips them for you, so “remove the quotes” is not your bug. - Array order doesn’t matter. A
partslist sent as[2, 1]completed fine — the list is sorted bypart_numberbefore it reaches the vendor. Duplicates still fail, asInvalidPartOrder. part_numbermust be a JSON number. A quoted"1"fails validation before the request leaves.
The failure that returns 200
Send a parts list that omits a part you uploaded and the call succeeds. You get an object built from only the parts you named — in our testing, listing part 1 of a two-part upload produced a 6,291,456-byte object where 8,388,608 bytes went in, with no warning anywhere. Your ZIP is then a truncated ZIP, discovered a week later by a customer.
So assert on size. It’s one comparison and it catches every variant of a lost part:
import { readFile } from "node:fs/promises";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const API = "https://api.infrai.cc";
const BUCKET = "app-exports";
/** Assemble an upload, tolerating the "already completed" case. */
export async function finishUpload(uploadId, objectKey, parts, expectedBytes) {
const ordered = [...parts].sort((a, b) => a.part_number - b.part_number);
const res = await fetch(`${API}/v1/storage/multipart/complete/${uploadId}`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ parts: ordered }),
});
const out = await res.json();
if (out.ok === false) {
const msg = out.error?.message ?? "";
if (msg.includes("NoSuchUpload")) return await confirmObject(objectKey, expectedBytes);
if (msg.includes("InvalidPart")) throw new Error(`terminal: part list rejected — ${msg}`);
throw new Error(`complete failed: ${out.error?.code} ${msg}`);
}
if (out.data.size_bytes !== expectedBytes) {
throw new Error(`short object: ${out.data.size_bytes} of ${expectedBytes} bytes assembled`);
}
return out.data;
}
async function confirmObject(objectKey, expectedBytes) {
const res = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${objectKey}`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
const out = await res.json();
if (!out.data?.found) throw new Error("upload id is gone and no object exists — restart the upload");
if (out.data.size_bytes !== expectedBytes) throw new Error("object exists but is the wrong size");
return out.data;
}
const manifest = JSON.parse(await readFile("upload-journal.json", "utf8"));
const done = await finishUpload(manifest.upload_id, manifest.key, manifest.parts, manifest.total_bytes);
console.log("assembled", done.key, done.size_bytes, done.etag);
Abandoned uploads, and what any of this costs
An upload that never completes keeps its parts billable and invisible — they don’t show up in GET /v1/storage/object/list/{bucket}. Abort explicitly when you give up:
curl -sS -X DELETE \
"https://api.infrai.cc/v1/storage/multipart/abort/${UPLOAD_ID}" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Opening an upload, signing parts and aborting are all free and rate-limited. The billed steps are the part uploads and the assemble: verified 26 July 2026, PUT /v1/storage/multipart/upload_part/{upload_id}/{part_number} is $0.0001 per part and POST /v1/storage/multipart/complete/{upload_id} is $0.0002 per call, so a 2 GB file in 100 MB parts costs about a fifth of a cent in call fees. Check today’s rates yourself:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.multipart')]"
Those rates drift downward and campaigns run, so treat the figures as a ceiling.
Two honest limits
part_size_min comes back as 5,242,880 bytes, but it isn’t enforced at assemble time — a 1 MiB non-final part completed without complaint in our testing, where Amazon S3 would have rejected it with EntityTooSmall. Don’t rely on the platform to catch a part-sizing bug; assert it in your uploader.
And multipart from a browser doesn’t work here at all: the bucket has no CORS rules and no route to set them, so the preflight fails before the first part moves, and even if it didn’t, JavaScript can’t read the ETag response header without Access-Control-Expose-Headers. If your uploads must run in a tab, S3, Cloudflare R2 or a self-hosted MinIO are the right choice — all three let you write both headers into a bucket policy. Server-side, CLI and mobile uploaders have no such problem, and that’s where this flow belongs.