Reading rate limit headers and backing off
Backoff guessed from a clock wastes the window you still have. Read X-RateLimit-Remaining, X-RateLimit-Reset and retryAfter, then sleep until that reset.
A client that sleeps for one second on every 429 is guessing. Versely already told it the window. The three X-RateLimit-* headers and the retryAfter field on the error body are the limit that actually applied to this request, and a backoff that ignores them either sits idle with remaining quota or retries into the same counter and gets another 429.
The developer page is the REST entry point. This post is the pacing layer you put in front of it: read the headers, sleep until the reset you were given, and keep polling off the generation budget.
The headers that are actually on the wire
Every limited response sets three headers, including the successful ones. Read them on 200 as well as on 429. The remaining count is how you avoid the 429 in the first place.
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Cap for this window, as an integer. |
X-RateLimit-Remaining |
How many calls are left before the next 429. Never negative; it floors at 0. |
X-RateLimit-Reset |
Unix time in seconds (not milliseconds) when this window expires. |
The window is fixed, not sliding. The counter increments, the TTL is armed once, and further hits do not push the expiry forward. That matters for the sleep: Reset is a wall-clock time, not "N seconds of idle". If you wait Limit seconds from now you will often oversleep a window that had 12 seconds left.
Which limiter wrote those headers is not named in the response. A generate call, an agent chat turn and a social post each sit behind a different counter, and the last limiter to run on the route is the one whose numbers you see. Treat the values as authoritative for this response. Do not cache a Limit of 30 from an image generate and apply it to chat, and do not assume the per-key RPM you configured is what the generate route will show. Configure keys on the API pricing path; pace from the headers.
If Redis could not serve the counter, some paths skip the limiter and send no rate-limit headers at all. Missing headers are not "unlimited". They are "no signal". Proceed, but do not invent a sleep.
What a 429 body looks like
There are two JSON shapes. Both carry retryAfter as an integer number of seconds. That is the number to sleep. Do not parse the human message for a duration.
Per-key limiter (the request never reached the route):
{
"success": false,
"error": "Rate limit exceeded",
"retryAfter": 17
}
Named route limiter (the request authenticated, then hit the family cap):
{
"success": false,
"error": {
"code": "429",
"message": "Too many requests. Please try again later."
},
"retryAfter": 17
}
A client that only looks for error.message will throw on the first shape. A client that only looks for a string error will throw on the second. Read retryAfter first, then log the rest.
retryAfter is the Redis TTL of the window, or a fallback of the full window if the TTL could not be read. It is the same information as X-RateLimit-Reset minus now, already computed. Prefer retryAfter on a 429 because you do not have to trust the local clock against the Unix timestamp. Prefer X-RateLimit-Reset on a 200 with Remaining: 0 because there is no body field yet.
A 429 means the call was not accepted. No generation started, no request ID was minted, no credits were charged. Retrying that same HTTP request after the wait is safe. Retrying a different job that already has a request ID is a new charge. Those are different code paths; mix them up and you double-spend.
A backoff that reads instead of guessing
The loop below is the whole policy. Exponential backoff is what you use when a server gives you nothing. Versely gives you the wait.
function secondsUntilReset(headers) {
const reset = Number(headers.get("x-ratelimit-reset"));
if (!Number.isFinite(reset) || reset <= 0) return null;
return Math.max(0, reset - Math.floor(Date.now() / 1000));
}
async function send(url, init) {
for (;;) {
const res = await fetch(url, init);
const remaining = Number(res.headers.get("x-ratelimit-remaining"));
const untilReset = secondsUntilReset(res.headers);
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
const wait = Number(body.retryAfter);
const sleepSec = Number.isFinite(wait) && wait > 0
? wait
: (untilReset ?? 60);
await new Promise((r) => setTimeout(r, (sleepSec + Math.random()) * 1000));
continue; // same request; not a new generation
}
if (res.ok && remaining === 0 && untilReset != null) {
// Next call in this process would 429. Park until the window opens.
await new Promise((r) => setTimeout(r, untilReset * 1000));
}
return res;
}
}
Four rules the snippet is encoding:
- On
429, sleepretryAfterseconds, then retry the same HTTP request. Add a fraction of a second of jitter so a fleet of workers does not wake up in lockstep. - Do not multiply that wait.
retryAfteris already the remainder of a fixed window. Doubling it on the second429just idles you into the next window for no reason. - On success with
Remaining: 0, wait untilResetbefore the next call in this process. That is cheaper than eating the429. - Cap the retry count. A stuck worker that loops on
429forever is a different incident from a single window. Five attempts is plenty; then page a human.
What this is not: a retry of a generation that already returned a request ID. Once you have an ID you poll GET /api/v1/status/:requestId. You do not POST again.
Why polling can starve generation
GET /api/v1/status/:requestId is not behind the generate limiter. It is behind authentication, and if you authenticate with an API key it does increment that key's per-key RPM counter. Default RPM is 60, clamped between 1 and 1000 when the key is created.
A worker that polls every 500 ms will spend 120 of those 60 slots in a minute on status reads, then 429 the POST that was supposed to start the next clip. The headers on the status response will show the per-key cap, not the generate cap. That is the tell: if X-RateLimit-Limit on a status call is 60 (or whatever you set on the key) you are looking at the key counter, and every poll is a debit against the same budget you need for submits.
Practical cadence:
- After
POST, wait at least one second before the first status read. The row is not usefully complete in tens of milliseconds. - Then poll on 2s, 4s, 8s, capped at 8 to 10s. Status reads are cheap compared with a generate
429. - Stop on
completedorfailed. Those are terminal. Polling a terminal ID is wasted RPM. - Give batch workers their own key with a low
rate_limit_rpmif you want a blast-radius cap. The generate route still has its own limiter; the key RPM is the one the poll loop can exhaust.
The same key is shared across the CLI, MCP and raw REST. Limits attach to the key, not the door it came through, which is why picking CLI, MCP or the API is an initiator question, not a quota question. A chat agent and a cron job holding one key are one counter.
A 429 is also not an insufficient-credits error. Credits live on the account and are checked separately. If a call failed because the balance could not cover the job, waiting on Reset will not help; check the balance and top up. Every generation costs credits. There is no free API allowance to sit on while you wait.
For a production integration the pacing layer belongs in one place, next to auth and status polling, not copied into every caller. API-first content generation is the wider architecture; this is the part that keeps that architecture from fighting the limiter. The CLI will mint and store a key. It will not pace your loop for you.
FAQ
Should I back off on 500 and 502 the same way as 429?
No. A 429 is a full window with a stated wait. A 500 or 502 is not a rate limit; the headers on that response, if any, describe a counter that may still have remaining calls. Use a short capped retry (two or three attempts, a second or two apart) and then fail the job out. Do not apply retryAfter logic to a status code that did not send retryAfter.
Can I raise the generate cap by creating a second API key?
A second key has its own per-key RPM counter. The generate limiter is IP-based and shared by every caller from that address, keys included. Two keys from one NAT will still share the generate window. Split keys for blast radius and for scopes, not as a way to double generate throughput from one machine.
Why did Remaining drop by 20 when I only sent one generate?
Fan-out is real: model accepts a string or an array, and one request can dispatch several named models. The HTTP limiter counts requests, not models, so that particular drop is not the rate limiter. If you saw twenty status polls in the same window, that is the per-key RPM. Log X-RateLimit-Remaining next to the path you called; the path tells you which counter moved.
Do I need a Retry-After header as well as retryAfter in the body?
The body field is the contract. Some stacks expose a Retry-After header themselves; Versely's limiter writes retryAfter in JSON and X-RateLimit-Reset on the response. Read those two. Do not wait for a header the route does not document.