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 sees | Body code | What actually happened | Where to look |
|---|---|---|---|
| 404 | NoSuchKey | Signature valid, key absent | The prefix you built, and whether the writer finished |
| 403 | AccessDenied + Request has expired | Link outlived expires_seconds | TTL vs how long the user sat on the page |
| 403 | SignatureDoesNotMatch | URL edited after signing, or signed for a different key | Anything 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.
Confirm the object before you sign the link
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":"download","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 doesn’t 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. Reads through the API are the billed side: storage.object.get is published at $0.0002 per call and storage.object.put at $0.0001 per call — figures verified 2026-07-26, 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")})'
Now the caveat that catches people in this exact debugging session: on Infrai a signature controls expiry, not access. In our testing an object stayed readable over plain HTTPS with the query string stripped off, even after POST /v1/storage/object/set_acl/{bucket}/{key} was set to signed-only. Treat the base URL as a bearer token with no expiry — don’t paste it into a ticket, and don’t rely on the TTL to claw back a leaked link. There’s also no route that sets bucket CORS rules today, so a browser fetching one of these URLs cross-origin gets a blocked preflight; anchor tags and window.location downloads work, XHR doesn’t.
When another store is the better answer
If your whole product is file delivery and you want signed URLs that really are access control, plus CORS you can configure yourself, S3 with a bucket policy or Cloudflare R2 with its presigned-URL flow will fit better than this — R2 in particular because egress is free and it publishes a CORS editor. 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.