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, plus the rate-limit headers to design against.

A signed URL is reusable until it expires, so minting 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 — in that order, because the traffic hammering you is a user holding F5, not the storage API. Infrai’s presign route is free and rate limited, and it tells you your remaining headroom on every response.

That last part is the bit most people miss, so start there.

The API tells you your budget

Every call to POST /v1/storage/object/presign/{bucket}/{key} comes back with three headers:

x-ratelimit-limit: 60
x-ratelimit-remaining: 52
x-ratelimit-reset: 1

Read x-ratelimit-remaining in your client and you know exactly how much room is left in the current window, without inferring anything from timing. Design so the number of calls stays proportional to distinct objects rather than to page views, and you’ll never see it drop far.

Here’s one call, for reference:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -D - -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-exports-0726/exports/2026-07/report.zip" \
  -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-exports-0726/exports/2026-07/report.zip?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=224b5850815cca46",
    "expires_at": "2026-07-27T01: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, in 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 res = await fetch(`${API}/v1/storage/object/presign/${bucket}/${key}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify({ op: "get", expires_seconds: TTL_SECONDS }),
  });
  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;
  }
  const remaining = Number(res.headers.get("x-ratelimit-remaining") ?? NaN);
  if (Number.isFinite(remaining) && remaining < 10) {
    console.warn(`presign headroom low: ${remaining} left this window`);
  }
  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 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 at a 250 ms base tops out around four seconds of waiting, which is a reasonable ceiling for someone standing in front of a download button.

Put a limiter on your own route

The presign endpoint isn’t what’s 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

StrategyPresign callsHerd protectionComplexity
Sign on every renderOne per viewNoneTrivial
TTL cacheOne per object per TTLPoor under cold-start burstsSmall
TTL cache + single-flightOne per object per TTLGoodSmall
Long-lived link stored in the databaseOne, evern/aRisky — 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 never rotate. The accepted TTL range is 1 to 604800 seconds, and staying at the short end of it 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, so the cache buys you latency and headroom rather than dollars. Verified 27 July 2026, the metered routes are writes at $0.0001 per call and API-mediated reads at $0.104 per GB of response body — reads bill by volume, so streaming exports through your own API instead of redirecting to a signed URL is the choice that actually shows up on the invoice. 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'), c['billing'].get('unit','')) 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. Then watch the 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')])"

That usage view is the same one covering the queue that builds the exports and the cron that expires them, because it’s all one key and one account — no second dashboard to open, and per-tenant attribution stays a query rather than a spreadsheet of three vendors’ CSV exports. POST /v1/metrics/report is where you push your own cache hit-rate alongside it.

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. There’s no server-side link revocation either, so a signed URL that leaks stays valid until expires_at, and short TTLs are the only lever; Amazon S3 and Cloudflare R2 have exactly the same property, since it’s inherent to signed URLs rather than specific to a vendor. And if your exports page needs a permanent shareable address rather than a timed one, none of this helps — you’d be better off putting an authenticated route of your own in front of the object and redirecting per request.

References

Browse more storage developer guides