Corrupted ZIP after a multipart export upload: how to diagnose it

A truncated archive usually means complete() got a parts list that didn't match the upload. The forensics, the error codes, and an uploader that can't do it.

A customer downloads their export and unzip reports an unexpected end of archive. The object exists, the download returned 200, and nothing in your logs looks wrong. In nearly every case we’ve reproduced, the cause is the same: the completion call received a parts list that didn’t describe what was actually uploaded, and the storage layer assembled exactly what it was told to. Infrai’s multipart routes make that diagnosable in two calls — the trap is that the broken outcome doesn’t look like an error anywhere in the chain.

Assembly is driven entirely by the list you submit. Nothing checks it against what arrived.

Which kind of broken is it?

Two symptoms, two different investigations, and they need separating before anything else.

If the downloaded file is smaller than the export you generated, parts are missing from the assembly. If it’s the right size but still won’t open, the pieces went together in the wrong order or a part was uploaded twice under different numbers. Get the local file size first — that single number picks the branch.

SymptomLikely causeFix
File shorter than expected, unzip hits EOFcompletion list was short a partre-upload; assert part count before completing
Right size, archive header unreadablepart numbers paired with the wrong ETagsre-upload with an ordered manifest
complete returns 503 InvalidPartan ETag doesn’t match any uploaded partabort, restart, don’t auto-retry
presign_part returns 409the upload_id was aborted or expiredopen a new multipart upload
ETag doesn’t match your MD5nothing is wrongmultipart ETags aren’t content hashes

The one that silently produces a truncated file

This is worth dwelling on because there’s no error to catch. We uploaded two parts — 5 MiB and 1 MiB — and then completed with only part one in the list. The API returned 200 and a perfectly ordinary-looking object:

{
  "ok": true,
  "data": {
    "bucket_id": "bkt_a1c634e029384107b72f35",
    "key": "exports/tenant_88/missing-part.zip",
    "size_bytes": 5242880,
    "etag": "3fa623e1834d7610384114e4697afa9d-1",
    "content_type": "application/zip",
    "created_at": "2026-07-26T00:51:38.162542Z"
  }
}

Six MiB went up. Five MiB got assembled. The last megabyte — which in a ZIP holds the central directory, the index every unzipper reads first — was dropped, and every layer reported success. That’s the behaviour behind most “the file is corrupted” tickets, and it’s why an uploader has to compare size_bytes against the bytes it sent rather than trusting a 200.

The trailing -1 in that ETag is the tell. A multipart ETag ends with a hyphen and the number of parts that were assembled, so if your uploader sent four parts and the ETag says -1, three of them never made it into the object.

When it does fail loudly

Pair a part number with the wrong ETag and completion fails, but not in the way you’d expect:

{
  "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
  }
}

Read past the code. VENDOR_DOWN with retryable: true describes a client-side mistake — your list is wrong, and retrying it will be wrong the same way forever. That mismatch between the envelope and the underlying message is a caveat worth building around: branch on the InvalidPart text, not on the 503, or your retry loop will spin until the budget runs out.

The other loud failure is a stale upload:

{
  "ok": false,
  "error": {
    "code": "STORAGE_MULTIPART_INCONSISTENT",
    "http_status": 409,
    "message": "upload '1785025928f6cc7fc0fd9695d37cd41e0a5717c99e2e6f967456c4d925541bab' not found",
    "retryable": false
  }
}

Once aborted, an upload_id is dead — later presign_part and complete calls against it return 409. If your worker restarted and lost the id, don’t try to resurrect it. Start again.

The forensics

Ask storage what it thinks it has:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/tenant-exports/exports/tenant_88/archive.zip" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Compare three things with your own records: size_bytes against the export you built, the digits after the hyphen in etag against the number of parts you uploaded, and last_modified against when the job ran. Two of those three will usually disagree, and which two tells you the cause.

To see whether a half-finished attempt left debris in the prefix:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/list/tenant-exports?prefix=exports/tenant_88/&limit=50" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

One quirk to know while reading that output: the listing omits content_type even where head reports one, so use head for per-object truth.

An uploader that can’t produce this bug

The fix isn’t more retries. It’s refusing to call complete unless the manifest is internally consistent, then verifying the assembled object before telling anyone the export is ready.

import { open, stat } 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 = "tenant-exports";
const PART_SIZE = 8 * 1024 * 1024;
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

async function api(method, path, payload) {
  const res = await fetch(`${API}${path}`, {
    method,
    headers,
    body: payload === undefined ? undefined : JSON.stringify(payload),
    signal: AbortSignal.timeout(30000),
  });
  const json = await res.json();
  if (!json.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
  return json.data;
}

export async function uploadExport(localPath, key) {
  const { size } = await stat(localPath);
  const upload = await api("POST", `/v1/storage/multipart/create/${BUCKET}`, {
    key,
    content_type: "application/zip",
  });

  const file = await open(localPath, "r");
  const manifest = [];
  let sent = 0;
  try {
    for (let partNumber = 1; sent < size; partNumber++) {
      const length = Math.min(PART_SIZE, size - sent);
      const buffer = Buffer.alloc(length);
      await file.read(buffer, 0, length, sent);

      const slot = await api("POST", `/v1/storage/multipart/presign_part/${upload.upload_id}/${partNumber}`, {});
      const put = await fetch(slot.url, { method: "PUT", body: buffer, signal: AbortSignal.timeout(120000) });
      if (!put.ok) throw new Error(`part ${partNumber} rejected with ${put.status}`);

      const etag = (put.headers.get("etag") ?? "").replaceAll('"', "");
      if (!etag) throw new Error(`part ${partNumber} returned no ETag — cannot complete safely`);
      manifest.push({ part_number: partNumber, etag });
      sent += length;
    }
  } finally {
    await file.close();
  }

  const expectedParts = Math.ceil(size / PART_SIZE);
  if (manifest.length !== expectedParts || sent !== size) {
    await api("DELETE", `/v1/storage/multipart/abort/${upload.upload_id}`);
    throw new Error(`manifest is ${manifest.length}/${expectedParts} parts, ${sent}/${size} bytes — aborted`);
  }

  const object = await api("POST", `/v1/storage/multipart/complete/${upload.upload_id}`, { parts: manifest });
  const head = await api("GET", `/v1/storage/object/head/${BUCKET}/${key}`);
  if (!head.found || head.size_bytes !== size) {
    throw new Error(`assembled ${head.size_bytes} of ${size} bytes — do not publish this export`);
  }
  return { ...object, parts: manifest.length };
}

console.log(await uploadExport("./tenant_88-archive.zip", "exports/tenant_88/archive.zip"));

Two details do the work: the manifest is built from the ETag each PUT returned (never from the presign response), and the abort runs before complete whenever the counts disagree. Quoted ETags are tolerated in our testing, but stripping the quotes costs nothing and matches what S3’s own documentation shows.

Cleaning up a failed attempt is one call:

curl -sS -X DELETE \
  "https://api.infrai.cc/v1/storage/multipart/abort/{upload_id}" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Abandoned uploads that are never aborted keep their parts around, so a worker that crashes without a finally block leaves storage you’re paying for and can’t see in an object listing.

Trade-offs and what this costs

Two limitations to keep in view. Part sizing is advisory rather than enforced — the create response advertises a 5 MiB minimum, yet an upload built from 1 MiB parts assembled without complaint in our testing, so don’t treat acceptance as proof your chunking is right. And there’s no server-side checksum verification of the assembled object: if end-to-end integrity matters, hash the export before upload and store the digest alongside it so a later download can be checked.

If your stack is already on AWS, S3’s managed uploader in @aws-sdk/lib-storage handles the manifest for you and is the safer default when uploads are all you need; MinIO is the answer when the bytes can’t leave your own hardware.

storage.multipart.upload_part is $0.0001 per call and complete is $0.0002, verified 2026-07-26, with $2 of free credit on a new account — a 4 GB export in 8 MiB parts is around 500 calls, so the arithmetic is dominated by stored bytes rather than by the upload. Rates move down over time, so read the current ones:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.id | startswith("storage.multipart")) | {id, price: .billing.price_usd}]'

References

Browse more storage developer guides