Download link 404 after an export job: prefix, race, or expiry?

A 404 on an export download link is almost never eventual consistency. Tell a wrong key prefix from a job race or an expired signature, with live checks.

A 404 on a download link means the signature was accepted and the object wasn’t there. That single sentence resolves most of these tickets. On Infrai’s object storage the split is clean: 403 is a signature problem, 404 is a key problem — a wrong prefix, or an export job that hadn’t finished writing when your API handed the link to the browser. Read the code first, then check the key.

The reflex answer of “eventual consistency” is usually wrong in 2026. S3 has served strong read-after-write consistency for new objects since December 2020, Google Cloud Storage documents strong consistency for object reads, and a write through Infrai’s PUT /v1/storage/object/put/{bucket}/{key} returns only after the object exists — the response carries the etag and byte count. If a HEAD immediately after the write says the object is missing, the write didn’t happen where you think it did.

The status code names the bug

Two failures wear the same red badge in a browser’s network tab, and they have nothing to do with each other.

Browser seesBody codeWhat actually happenedWhere to look
404NoSuchKeySignature valid, key absentThe prefix you built, and whether the writer finished
403AccessDenied + Request has expiredLink outlived expires_secondsTTL vs how long the user sat on the page
403SignatureDoesNotMatchURL edited after signing, or signed for a different keyAnything that rewrites the query string

That’s the whole triage. A 403 is never a missing object, and a 404 is never an expiry — so the first thing to do with a support screenshot is ask for the response body, not the timestamp.

GET /v1/storage/object/head/{bucket}/{key} is free and answers the only question that matters. It does not 404: it returns HTTP 200 with a found boolean, which makes it pleasant to call from a link handler.

export INFRAI_API_KEY=your_infrai_api_key

curl -s -X GET \
  "https://api.infrai.cc/v1/storage/object/head/kb-exports-404/exports/2026-07/orders-9f2a.csv" \
  -H "Authorization: Bearer $INFRAI_API_KEY"

Against a key the export job never wrote, the same call comes back like this — 200, and found: false:

{
  "ok": true,
  "data": {
    "found": false,
    "status": "not_found",
    "key": "exports/2026-07/orders-MISSING.csv"
  }
}

Now sign a link for that missing key and watch what the browser gets. The presign call succeeds, because signing is a local operation that never touches the object:

URL=$(curl -s -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-exports-404/exports/2026-07/orders-typo.csv" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"op":"get","expires_seconds":300}' \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).data.url))')

curl -s -o /dev/null -w "%{http_code}\n" "$URL"

Which prints 404, with this in the body:

<?xml version='1.0' encoding='utf-8' ?>
<Error>
	<Code>NoSuchKey</Code>
	<Message>The specified key does not exist.</Message>
</Error>

Set expires_seconds to 1, wait three seconds and fetch the same link, and the code changes to 403 with Request has expired. Two different bugs, two different codes, no guesswork.

The prefix typo that produces exactly this

Most “the file vanished” reports we reproduce come down to two code paths building the key differently — one with a trailing plural, one without, or one that interpolates a tenant id the other omits.

Listing is free and settles it in one call:

curl -s -X GET \
  "https://api.infrai.cc/v1/storage/object/list/kb-exports-404?prefix=exports/" \
  -H "Authorization: Bearer $INFRAI_API_KEY"

In a bucket where the export worker wrote export/2026-07/orders-typo-prefix.csv while the download handler looked under exports/, that response lists three objects and none of them is the one the user wants. Repeat with ?prefix=export/ and the missing file appears on its own — proof that the bytes are fine and the key builder is not. A delimiter=/ query returns common_prefixes instead of keys, which is the fastest way to see both spellings side by side. Worth flagging: with a delimiter, prefixes can repeat across pages, so accumulate into a Set rather than trusting one page.

Where the race actually lives

Handing out a link before the writer commits is a real race, but it’s a race in your job graph, not in the storage layer.

Two patterns cause it. The first is a worker that reports success on the HTTP response of the generation step and enqueues the “email the link” job before the upload promise resolves. The second is subtler and specific to presigned uploads: when a client uploads straight to a presigned URL, that write goes to the vendor endpoint rather than through the API, so it emits no object.created event. Anything you wired to POST /v1/storage/bucket/set_notification/{bucket} will never fire for it, and a link service that waits for that event ships a URL for an object nobody confirmed. Call HEAD instead — it’s free, it’s authoritative, and it costs about 70ms.

A link handler that can’t produce this class of bug looks like this:

import process from "node:process";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };

async function call(path, init) {
  const res = await fetch(`${BASE}${path}`, init);
  const body = await res.json();
  if (!body.ok) throw new Error(`${path} failed: ${body.error?.code} ${body.error?.message}`);
  return body.data;
}

export async function downloadLink(bucket, key, ttlSeconds = 300) {
  const head = await call(`/v1/storage/object/head/${bucket}/${key}`, { method: "GET", headers });
  if (!head.found) {
    const err = new Error(`no object at ${bucket}/${key} — check the prefix, not the signature`);
    err.code = "EXPORT_NOT_READY";
    throw err;
  }
  const signed = await call(`/v1/storage/object/presign/${bucket}/${key}`, {
    method: "POST",
    headers,
    body: JSON.stringify({ op: "download", expires_seconds: ttlSeconds }),
  });
  return { url: signed.url, expiresAt: signed.expires_at, bytes: head.size_bytes };
}

const link = await downloadLink("kb-exports-404", "exports/2026-07/orders-9f2a.csv");
console.log(link.expiresAt, link.bytes);

Return 409 with EXPORT_NOT_READY from your API when found is false, and the front end can poll instead of showing the user a dead link.

What it costs, and what the signature does buy

Head, list and presign are free calls on Infrai, rate-limited but not metered, so a defensive HEAD before every signature is free insurance. The billed side splits by unit, and the split matters for exports specifically: storage.object.put is $0.0001 per call, while storage.object.get is metered on egress at $0.104 per GB of response body. A nightly CSV that grew from 200 KB to 90 MB changed your bill even though the number of downloads didn’t move. Figures verified 2026-07-27, and rates here move down rather than up, so check today’s number rather than trusting this paragraph:

curl -s -X GET "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer $INFRAI_API_KEY" \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const d=JSON.parse(s);for(const c of d.capabilities)if(c.id.startsWith("storage.object."))console.log(c.id,c.billing.is_billable?c.billing.price_usd:"free")})'

One more thing about the URL you’re staring at, because it’s what makes the triage above work at all: the signature is enforced. Drop the query string and the object answers 403. Change a character in it and the answer is still 403. Past expires_at, 403 with Request has expired. That’s exactly why a 404 tells you something — the request got far enough for the store to look for a key and not find one. Treat the signed link as a bearer token anyway: whoever holds it reads that one object until it lapses, so short TTLs and server-derived keys stay the practice, and a URL pasted into a support ticket is a URL you’ve shared.

Cross-origin fetch is the one thing these links won’t do from a page. The storage host answers the preflight with 403, so anchor tags and window.location downloads work and XHR doesn’t — if your front end needs to read the bytes in JavaScript, proxy the download through your own API.

When another store is the better answer

If file delivery is the whole product, S3 with a bucket policy or Cloudflare R2 with its presigned-URL flow is the deeper tool — R2 in particular, because egress is free and the CORS rule set reaches the object endpoint, which is the leg that matters when downloads are fetched by JavaScript rather than clicked. GCS is the pick if you’re already inside Google Cloud and want IAM-conditioned URLs.

The argument for keeping exports here is different: the same key that signs the link also runs the report query, queues the job, sends the notification email and records the error when the render fails, and all of it lands on one bill with one usage view. If you need only a download link and nothing else, a specialist is cheaper.

References

Browse more storage developer guides