Multipart complete rejects your parts list: read the message
One error code covers every bad manifest, and the message names the cause. How to tell a short list from a wrong etag from a dead upload id, and fix each.
If every part uploaded cleanly and the assemble step still refuses, the manifest you sent doesn’t describe the upload the server is holding. Infrai answers that with one code — STORAGE_MULTIPART_INCONSISTENT, HTTP 409, retryable: false — and puts the actual cause in the message. Three messages cover essentially every case, and each points at a different bug in your uploader.
So don’t change code yet. Read the string.
The three messages, and what each one means
Message from complete | What happened | What to do |
|---|---|---|
multipart manifest must contain exactly uploaded parts [1, 2] | Your list is missing a part, names a part you never uploaded, repeats a number, or is empty | Rebuild the list from the PUT responses |
part 2 etag mismatch | That part number is paired with an etag the server never issued — or has no etag key at all | Capture the etag from the response header, not from the presign call |
upload 'abc…' not found | The upload id was aborted, already completed, or belongs to another account | Check whether the object already exists, then start a fresh upload |
All three arrive as 409 with retryable: false, so a worker that branches on error.retryable does the right thing without parsing anything. Parse the message only when you want to report which mistake it was.
Here’s the first one, live:
export INFRAI_API_KEY="your_infrai_api_key"
UPLOAD_ID="178515273680da5ef4949412b1603fd753f63c17f430b2462bf44cb4d31757179"
curl -sS -X POST "https://api.infrai.cc/v1/storage/multipart/complete/${UPLOAD_ID}" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"parts":[{"part_number":1,"etag":"08b46181d7094b5ece88bb389c7499af"}]}'
{
"ok": false,
"error": {
"code": "STORAGE_MULTIPART_INCONSISTENT",
"http_status": 409,
"message": "multipart manifest must contain exactly uploaded parts [1, 2]",
"docs_url": "https://docs.infrai.cc/errors/STORAGE_MULTIPART_INCONSISTENT",
"retryable": false
}
}
Two parts went up; the list named one. The server tells you the exact set it’s holding — [1, 2] — which is usually enough to spot the gap without any further digging.
Where the etag has to come from
The second-most-common bug is capturing the etag from the wrong place. Part bytes don’t travel through the API: you sign a slot, PUT the bytes at the returned URL, and the etag comes back as a response header on that PUT.
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, all confirmed against the live API on 27 July 2026:
- Quotes around the etag are optional.
"08b46181…"and08b46181…are both accepted — the API strips them, so “remove the quotes” is never the fix. - Array order doesn’t matter. A
partslist sent as[2, 1]completes fine, because the list is sorted bypart_numberbefore assembly. What does matter is that the set of numbers matches. part_numberis a JSON integer between 1 and 10000. Send"1"as a string and you’re outside the documented schema.
Leave the etag key out of a part object entirely and you get part 1 etag mismatch, which is the same branch as a genuinely wrong hash.
The dead upload id
An upload id is single use. Complete it once, or abort it, and it’s gone — so a job that crashes after a successful complete and restarts from its journal will ask about an id that no longer exists:
{
"ok": false,
"error": {
"code": "STORAGE_MULTIPART_INCONSISTENT",
"http_status": 409,
"message": "upload '17851532592…' not found",
"retryable": false
}
}
The object may well be sitting in the bucket already. Ask before you restart anything — head is free and answers in a couple of hundred milliseconds:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-exports-0726/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": 7340032,
"etag": "1272f751990a94a9f44a86fc888b0062-2",
"content_type": "application/zip",
"last_modified": "2026-07-27T11:53:41Z"
}
}
Note the -2 suffix. A multipart etag is a hash of the part hashes plus the part count, so that trailing number tells you how many parts the finished object was built from — a cheap cross-check against how many your uploader meant to send.
An assembler that can’t get this wrong
The whole class of bug disappears if the manifest is built from what the PUTs returned and the finished object is checked against the bytes you sent.
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 = "kb-exports-0726";
/** Assemble an upload, tolerating the "this already finished" restart. */
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("not found")) return await confirmObject(objectKey, expectedBytes);
throw new Error(`terminal: ${out.error?.code} — ${msg}`);
}
if (out.data.size_bytes !== expectedBytes) {
throw new Error(`assembled ${out.data.size_bytes} of ${expectedBytes} bytes`);
}
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 — start a new 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);
Because every manifest failure is terminal, there’s nothing to retry at this level — what you want instead is to re-queue the whole export. POST /v1/queue/publish schedules the redo and POST /v1/errors/capture files the rejected manifest with its trace id, both on the same key that ran the upload. No second account, no second SDK, no separate bill for the job runner that cleans up after storage.
Give up on an upload and abort it explicitly, so the parts stop occupying space you’re paying for:
curl -sS -X DELETE "https://api.infrai.cc/v1/storage/multipart/abort/${UPLOAD_ID}" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Two honest limits, and the bill
part_size_min comes back as 5,242,880 bytes and is advisory: a 1 MiB non-final part assembled without complaint in our testing, where Amazon S3 would answer EntityTooSmall. That’s a limitation your uploader has to carry itself — assert the chunk size before you send, because nothing downstream will. There’s also no server-side checksum of the finished object beyond the part-count etag, so if end-to-end integrity is contractual, hash the file before upload and store the digest next to it.
Opening an upload, signing parts and aborting are free and rate-limited. The metered steps are the part uploads and the assemble — verified 27 July 2026, upload_part is $0.0001 per call and complete $0.0002 per call, so per-call fees on a large export round to nothing against the stored bytes. Pull today’s figures rather than trusting this paragraph:
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'), c['billing'].get('unit','')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.multipart')]"
Those rates trend down and campaigns run, so treat them as a ceiling. If browser-resumable uploads with a client library are the actual requirement rather than server-side assembly, a self-hosted MinIO with the S3 SDK’s managed uploader will do more of this work for you, and that’s a fair reason to pick it.