HTTP 429 Too Many Requests means the server is refusing your request because you went over a limit. The fix is to wait the number of seconds in the Retry-After header, retry once, and then slow your client down so it stops happening. Before you do any of that, check which limit it is. Many APIs, ours included, also use 429 when your monthly quota is used up, and retrying that one never works.
The two 429s
Here are both, as our API sends them. The first is the per-minute limit on your plan:
{
"status": "error",
"error": "Rate limit exceeded. Max 60 requests per minute.",
"data": null
}
The second is a spent quota:
{
"status": "error",
"error": "Monthly credit limit reached. Credits will reset on your renewal date.",
"data": null
}
Our edge has a second, much higher per-key ceiling that answers "Rate limit exceeded. Slow down and retry."; it behaves like the first.
The rate limits go away in seconds. The quota lasts until your renewal date, and a client that retries it will spend the whole time collecting rejections while the job that depends on it quietly does nothing.
Don’t match on the message text; check the header. On our API, x-api-remaining-credits: 0 marks the quota case:
if (res.status === 429) {
if (res.headers.get('x-api-remaining-credits') === '0') {
throw new Error('Out of credits until renewal. Alert someone; do not retry.');
}
const wait = Number(res.headers.get('retry-after') || 10);
await new Promise((r) => setTimeout(r, wait * 1000));
// ...retry once
}
Other APIs mark it differently. GitHub, for example, sends 403 or 429 with x-ratelimit-remaining: 0 for its primary limit. Look for a remaining-quota header in your API’s docs before you write the retry logic, not after the first outage. The retry post has a full client that makes this distinction along with the other errors worth and not worth retrying.
Pace yourself instead of bouncing off the limit
Retrying after a 429 works, but it means every burst spends time being refused. If you know the limit, it’s cheaper not to cross it. A token bucket does that: it holds up to burst tokens, refills at perSecond, and each call waits for a token.
// paced.js: a client-side token bucket so you never send faster than the limit
function pacer({ perSecond, burst = perSecond, concurrency = Infinity }) {
let tokens = burst;
let last = Date.now();
let inFlight = 0;
const waiting = [];
function refill() {
const now = Date.now();
tokens = Math.min(burst, tokens + ((now - last) / 1000) * perSecond);
last = now;
}
function pump() {
refill();
while (waiting.length && tokens >= 1 && inFlight < concurrency) {
tokens -= 1;
inFlight++;
waiting.shift()();
}
if (waiting.length) setTimeout(pump, Math.max(5, ((1 - tokens) / perSecond) * 1000));
}
// Wrap any async function: it starts only when a token is free.
return async function run(fn) {
await new Promise((resolve) => { waiting.push(resolve); pump(); });
try { return await fn(); } finally { inFlight--; pump(); }
};
}
module.exports = { pacer };
Use it by wrapping each call:
const run = pacer({ perSecond: 60, burst: 10, concurrency: 8 });
const results = await Promise.all(ips.map((ip) => run(() => lookup(ip))));
The concurrency option matters as much as the rate. Our plans have a separate ceiling on how many calls can be in flight at once, and it’s the one that trips first when you fan out across workers. Most APIs with per-second limits have something similar, documented or not.
We ran 2,400 lookups against our VPN detector three ways. The key we used is an internal one with no per-minute plan limit, so the only limiter in play was the edge’s per-key ceiling:
| Run | Calls/second | Time | Result |
|---|---|---|---|
| 24 parallel workers, no pacing | 84 | 28.7 s | 2,400 OK |
| 48 parallel workers, no pacing | 115 | 20.9 s | 2,380 OK, 20 network errors |
| Paced at 60/s, burst 10 | 60 | 40.1 s | 2,400 OK |
The paced run was the slowest; only the 48-worker run left errors to clean up. Neither unpaced run hit the edge limiter, even at 115 calls a second.
Rate limits are approximate, on purpose
Earlier the same day, a different job of ours sent about 14,000 lookups with the same key in 212 seconds, an average of 66 a second from 24 concurrent requests. A few hundred of them came back 429. A few hours later, 84 and 115 calls a second produced none.
Nothing was broken. Our per-key limiter runs on Cloudflare’s edge, and Cloudflare documents its rate-limiting binding as approximate: each check uses a local count, counts sync between instances in the background, and each Cloudflare location keeps its own tally. A burst can get past the limit before the counts catch up, and a client whose requests land in two locations is effectively measured twice, separately. Whether a given burst trips the limit depends on how the requests happen to spread.
We’d already met this spreading when we tried batching requests at the edge. In a test burst of 100 parallel requests from one client, Cloudflare spread the requests across about 40 separate isolates, so any one isolate saw only two or three of them. Batches built from isolate memory averaged 1.5 requests. When we moved the batching into a single Durable Object, which every request for that key goes through, a 200-request burst became 17 batches of up to 11, about 12 times fewer calls to our origin.
If you’re calling an API, don’t calibrate to where you start seeing 429s. A rate that’s fine today may trip tomorrow when your traffic lands differently. Pace a little under the documented number.
If you’re building one, an in-memory rate limiter on each of your servers is as approximate as this, only worse: ten instances each allowing 100 a minute allow 1,000. If you need a strict limit, count in one place, such as Redis or a Durable Object, and accept the extra hop. If you don’t need it strict, an approximate limiter is cheaper and fine, as long as you document the number you mean rather than the number you enforce.
If you run the limiter: make the 429 useful
Send Retry-After on every 429. Clients that honour it come back at the right moment; without it, each client guesses, and the guesses arrive together.
Make the quota case distinguishable without reading the message, with a header or a different error code. We return 429 for both cases and a header to tell them apart; a separate status would be cleaner, but changing it now would break every client that already handles the 429.
Never put a made-up number in a limit header. We did once, and our own edge started refusing our own key; the retry post has that story.
And let people see their limits before they hit them. Responses that count credits carry the remaining-credit header, so a client can slow down or alert someone at 10% left.