Export downloads that time out: range requests and resumable pulls
Why a 900 MB CSV or PDF export dies on the way out, how to prove the signed link supports HTTP ranges, and a Node 22 downloader that resumes.
Export downloads time out because the bytes are travelling through a process that has a request deadline. Move them off it: write the finished CSV or PDF into a bucket, hand the caller a presigned URL, and let the storage host stream. Infrai’s signed GET links answer HTTP range requests, so a broken transfer resumes from the byte it stopped at instead of starting over.
That last part is the half most guides skip. A signed link that can’t resume is only slightly better than a proxy, because at 60 MB into a 900 MB file a dropped Wi-Fi connection still costs you the whole download.
The timeout is in your app server, not the bucket
Infrai does expose a read endpoint that returns object bytes as base64 inside JSON — GET /v1/storage/object/get/{bucket}/{key} — and for a 4 KB receipt it’s the simplest thing that works. It’s the wrong tool here. We pulled a 918 KB CSV through it and the JSON response came back at 1,253,728 bytes in roughly 4.8 seconds: base64 inflates the payload by a third, and your process has to hold all of it in memory before it can write a byte to the client.
Scale that to a 900 MB export and you have a 1.2 GB string, a heap ceiling, and a gateway that gives up long before the last row.
So the export job’s last step isn’t “send the file”. It’s “put the file somewhere the client can pull it from directly, then hand over a URL”.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-bigexport-0726/exports/2026-07/orders-tnt_42.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}'
{
"ok": true,
"data": {
"url": "https://infrai-1333115350.cos.ap-singapore.myqcloud.com/a4ee0c441fa36c267.kb-bigexport-0726/exports/2026-07/orders-tnt_42.csv?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-Signature=c5d2042a805adb3e4f81b32a13e73ed0",
"expires_at": "2026-07-26T01:09:30.893412Z"
}
}
Proving the link really resumes
Don’t take resumability on faith — one request settles it:
SIGNED_URL="$1"
curl -sS -D - -o /dev/null -r 0-99 "${SIGNED_URL}"
HTTP/1.1 206 Partial Content
accept-ranges: bytes
content-range: bytes 0-99/939941
content-type: text/csv
content-disposition: attachment
etag: "72a65d4fdfce62261067e531a48ea2ee"
x-amz-force-download: true
206 with a content-range is the whole answer: the host honours Range, so a client can ask for bytes=612345- and carry on. MDN’s range-requests page is the reference for the header grammar, and it applies unchanged here because the signed URL is an ordinary S3-style GET.
Two details from that response matter later. content-disposition: attachment is fixed — a browser navigating to the link downloads the file rather than rendering it, and there’s no presign parameter to change that. And the etag is your resume guard: if it changes between attempts, the export was regenerated and your half-file is garbage.
A downloader that survives a dropped connection
This is the piece worth copying. It heads the object first (free), asks for a signed link, then streams to disk with a Range header on every retry:
import { createWriteStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
const API = "https://api.infrai.cc";
const BUCKET = "kb-bigexport-0726";
const KEY = "exports/2026-07/orders-tnt_42.csv";
const OUT = "/tmp/orders-tnt_42.csv";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const auth = { Authorization: `Bearer ${token}` };
const PRESIGN_BODY = JSON.stringify({ op: "get", expires_seconds: 900 });
async function meta() {
const res = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${KEY}`, { method: "GET", headers: auth });
const json = await res.json();
if (!res.ok || !json.data?.found) throw new Error(`object missing: HTTP ${res.status}`);
return json.data;
}
async function signedUrl() {
const res = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${KEY}`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: PRESIGN_BODY,
});
const json = await res.json();
if (!res.ok || json.ok === false) throw new Error(json?.error?.code ?? `presign HTTP ${res.status}`);
return json.data.url;
}
async function bytesOnDisk() {
try { return (await stat(OUT)).size; } catch { return 0; }
}
const object = await meta();
let attempt = 0;
while (attempt < 6) {
const from = await bytesOnDisk();
if (from >= object.size_bytes) break;
attempt += 1;
try {
const res = await fetch(await signedUrl(), { method: "GET", headers: { Range: `bytes=${from}-` } });
if (res.status !== 206 && res.status !== 200) throw new Error(`HTTP ${res.status}`);
if (res.headers.get("etag")?.replace(/"/g, "") !== object.etag) throw new Error("etag changed — export was regenerated");
await pipeline(Readable.fromWeb(res.body), createWriteStream(OUT, { flags: from ? "a" : "w" }));
} catch (err) {
console.warn(`attempt ${attempt} stopped at ${await bytesOnDisk()} bytes: ${err.message}`);
await new Promise((r) => setTimeout(r, Math.min(30000, 500 * 2 ** attempt)));
}
}
const final = await bytesOnDisk();
if (final !== object.size_bytes) throw new Error(`incomplete: ${final}/${object.size_bytes}`);
console.log(`downloaded ${final} bytes, etag ${object.etag}`);
The head call is the part people leave out, and it’s the one that makes the loop safe: you can’t verify a download you never knew the size of.
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/kb-bigexport-0726/exports/2026-07/orders-tnt_42.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "exports/2026-07/orders-tnt_42.csv",
"size_bytes": 939941,
"etag": "72a65d4fdfce62261067e531a48ea2ee",
"content_type": "text/csv",
"last_modified": "2026-07-26T00:59:19Z"
}
}
What expires, and what that breaks
expires_seconds accepts 1 to 604800; anything outside that comes back as STORAGE_INVALID_TTL with HTTP 400. Once the window closes the host answers 403 AccessDenied with Request has expired — which is exactly why the loop above re-signs on every attempt rather than caching one URL for an hour.
A transfer already in flight isn’t cut off when the clock runs out, but a retry after that point is, and a 900 MB pull over a hotel connection will outlive a 60-second link.
Two more sharp edges we hit in testing. HEAD against the signed URL returns 403 — the signature covers the GET method only, so use the free object/head route for size and etag. And there’s no Access-Control-Allow-Origin on the signed response and no route to set bucket CORS, so front-end code can’t fetch() the link and read the body; point an anchor tag or window.location at it instead. If your UI needs a JavaScript-visible progress bar over a cross-origin download, you’d be better off on Cloudflare R2 or S3, where you control the CORS configuration.
Four ways to hand over a large export
| Design | Timeout risk | Resumable | Your bandwidth |
|---|---|---|---|
| Stream from the app process | High — one request deadline for the whole file | No | All of it |
object/get and re-serve base64 | High — full body buffered, +33% size | No | All of it |
| Presigned GET link | Low | Yes, via Range | None |
| Presigned link plus a resume loop | Lowest | Yes, with etag verification | None |
What the export path costs
Verified 26 July 2026: storage.object.put is billed at $0.0001 per call and storage.object.get at $0.0002 per call — reads are published at twice the price of writes — while presign, head, list and every bucket call are free and rate-limited, and don’t draw down the $2 of trial credit a new account gets. Stored GB-months and egress GB are metered separately, and for a nightly 900 MB export they dominate the per-call fees completely. Read today’s numbers 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')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Rates here move down and discount campaigns run, so what you find is likely lower than what’s printed above. GET /v1/account/usage is the honest check — on our own account the metered charge for reads came out an order of magnitude below the published per-call rate, while writes matched it exactly.
The reason to run the export on Infrai isn’t the rate, though. It’s that the cron trigger that starts the job, the queue that retries a failed leg, the bucket that holds the artefact and the email that tells the user it’s ready all sit behind one key and one invoice — and per-tenant attribution is a query rather than four vendor exports stapled together in a spreadsheet.
Where a specialist wins
If exports are your product — terabytes a day, multi-region edge delivery, byte-range caching at a POP near the user — Cloudflare R2 with a Worker in front, or S3 with CloudFront, is the stronger build, and their cold-storage tiers have no equivalent here. Backblaze B2 wins on pure $/GB for archives nobody downloads. Storage on Infrai is the right pick when the export is one job in an application that also sends mail, runs jobs and calls models, and you’d rather not run four accounts to ship one CSV.