Claude API Error Retry Logic Implementation Guide
Claude API error retry logic implementation comes down to three decisions: which errors are safe to retry, how long to wait between attempts, and when to give up. Get these wrong and you either hammer the API into a longer outage or silently drop requests your users were waiting on.
This article gives you a working retry implementation you can drop into a Node.js or Python backend today, plus the reasoning behind each design choice so you can adapt it to your own traffic patterns.
Which Claude API errors should you retry?
Not every error deserves a retry. Retrying a malformed request just burns time and quota. Split errors into three buckets:
- Retryable, transient:
429 Too Many Requests,500 Internal Server Error,503 Service Unavailable, connection timeouts, and DNS/socket errors. These usually resolve on their own within seconds. - Retryable with caution:
529 Overloaded(capacity-related) — retry with longer backoff since the condition may persist for minutes, not seconds. - Never retry:
400 Bad Request,401 Unauthorized,403 Forbidden,404 Not Found,422 Unprocessable Entity. These are client-side mistakes — a bad schema, an expired key, a missing field. Retrying them wastes a round trip and can trigger rate limiting for no benefit.
A quick way to check status codes in code:
function isRetryable(status) {
return status === 429 || status === 500 || status === 503 || status === 529;
}
Exponential backoff with jitter
Fixed-interval retries synchronize client requests into bursts, which makes rate limiting worse, not better. Exponential backoff with jitter spreads retries out over time.
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;
if (!isRetryable(status) || attempt >= maxRetries) throw err;
const expDelay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
const jitter = Math.random() * expDelay * 0.5;
const delay = expDelay / 2 + jitter;
await new Promise((resolve) => setTimeout(resolve, delay));
attempt++;
}
}
}
Usage:
const response = await withRetry(() =>
callClaude({ prompt: "Summarize this ticket" })
);
Key parameters to tune:
- baseDelayMs: start small (300–500ms) so transient blips resolve fast.
- maxDelayMs: cap it around 15–30 seconds so a hung request doesn't block a web request handler indefinitely.
- maxRetries: 3–5 is typical. More than that and you should be surfacing an error to the caller instead of silently retrying.
Respect the Retry-After header
A 429 response often includes a retry-after header telling you exactly how long to wait. Honor it instead of guessing:
function getRetryDelay(err, fallbackMs) {
const retryAfter = err.headers?.["retry-after"];
if (retryAfter) {
const seconds = parseInt(retryAfter, 10);
if (!Number.isNaN(seconds)) return seconds * 1000;
}
return fallbackMs;
}
Ignoring this header and retrying on your own schedule is one of the most common causes of repeated rate limiting — you end up retrying too early, over and over.
Timeouts matter as much as retries
A retry loop without a request timeout can hang indefinitely on a stalled connection. Always set an explicit timeout per attempt, separate from your overall retry budget:
async function callWithTimeout(fn, timeoutMs = 30000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fn(controller.signal);
} finally {
clearTimeout(timer);
}
}
Combine this with your retry wrapper so each attempt has its own timeout, and the whole operation has an upper bound (timeout × maxRetries + cumulative backoff).
Idempotency for streaming and side-effecting calls
If your Claude integration triggers a side effect (writing to a database, sending an email via a tool call) before the response completes, a retried request could duplicate that side effect. Two mitigations:
- Idempotency keys: generate a UUID per logical request and pass it through your own request layer so a retried call is recognized as the same operation, not a new one.
- Defer side effects until the response is fully validated: don't act on tool call output until you've confirmed the full response was received without truncation or a mid-stream disconnect.
For streaming responses specifically, a dropped connection mid-stream is harder to retry cleanly than a non-streaming call — you either need to buffer partial output and resume from a checkpoint, or discard the partial stream and retry the whole request. Discarding and retrying is simpler and usually the right default unless you're processing very long generations.
Logging and observability
Retry logic without logging just hides problems. At minimum, log:
- Status code and error type on each failed attempt
- Attempt number and delay before the next try
- Final outcome (success after N retries, or exhausted retries)
This lets you distinguish "brief blip, resolved on attempt 2" from "sustained outage, failed after 5 attempts" — which should trigger different alerts.
Where SubToAPI fits
If you're building this retry layer because you're calling Claude directly and hitting inconsistent rate limits or overload errors across multiple apps, it's worth checking whether you're duplicating infrastructure that should live in one place. SubToAPI turns your existing Claude access into a standard HTTPS API with sub_live_... keys, so you get consistent error codes, streaming, and usage metadata across every project without rebuilding retry handling app by app. See the quickstart or the messages and streaming docs for the exact response shapes to handle in your retry logic. Plans start at €9/month with a free trial — check pricing or sign up to try it.
questions
Should I retry on a 400 Bad Request error? No. A 400 means the request itself is malformed — bad JSON, invalid parameters, or a schema mismatch. Retrying without fixing the request will fail identically every time and wastes your retry budget.
How many retries is reasonable for a production app? 3–5 retries with exponential backoff and jitter is standard. Beyond that, surface the error to the caller or queue the request for later rather than blocking on retries indefinitely.
Does retry logic differ for streaming responses? Yes. A connection drop mid-stream usually means discarding the partial output and retrying the whole request from scratch, since resuming a partial generation isn't generally supported. Buffer only if you have a specific reason to preserve partial output.