How to Add Retry Logic to the Claude API
If you're calling the Claude API in production, you will eventually hit a 429 rate limit, a 529 overloaded error, or a dropped connection mid-stream. The fix isn't to catch the exception and give up — it's to add retry logic that distinguishes between errors worth retrying and errors that mean your request is broken. This guide shows exactly how to implement that in Node.js, with the backoff math and status-code logic you need.
The short answer: retry on 429, 500, 502, 503, and 529 using exponential backoff with jitter, cap retries at 3–5 attempts, respect any retry-after header, and never retry on 400, 401, or 403 since those indicate a request or auth problem that a retry won't fix.
Which Claude API errors are retryable
Not every error should trigger a retry. Here's how to classify them:
Retry these:
429 Too Many Requests— you've hit a rate limit500 Internal Server Error— transient server-side fault502 Bad Gateway/503 Service Unavailable— upstream infrastructure hiccup529 Overloaded— Claude-specific status for high load- Network-level failures: timeouts,
ECONNRESET, DNS resolution failures
Don't retry these:
400 Bad Request— malformed payload, retrying sends the same broken request401 Unauthorized— invalid or expired API key403 Forbidden— permission issue404 Not Found— wrong endpoint or resource
Retrying a 400 or 401 in a loop just burns time and, if you're not careful, API quota. Fail fast on those and surface the error to your logs immediately.
Exponential backoff with jitter
A naive retry loop that fires immediately on failure makes rate limiting worse — every client retries at the same instant and you get a thundering herd. Exponential backoff spaces out retries, and jitter randomizes the spacing so concurrent clients don't collide.
async function callWithRetry(fn, { maxRetries = 5, baseDelayMs = 500 } = {}) {
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) || !status;
if (!retryable || attempt >= maxRetries) {
throw err;
}
const retryAfter = err.response?.headers?.get('retry-after');
const delay = retryAfter
? Number(retryAfter) * 1000
: baseDelayMs * 2 ** attempt + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, delay));
attempt++;
}
}
}
Usage:
const response = await callWithRetry(() =>
fetch('https://api.subtoapi.app/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SUBTOAPI_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-sonnet-4',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Summarize this ticket.' }],
}),
}).then((res) => {
if (!res.ok) {
const error = new Error(`HTTP ${res.status}`);
error.status = res.status;
error.response = res;
throw error;
}
return res.json();
})
);
This pattern works whether you're calling Claude directly or through a gateway like SubToAPI — the retry logic lives entirely in your client, independent of which endpoint is behind it. If you're on SubToAPI, check your plan's rate limit details and response headers in the docs before tuning maxRetries.
Respecting retry-after headers
When a 429 includes a retry-after header, use it instead of your own backoff calculation — the server is telling you exactly how long to wait. Ignoring it and retrying on your own schedule just extends the time until you succeed. The code above already checks for this header first and falls back to exponential backoff only when it's absent.
Handling retries during streaming
Retrying a non-streaming request is straightforward — you resend the same payload. Streaming is trickier because a connection can drop mid-response, after you've already received partial tokens.
The safe approach is to buffer tokens as they arrive and track whether you received a terminal event (message_stop). If the stream drops before that event, treat the entire response as incomplete and retry the full request from scratch rather than trying to resume from a partial position — Claude's API doesn't support resuming a stream mid-sequence.
async function streamWithRetry(requestFn, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
let completed = false;
try {
const chunks = [];
for await (const event of requestFn()) {
if (event.type === 'message_stop') completed = true;
chunks.push(event);
}
if (completed) return chunks;
throw new Error('Stream ended without message_stop');
} catch (err) {
if (attempt === maxRetries) throw err;
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
}
}
}
See /docs/streaming for the exact event sequence to check for completion.
Idempotency for safety
If your Claude calls trigger side effects — writing to a database, sending an email, charging a customer — a retry after a timeout can be dangerous: the first request might have actually succeeded even though you never got the response. Generate an idempotency key per logical request (not per retry attempt) and store it alongside the result, so a duplicate send can be detected and skipped at your application layer. The Claude API itself doesn't do this for you — it has to be built into your retry wrapper.
Where retry logic belongs in your stack
Put retry logic in one place — a shared HTTP client or SDK wrapper — rather than scattering try/catch blocks across every call site. This makes it trivial to tune backoff parameters, add logging on retry, and swap in metrics (retry count, time-to-success) without touching business logic. If you're using SubToAPI as your gateway, you get a single sub_live_... API key and consistent error codes across models, so your retry wrapper only needs one implementation instead of one per provider. Start with the quickstart and plug the retry function above directly into your existing fetch calls.
FAQs
Should I retry on every error from the Claude API? No. Only retry on 429, 500, 502, 503, 529, and network-level failures. 400, 401, and 403 indicate a problem with the request itself that a retry won't fix — log and surface those immediately.
How many retries is reasonable? 3–5 attempts with exponential backoff is standard. Beyond that, you're likely masking a persistent outage or a misconfigured request, and the delay cost outweighs the benefit.
Does retrying a streaming request resume from where it stopped? No. Claude's streaming API doesn't support resuming mid-stream. If a connection drops before the message_stop event, discard the partial output and retry the full request.