Claude API Retry with Exponential Backoff Example
When a call to the Claude API fails with a rate limit error (429) or an overloaded error (529), the correct fix is not to retry immediately — it's to wait, and wait longer each time you fail again. This is exponential backoff: each retry attempt waits roughly twice as long as the one before it, often with some randomness (jitter) mixed in so you don't create synchronized retry storms across many clients.
Below is a complete, working example in JavaScript you can drop into a Node.js project, plus the reasoning behind each part. If you just want the short version: catch the specific retryable status codes, compute delay = baseDelay * 2^attempt, add jitter, cap the max delay and the max attempt count, and respect any retry-after header if the API sends one.
Why naive retries make things worse
A common mistake is wrapping an API call in a while loop that retries instantly on any error:
// Don't do this
while (true) {
try {
return await callClaude(prompt);
} catch (e) {
continue; // retries immediately, forever
}
}
This has two problems. First, if the error is a rate limit, retrying instantly just hits the same limit again — you're not giving the server time to free up capacity. Second, if you have many concurrent requests all failing and retrying at once, you create a thundering herd that makes the overload worse, not better. Exponential backoff with jitter solves both.
Which errors are actually worth retrying
Not every failure should trigger a retry. Retrying a 400 (bad request) or 401 (invalid API key) just wastes time and masks a real bug. The errors worth retrying are:
- 429 — rate limit exceeded
- 529 — the model is temporarily overloaded
- 500/502/503 — server-side errors
- Network-level failures — timeouts, connection resets, DNS failures
Everything else should fail fast and surface the error to your code or logs.
A complete retry implementation
async function callWithBackoff(fn, {
maxRetries = 5,
baseDelayMs = 500,
maxDelayMs = 20000,
} = {}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
const status = err.status || err.response?.status;
const isRetryable =
status === 429 ||
status === 529 ||
(status >= 500 && status < 600) ||
err.code === 'ECONNRESET' ||
err.code === 'ETIMEDOUT';
if (!isRetryable || attempt >= maxRetries) {
throw err;
}
const retryAfterHeader = err.response?.headers?.['retry-after'];
const serverDelay = retryAfterHeader ? Number(retryAfterHeader) * 1000 : 0;
const exponential = baseDelayMs * 2 ** attempt;
const jitter = Math.random() * exponential * 0.3; // up to 30% jitter
const delay = Math.min(serverDelay || exponential + jitter, maxDelayMs);
console.warn(`Retry ${attempt + 1}/${maxRetries} after ${Math.round(delay)}ms (status ${status})`);
await new Promise((resolve) => setTimeout(resolve, delay));
attempt++;
}
}
}
Usage against the Claude API directly:
const response = await callWithBackoff(() =>
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({
model: 'claude-sonnet-4-5',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Explain backoff in one sentence.' }],
}),
}).then((res) => {
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
err.response = res;
throw err;
}
return res.json();
})
);
A few details worth calling out:
- Jitter matters. Pure exponential backoff (500ms, 1s, 2s, 4s...) means every client hits the retry wall at the same moments if they all started failing together. Adding randomness spreads retries out over time.
- Cap the max delay. Without a cap,
2^attemptgrows fast enough that your 6th retry could wait over a minute. Capping at something like 20–30 seconds keeps user-facing latency reasonable. - Respect
retry-afterif present. Some APIs tell you exactly how long to wait. When that header is present, trust it over your own calculation. - Limit total attempts. Five retries is usually enough. If you're still failing after that, something is systemically wrong and you should surface the error rather than keep hammering the API.
Handling streaming responses
Backoff gets trickier with streaming, because a connection can fail mid-stream after you've already received partial tokens. In that case, don't restart the whole generation from scratch — track what you've already received, and either discard it and retry cleanly (simplest) or build dedup logic around a message_id if your workflow needs exact continuity. For most applications, discarding a partial stream and retrying the full request is the pragmatic choice.
If you'd rather not write this yourself
Retry logic is easy to write wrong in subtle ways — off-by-one attempt counts, missing jitter, not distinguishing retryable from non-retryable errors, forgetting to cap delays. If you're building against Claude through SubToAPI, rate limiting, retries on transient failures, and usage metadata are handled server-side, so your client code just makes a normal HTTPS call. You get an API key (sub_live_...), streaming, tool use, and a dashboard with usage stats — see the quickstart or docs to get started, or jump straight to pricing and the free trial at signup.
FAQ
How many retries should I use for the Claude API? Three to five is typical. More than that usually means the failure isn't transient, and you're better off failing fast and alerting than retrying blindly.
What's the difference between exponential backoff and exponential backoff with jitter? Plain exponential backoff doubles the wait time on each retry but is deterministic — every client with the same attempt count waits the same amount. Jitter adds randomness to that wait so concurrent clients don't retry in lockstep, which reduces server-side spikes.
Should I retry on a 400 or 401 error? No. Those indicate a malformed request or an authentication problem, not a transient failure. Retrying won't fix them — you need to fix the request or the API key instead.