"I added retry logic, and somehow my 429 rate is higher than before" — this is the question I get most often from teams that just put Claude API into production. I have hit the same wall myself: my first client wrapped requests in a "wait three seconds, retry up to three times" loop, and during peak traffic the rate limit hits actually went up.
It feels counterintuitive. Retries are supposed to be the polite thing to do. The problem is rarely the retry itself — it is that multiple clients waiting the same fixed interval will all hammer the server at the same instant. Anthropic's docs recommend "exponential backoff," but what actually works in practice is "exponential backoff with full jitter, plus respecting the Retry-After header."
This article walks through the retry traps I have repeatedly fallen into while integrating Claude API into my own apps, in the order you should diagnose them. The code samples are minimal Node.js (no SDK), but the same patterns apply to Python or any other language.
Start by inspecting the headers, not by writing code
Before you change a single line of retry code, look at what the server is telling you. When the API returns 429 or 529, the response usually contains:
retry-after: minimum seconds to wait before retrying (a number, or an HTTP date)anthropic-ratelimit-requests-remaining: how many requests you have left in the windowanthropic-ratelimit-requests-reset: UNIX timestamp when that count resetsanthropic-ratelimit-tokens-remaining: how many tokens you have leftanthropic-ratelimit-tokens-reset: UNIX timestamp when the token window resets
The first move is to log every one of these whenever a request fails. I skipped this step at first and walked around with a vague feeling that "things are congested." Once I started recording them, it turned out one specific request was burning through the entire token quota in a single call. The real bug was not retry logic at all — it was a giant uncached prompt being sent in parallel. Some retry "problems" are really design problems hiding behind a noisy log.
// Always capture the relevant headers when a request fails.
async function callClaude(body) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": process.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
// For 429 / 529, log every header that helps explain the failure.
console.error({
status: res.status,
retryAfter: res.headers.get("retry-after"),
reqRemaining: res.headers.get("anthropic-ratelimit-requests-remaining"),
reqReset: res.headers.get("anthropic-ratelimit-requests-reset"),
tokRemaining: res.headers.get("anthropic-ratelimit-tokens-remaining"),
tokReset: res.headers.get("anthropic-ratelimit-tokens-reset"),
requestId: res.headers.get("request-id"),
});
}
return res;
}Capture the request-id too. The day you have to email Anthropic support, you will be glad you stored it.
Anti-pattern 1: a fixed-delay retry loop
Almost every codebase starts here.
// BAD: same wait every time, no matter what.
async function retryFixed(body, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const res = await callClaude(body);
if (res.ok) return res;
if (res.status === 429 || res.status === 529) {
await new Promise((r) => setTimeout(r, 3000)); // always 3s
continue;
}
return res;
}
throw new Error("max retries exceeded");
}The problem is not the value 3000. The problem is that every client that hit a 429 at the same moment now retries together exactly three seconds later. From the server's point of view, you have just generated a synchronized spike that repeats every three seconds — the classic thundering herd. You meant to be polite; what you actually did was build a small DDoS.
Anti-pattern 2: exponential backoff without jitter
Once people learn about "exponential backoff," they often write this:
// MARGINAL: exponential, but no randomness.
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s...
await new Promise((r) => setTimeout(r, delay));It is better than a flat delay, but every client still waits the exact same amount of time, so the spikes just become a tidy staircase. The shape of the load on the server is still synchronized; you have only stretched it out.
In my own apps, just adding jitter on top of this dropped the rate of repeat 429s during peak hours by roughly half.
The pattern that actually works: Retry-After first, then full jitter
The "full jitter" backoff popularized by the AWS Architecture Blog works very well for Claude API. The idea is simple: grow the upper bound of the wait exponentially, but pick a random delay inside that bound.
// GOOD: respect Retry-After first, fall back to full-jitter exponential backoff.
function computeBackoffMs(attempt, retryAfter) {
// 1) If the server tells us how long to wait, honor it.
if (retryAfter) {
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds)) return seconds * 1000;
// HTTP-date format
const dateMs = Date.parse(retryAfter);
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
}
// 2) Otherwise: full-jitter exponential backoff.
const cap = 60_000; // 60s ceiling
const base = 500; // 0.5s starting point
const exp = Math.min(cap, base * 2 ** attempt);
return Math.floor(Math.random() * exp); // random in [0, exp)
}
async function retryWithBackoff(body, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await callClaude(body);
if (res.ok) return res;
// Only retry 5xx and 429. 4xx (401, 400, etc.) are permanent.
const retriable = res.status === 429 || res.status === 529 || (res.status >= 500 && res.status < 600);
if (!retriable) return res;
const wait = computeBackoffMs(attempt, res.headers.get("retry-after"));
console.warn(`[retry ${attempt + 1}/${maxRetries}] status=${res.status} waiting=${wait}ms`);
await new Promise((r) => setTimeout(r, wait));
}
throw new Error("max retries exceeded");
}The behavior you want: if the server says "wait three seconds," you wait three seconds. Otherwise the waits become 0–0.5s, 0–1s, 0–2s, 0–4s, and so on, with each client picking a different value inside its window. The synchronized spike vanishes.
Things to check if 429s are still happening after the fix
If you have made the changes above and still see elevated 429 rates, work through this list:
- Lack of idempotency: if Claude's response feeds something with side effects (a database write, a payment), retrying creates duplicates. Use
message.idfor deduplication, or assign your own UUID up front and track an idempotency key. - Concurrency above your tier's limit: each account tier has an implicit cap on simultaneous requests. Even perfect backoff cannot save you if your worker pool is constantly above that cap. Use something like
p-limitto cap concurrency explicitly — it is the fastest fix. - Aborting before the response body is read: cutting an HTTP connection mid-response can cause the server to count the request as completed late, wasting your quota. Use
AbortControllerfor explicit timeouts, and still drainres.bodyeven on error. - Token quota exhaustion mistaken for request limits: if
anthropic-ratelimit-tokens-remainingis hitting zero, throttling requests will not help. The fix is shorter prompts or prompt caching. I cover the diagnosis flow for cache misses in How to diagnose Claude API prompt cache misses.
Streaming responses need a different decision
For streaming endpoints, the retry decision hinges on whether the first message_start event arrived. Restarting a stream from scratch after a mid-stream disconnect double-bills tokens and double-spends your rate quota. I walk through safe handling of mid-stream failures in Production-grade resilience for Claude API streaming; pair it with this article if you are streaming in production.
A pattern I keep around for new projects
Over time I have collected one helper module that I drop into every Claude API project on day one: it wraps fetch, applies the retry pattern above, exposes a concurrency option that is enforced internally, and emits structured logs to the same place as the rest of the request telemetry. It is around eighty lines, but the value is that the first version I deploy already handles 429 sanely — I am not waiting for production traffic to teach me the lesson again. If you maintain more than one Claude integration, building this small abstraction once pays back every time.
One concrete next step
Open your current Claude API client and check just one thing: does it read the retry-after header? If not, drop in the computeBackoffMs snippet above. That single change usually pulls peak-hour error rates down visibly. If concurrency is the real culprit, follow up by wrapping your call site in p-limit. Doing those two in that order has been my reliable recipe.