Claude API Retry Logic with Exponential Backoff
Why Claude API requests fail and need retries
Every production integration with the Claude API eventually hits transient failures: rate limits (429), server-side hiccups (529 overloaded, 500, 502), or network timeouts. These aren't bugs in your code — they're expected behavior under load, and the correct response isn't to fail the request, it's to retry it intelligently.
Exponential backoff with retry logic means: when a request fails with a retryable error, wait a short interval, try again, and if it fails again, wait longer each time. This avoids hammering an already-struggling endpoint while still recovering automatically from short-lived issues. Below is a working implementation you can drop into a Node.js or TypeScript project, plus the rules for which errors deserve a retry and which don't.
Which Claude API errors should you retry?
Not every failure should trigger a retry. Retrying a 400 (bad request) or 401 (invalid auth) just wastes time and quota — the request will fail identically every time. Retry logic should target errors that are likely to succeed on a second attempt:
429 Too Many Requests— rate limit exceeded, retry after backing off529 Overloaded— Anthropic's servers are temporarily overloaded500/502/503— generic server-side errors- Network-level failures — timeouts, connection resets, DNS failures
Do not retry:
400 Bad Request— malformed payload, fix the request instead401 Unauthorized— invalid or missing API key403 Forbidden— permission issue, retrying won't help404 Not Found— wrong endpoint or resource
Some responses include a retry-after header. When present, respect it instead of guessing — it tells you exactly how long the server wants you to wait.
A basic exponential backoff implementation
Here's a reusable retry wrapper in JavaScript that works against any HTTP API, including Claude:
async function withRetry(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 retryable = [429, 500, 502, 503, 529].includes(status);
if (!retryable || attempt >= maxRetries) {
throw err;
}
const retryAfter = err.response?.headers?.get?.('retry-after');
const delay = retryAfter
? Number(retryAfter) * 1000
: Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
// add jitter to avoid thundering herd
const jitter = delay * (0.5 + Math.random() * 0.5);
await new Promise((resolve) => setTimeout(resolve, jitter));
attempt++;
}
}
}
Use it around any Claude API call:
const response = await withRetry(() =>
fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Summarize this ticket.' }],
}),
}).then((res) => {
if (!res.ok) {
const error = new Error('Request failed');
error.status = res.status;
error.response = res;
throw error;
}
return res.json();
})
);
Why jitter matters
Pure exponential backoff (1s, 2s, 4s, 8s...) has a hidden problem: if hundreds of your app instances hit a rate limit at the same moment, they'll all retry at exactly the same intervals, creating synchronized bursts that keep tripping the rate limit. Jitter — adding a random component to each delay — spreads retries out over time so they don't collide. The implementation above uses "full jitter" (random between 50% and 100% of the calculated delay), which is a good default for most workloads.
Setting sane limits
A few practical guardrails to add on top of the basic loop:
- Cap total retry time, not just retry count. A user-facing request shouldn't retry for 60 seconds if your UI times out at 15.
- Log every retry with the status code and attempt number — this is invaluable for diagnosing whether you're hitting rate limits because of traffic spikes or because your concurrency settings are too aggressive.
- Distinguish streaming from non-streaming. If a stream fails mid-response after already sending partial tokens to a user, retrying from scratch may duplicate content. Handle stream retries separately, ideally before any output has been flushed to the client.
- Respect
retry-afterfirst, backoff calculation second. The server knows more about its own load than you do.
When retry logic isn't enough
Retries handle transient failures, but they don't fix structural problems. If you're seeing frequent 429s under normal traffic, the real fix is better rate limit management: request queuing, concurrency caps, or spreading load across API keys. If you're seeing frequent 529s, that's Anthropic-side capacity — retries with backoff are the right tool, but there's a ceiling to how much they can compensate for sustained overload.
This is also where routing through a managed layer helps. SubToAPI sits between your app and Claude, giving each application its own sub_live_... key with usage metadata per key — useful for isolating which service or team is generating the retry-triggering traffic in the first place. It doesn't replace your retry logic, but it makes the "who is causing the 429s" question a lot easier to answer, and streaming responses (/docs/streaming) and tool calls (/docs/tools) work the same way as talking to Claude directly, so your retry wrapper doesn't need to change. Check the quickstart or Messages API docs if you're setting up a new integration.
Summary
Exponential backoff with jitter is the standard approach for handling transient Claude API failures: retry on 429, 5xx, and 529 errors, don't retry on 4xx client errors (except 429), respect retry-after headers when present, and always cap your maximum retry time. The implementation above is intentionally minimal — wrap it around your existing Claude API calls and adjust maxRetries and baseDelayMs based on your latency budget.
questions
Should I retry on every non-2xx response from the Claude API? No. Only retry on 429, 5xx, and 529 responses. Client errors like 400 or 401 indicate a problem with the request itself that a retry won't fix.
How many retries is reasonable for a production app? Three to five retries with exponential backoff is typical. Beyond that, the added latency usually outweighs the chance of success, and you're better off surfacing an error to the user or queuing the request for later.
Does exponential backoff work for streaming responses? Yes, but only before any partial output has been sent to the end user. Once a stream has started delivering tokens, retrying from the beginning can duplicate content, so streaming retries should happen before the first chunk is flushed downstream.