Refresh spam on a download page: cache signed URLs, don't re-sign
One presign per click is a self-inflicted load pattern. A TTL cache, single-flight collapsing and 429 backoff for Node 22, with what Infrai actually rate limits.
A signed URL is reusable until it expires, so signing a fresh one on every render of your exports page is work nobody asked for. Cache the URL for slightly less than its lifetime, collapse concurrent requests for the same object into one call, and rate limit your own endpoint — that ordering matters, because the traffic hammering you is a user holding F5, not the storage API. Infrai’s presign route is free and rate limited; the practical ceiling you’ll hit first is your own.
The rest of this page is the implementation, plus what we measured when we deliberately hammered the endpoint on 26 July 2026.
What we actually saw under a burst
Sixty presign requests fired at twenty-way concurrency against POST /v1/storage/object/presign/{bucket}/{key} all returned HTTP 200. Serial calls took roughly a second each from our test host; under that concurrency the slowest reached about 2.9 seconds. No 429 and no rate-limit headers came back.
Worth flagging: Infrai publishes the presign route as “free (rate-limited)” without a numeric ceiling, so don’t design against a specific requests-per-second figure. Design so the number of calls stays proportional to distinct objects, not to page views.
Here’s one call, for reference:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/downloads-demo/reports/tenant_42/2026-07-invoice.pdf" \
-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.downloads-demo/reports/tenant_42/2026-07-invoice.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=224b5850815cca46",
"expires_at": "2026-07-26T01:12:31.949540Z"
}
}
A cache with single-flight, which is the whole fix
Two failure modes, one component. The obvious one is repeat traffic: ten page loads for the same report should produce one signed URL, not ten. The subtler one is the thundering herd — twenty concurrent requests arriving before the first response lands, each starting its own call because the cache is still empty. A map of in-flight promises fixes the second, and it’s about fifteen lines:
const API = "https://api.infrai.cc";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const TTL_SECONDS = 600;
const SKEW_SECONDS = 60; // re-sign before the link is actually dead
const cache = new Map(); // key -> { url, notAfter }
const inFlight = new Map(); // key -> Promise
async function sign(bucket, key) {
const request = { op: "get", expires_seconds: TTL_SECONDS };
const res = await fetch(`${API}/v1/storage/object/presign/${bucket}/${key}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(request),
});
const out = await res.json();
if (!res.ok || out.ok === false) {
const err = new Error(out?.error?.code ?? `HTTP ${res.status}`);
err.status = res.status;
err.retryAfter = Number(res.headers.get("retry-after") ?? 0);
throw err;
}
return out.data.url;
}
export async function downloadUrl(bucket, key) {
const id = `${bucket}/${key}`;
const hit = cache.get(id);
if (hit && hit.notAfter > Date.now()) return hit.url;
const pending = inFlight.get(id);
if (pending) return pending;
const job = sign(bucket, key)
.then((url) => {
cache.set(id, { url, notAfter: Date.now() + (TTL_SECONDS - SKEW_SECONDS) * 1000 });
return url;
})
.finally(() => inFlight.delete(id));
inFlight.set(id, job);
return job;
}
With that in place, a page refreshed thirty times in ten minutes costs one presign call. The 60-second skew exists because a URL handed out at second 599 is useless — the click arrives after expiry and the user gets STORAGE_PRESIGN_EXPIRED instead of a PDF.
Swap the Map for Redis when you run more than one instance. The logic doesn’t change; the eviction does.
Backing off without a stampede
Rate limits on the platform surface as HTTP 429 with codes like RATE_LIMIT_ACCOUNT and RATE_LIMIT_USER, and they honour Retry-After. Retry them, but add jitter — synchronised retries just reproduce the burst a second later:
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function withBackoff(fn, { attempts = 4, baseMs = 250 } = {}) {
let lastError;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
const retryable = err.status === 429 || (err.status >= 500 && err.status < 600);
if (!retryable || attempt === attempts - 1) throw err;
const hinted = err.retryAfter ? err.retryAfter * 1000 : baseMs * 2 ** attempt;
const jitter = Math.random() * hinted * 0.3;
await sleep(hinted + jitter);
}
}
throw lastError;
}
Four attempts with a 250 ms base tops out around four seconds of waiting, which is a reasonable ceiling for a user standing in front of a download button.
Put a limiter on your own route
The presign endpoint isn’t the thing being abused — your /api/exports/:id/link is. A per-user token bucket in front of it turns a refresh-happy customer into a 429 you control, before any of it reaches storage:
const buckets = new Map(); // userId -> { tokens, updatedAt }
const CAPACITY = 10; // burst
const REFILL_PER_SECOND = 1; // sustained
export function allow(userId) {
const now = Date.now();
const b = buckets.get(userId) ?? { tokens: CAPACITY, updatedAt: now };
const refill = ((now - b.updatedAt) / 1000) * REFILL_PER_SECOND;
const tokens = Math.min(CAPACITY, b.tokens + refill);
if (tokens < 1) {
buckets.set(userId, { tokens, updatedAt: now });
return false;
}
buckets.set(userId, { tokens: tokens - 1, updatedAt: now });
return true;
}
Ten in a burst, one per second sustained — generous for a human, restrictive for a script.
Four strategies, ranked by calls per page view
| Strategy | Presign calls | Herd protection | Complexity |
|---|---|---|---|
| Sign on every render | One per view | None | Trivial |
| TTL cache | One per object per TTL | Poor under cold-start bursts | Small |
| TTL cache + single-flight | One per object per TTL | Good | Small |
| Long-lived link stored in the database | One, ever | n/a | Risky — a leaked link stays valid |
Row four is tempting and mostly wrong. A signed URL is a bearer token for that object: anyone holding it can download until it expires, so a 30-day link in a database row is a 30-day credential you don’t rotate. Short lifetimes plus caching gives you the same call volume with a much smaller blast radius.
Measuring the effect
Signing, head, list and bucket reads are free and rate-limited; verified 26 July 2026, the reads that do cost money are storage.object.get at $0.0002 per call and writes at $0.0001, with bytes and egress metered separately. Since presign is free, the cache is buying you latency and headroom rather than dollars — the dollars show up if you were streaming files through your own API instead. Confirm the current structure yourself:
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') ])"
Prices here trend down rather than up, so treat that output as today’s truth. And watch call volume drop after you ship the cache:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print([b for b in d['breakdown'] if b['key'].startswith('storage')])"
If you’re still deciding whether to serve exports from a bucket at all, the cost comparison against keeping files on the app server is covered separately at docs.infrai.cc/en/guides/storage/answers/cheapest-simplest-file-export-delivery-signed-urls-vs-s/.
Where this approach falls short
An in-process cache is per instance, so eight containers means up to eight presign calls per object per TTL — still a rounding error against per-view signing, but not one call. Infrai doesn’t publish the numeric rate limit for storage routes, which means you can’t compute headroom in advance; you can only observe 429s and back off. There’s no server-side link-revocation API either, so a signed URL that leaks stays valid until expires_at — short TTLs are the only lever. Amazon S3 and Cloudflare R2 have the same property, incidentally; it’s inherent to signed URLs rather than specific to any one vendor.