Rate limits and backoff
429 means two different things here, and treating them the same way produces either a client that
hangs for a day or one that hammers a wall.
The two 429s
| Per-minute rate | Monthly quota | |
|---|---|---|
| Status | 429 | 429 |
retry-after | 1 | Seconds until the month resets — possibly days |
x-ratelimit-remaining | 0 | Usually non-zero |
x-quota-remaining | Non-zero | 0 |
| Retrying helps | Yes, in a second | No |
x-quota-remaining: 0 is the discriminator. Every correct client checks it, and most
incorrect ones do not.
Do not back off exponentially from the rate limit
The per-minute bucket refills continuously, not at a wall-clock boundary. retry-after: 1 is
accurate: a second really is enough. A client that doubles its way from 1 to 60 seconds spends 59 of them idle on
allowance it already paid for — and it does this precisely when it is busiest.
Exponential backoff is for 503, where the server is actually unwell and pressure makes it worse.
A correct client
async function call(url, key) {
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, { headers: { "x-api-key": key } });
if (response.status === 429) {
// Not transient. Retrying burns the rest of the month.
if (response.headers.get("x-quota-remaining") === "0") {
throw new QuotaExhausted(Number(response.headers.get("retry-after")));
}
if (attempt >= 5) throw new Error("rate limited repeatedly");
// Transient, and one second is the honest wait.
await sleep(Number(response.headers.get("retry-after") ?? 1) * 1000);
continue;
}
// The server, not you. Back off properly here.
if (response.status === 503 && attempt < 3) {
await sleep((2 ** attempt) * 1000 + Math.random() * 500);
continue;
}
return response;
}
}
The jitter on the 503 path matters if you have several workers: without it they retry in lockstep and
arrive together every time.
Not hitting it in the first place
Backoff is the fallback. These are cheaper:
- Bound your concurrency. Twenty parallel requests exhaust a per-minute bucket in the first second and spend the remaining 59 retrying. A semaphore of 4–8 with a queue behind it finishes sooner.
- Ask for bigger pages.
limit=100is one tenth of the requests oflimit=10for the same results, and each page is billed. - Use /stories. One clustered request replaces a search plus client-side deduplication.
- Cache by id. An article fetched with
/doc/{id}is immutable. There is no reason to fetch it twice. - Read the headers you already have. Every response carries
x-ratelimit-remaining; a client that slows down at 10% remaining never sees a429.
Pacing from the headers
let pauseUntil = 0;
function pace(response) {
const remaining = Number(response.headers.get("x-ratelimit-remaining") ?? Infinity);
const limit = Number(response.headers.get("x-ratelimit-limit") ?? 0);
// Below 10% of the bucket, spread the rest of the minute out rather than
// sprinting into a 429 and retrying.
if (limit && remaining < limit * 0.1) {
pauseUntil = Date.now() + 1000;
}
} On MCP
The rate limit is still a 429 with retry-after — retry the same JSON-RPC message. The
quota is not: it arrives as a tool error carrying "code": "quota_exceeded" on an open
session, so an agent can report it rather than concluding the server is down.
The shapes are here.