Retrying an image API on 429: backoff, jitter and credits
On a 429, wait for the number of seconds in the Retry-After header plus a little random jitter, then send the same request again with the same Idempotency-Key. Against a mock of the Blinkimg API's limiter, that policy cr
On a 429, wait for the number of seconds in the Retry-After header plus a little random jitter, then send the same request again with the same Idempotency-Key. Against a mock of the Blinkimg API's limiter, that policy created 120 jobs in 144 requests. Retrying at once took 3,869 requests and still lost 72 jobs. The key is what stops a retry from paying twice.
What does the API send back on a 429?
I read this from the API's source (apps/api), not from memory. A request-rate refusal comes from @fastify/rate-limit, which keeps a fixed window per caller and sets Retry-After in whole seconds. The error handler mirrors that into the JSON body:
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded",
"requestId": "...",
"resetAt": "2026-10-17T09:12:31.000Z"
}
}
The budget for an API key is 120 writes a minute (uploads, jobs, batches) and 1,200 reads a minute (polling). Two keys on one account share one budget. Reads and writes are counted apart, so polling a running job cannot use up the budget for creating the next one.
Is every 429 worth retrying?
No. The same status carries different codes, and only one of them clears in seconds:
| Status and code | Meaning | What to do |
|---|---|---|
429 RATE_LIMITED |
Per-minute window is full | Wait Retry-After, retry |
429 QUOTA_EXCEEDED |
A key's monthly credit cap is reached; Retry-After points at next month |
Stop, tell a person |
429 SIGNUP_REQUIRED |
Anonymous hourly or daily image cap | Stop; use a key |
402 INSUFFICIENT_CREDITS |
The account balance is empty | Stop, top up |
Read error.code before you sleep. A loop that obeys a Retry-After of several days is a hung job, not a retry.
Why does retrying at once fail so badly?
Because every refused request also counts against the window. I wrote a mock that answers like the API: a fixed window of 30 writes a second, the same envelope, Retry-After rounded up to whole seconds. Then 8 workers created 120 jobs under four policies, each giving up after 50 tries. The table is the median of 5 runs:
┌─────────┬─────────────────────────────┬─────────┬──────────┬─────────┬─────────┬────────┐
│ (index) │ name │ seconds │ requests │ limited │ charged │ gaveUp │
├─────────┼─────────────────────────────┼─────────┼──────────┼─────────┼─────────┼────────┤
│ 0 │ 'retry at once' │ 1 │ 3869 │ 3821 │ 48 │ 72 │
│ 1 │ 'exponential, no jitter' │ 4.57 │ 216 │ 96 │ 120 │ 0 │
│ 2 │ 'exponential, full jitter' │ 7.25 │ 181 │ 61 │ 120 │ 0 │
│ 3 │ 'Retry-After + jitter' │ 3.58 │ 144 │ 24 │ 120 │ 0 │
│ 4 │ 'no key, 10% answers lost' │ 4.9 │ 163 │ 32 │ 131 │ 0 │
│ 5 │ 'Idempotency-Key, 10% lost' │ 4.75 │ 163 │ 32 │ 120 │ 0 │
└─────────┴─────────────────────────────┴─────────┴──────────┴─────────┴─────────┴────────┘
Retrying at once burned 50 tries per job in about a second and finished only 48 of 120. Exponential backoff fixed that. Full jitter, as described in AWS's backoff and jitter article, sent fewer requests but took longest, because a random wait is often far shorter or longer than needed. Retry-After plus jitter won on both counts: the server knows when the window rolls, so the client stops guessing.
The retry function
import { randomUUID } from 'node:crypto';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (n) => Math.random() * Math.min(8000, 100 * 2 ** n);
export async function createJob(api, key, body) {
const idem = randomUUID(); // one key per logical job, reused on every retry
for (let n = 0; n < 8; n++) {
let res;
try {
res = await fetch(`${api}/jobs`, {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
'Idempotency-Key': idem,
},
body: JSON.stringify(body),
});
} catch {
await sleep(backoff(n)); // network error: the job may exist, the key makes that safe
continue;
}
if (res.ok) return res.json();
const err = (await res.json()).error ?? {};
if (res.status === 429 && err.code === 'RATE_LIMITED') {
const after = Number(res.headers.get('retry-after'));
await sleep(after > 0 ? after * 1000 + Math.random() * 250 : backoff(n));
continue;
}
throw Object.assign(new Error(err.message ?? `HTTP ${res.status}`), err);
}
throw new Error('gave up after 8 tries');
}
api is https://api.blinkimg.com/v1, and the body is { uploadId, options } as the API docs show. I ran this exact function against the mock, 8 workers and 120 jobs with 10% of answers lost: { requests: 163, limited: 32, charged: 120 }. Anything that is not a RATE_LIMITED refusal throws with the server's code attached, so the caller can tell a full window from an empty balance.
Why send an Idempotency-Key on a POST?
A network error after the server answered leaves you blind: did the job get created? If you retry without a key, you might create it twice and pay twice. Rows 4 and 5 of the table measured that. The mock drops 10% of answers after it has already charged:
- Without a key: 131 charges for 120 jobs.
- With a key: 120 charges for 120 jobs.
The real API does the same thing for POST /v1/jobs, /v1/uploads, /v1/batches and attachments. Its code says a repeat of the same key and body after a success replays the stored answer with Idempotent-Replayed: true, and "nothing ran and nothing was charged". The key check runs before quota and credits. The same key with a different body is 409 IDEMPOTENCY_KEY_REUSED; the same key while the first request still runs is 409 IDEMPOTENCY_IN_PROGRESS. Job answers are kept 24 hours, upload answers 14 minutes.
Note: Generate the key once per logical job, outside the retry loop. A fresh UUID on every attempt is the most common bug here, and it makes the header do nothing.
How do credits behave on a retry?
A compression costs 1 credit and a conversion costs 2. A refused request (429 or 402) is turned away before any work runs. A replayed request costs nothing. A job that dies in the worker is refunded by the worker (refundDeadJob). So with the key in place, a retry loop cannot spend more than the jobs you meant to create.
How I measured
- Machine: Apple M1 Pro, macOS 15.7.3, on 2026-10-17. Node 24.22.0.
- A local mock server: fixed 1-second window of 30 writes, the API's 429 envelope,
Retry-Afterasmax(1, ceil(seconds left))like the API's error handler, and a seeded 10% answer drop for rows 4 and 5. - 120 jobs, 8 concurrent workers, 50 tries per job in the test harness; each row is the median of 5 runs.
- API behaviour (limits, codes, idempotency, refunds) read from
apps/api/srcandpackages/shared/src/quota.tson 2026-10-17.
What I did not test
I did not run this against the live API, so real round trips would stretch the seconds column. The mock's window is 1 second, not the API's 60, to keep each run short.
I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.