-
Wrap every call in a timeout + cancellation token. No naked await. A hung socket must die on a deadline you own, not the SDK default (often 600s+). Two clocks: a per-attempt timeout (the request) and a total deadline (all retries combined). Stream long calls so the per-attempt timeout measures time-to-first-byte, not total generation.
const TOTAL_DEADLINE_MS = 30_000;
const PER_ATTEMPT_MS = 12_000;
async function callWithDeadline(fn, remainingMs) {
const ctrl = new AbortController();
const budget = Math.max(0, Math.min(PER_ATTEMPT_MS, remainingMs));
const t = setTimeout(() => ctrl.abort(), budget);
try { return await fn(ctrl.signal); }
finally { clearTimeout(t); }
}
Pass signal into the SDK (client.messages.create({...}, { signal })). On the user side wire the inbound request's abort signal through so a closed browser tab cancels the upstream call instead of burning tokens.
-
Retry only what's retryable, with exponential backoff + full jitter, and honor Retry-After. Classify the error before you retry — retrying a 400 is just slower failure.
| Status / condition | Retry? | Wait |
|---|
| 429 rate-limited | Yes | Retry-After header if present, else backoff |
| 529 overloaded (Anthropic) / 503 | Yes | backoff + jitter |
| 500 / 502 / 504 / gateway | Yes | backoff + jitter |
| Network reset / timeout / ECONNRESET | Yes | backoff + jitter |
| 408 request timeout | Yes | backoff |
| 400 / 422 bad request | No | fix the request, not the retry |
| 401 / 403 auth | No | rotate key / fix scope |
| 413 too large | No | trim input |
Refusal / stop_reason | No retry — fall back (step 4) | — |
Defaults: max 4 attempts, base 500ms, cap 8s, full jitter (sleep = random(0, min(cap, base * 2**attempt))). Full jitter beats fixed/equal backoff because synchronized clients (a 429 storm) otherwise retry in lockstep and re-stampede. Always clamp the wait to the remaining total deadline — never sleep past it.
const start = Date.now();
const elapsed = () => Date.now() - start;
for (let attempt = 0; attempt < 4; attempt++) {
try { return await callWithDeadline(fn, TOTAL_DEADLINE_MS - elapsed()); }
catch (e) {
if (!isRetryable(e) || attempt === 3 || elapsed() > TOTAL_DEADLINE_MS) throw e;
const ra = retryAfterMs(e);
const backoff = Math.random() * Math.min(8000, 500 * 2 ** attempt);
await sleep(Math.min(ra ?? backoff, TOTAL_DEADLINE_MS - elapsed()));
}
}
LLM calls are non-idempotent and billed — a retry after a partial success double-charges. Only retry attempts that demonstrably failed before producing a usable response (connection error, non-2xx, timeout-before-first-byte). Never retry a call that already streamed a full body.
-
Validate structured output; repair once; then fail safe — never crash on malformed JSON. When you asked for JSON, do not feed the raw model string straight into JSON.parse + a schema and let it throw to the user.
- Parse → validate against the schema (Zod / Pydantic / JSON Schema).
- On failure, one repair round: send the model the broken output + the validator error, ask for corrected JSON only. Strip code fences and prose first.
- Still invalid → return a typed safe default (e.g.
{ status: "unavailable" }) or route to degraded mode. Log the raw output. Do not loop repairs (cost + latency blowup).
Prefer the SDK's native enforcement (tool/tool_choice forcing, strict JSON mode) over free-text + regex — it eliminates most repairs. Repair is the safety net, not the plan.
-
Fall back to another model on persistent failure or refusal. When the primary is exhausted (retries spent, circuit open) or returns a refusal, try a fallback before giving up. Order by capability-then-availability, e.g. primary Sonnet → fallback Haiku, or cross-provider if you run multi-vendor.
- A refusal (
stop_reason: "refusal", or the model declining) is not a transport error — do not retry the same model; either fall back or return the refusal as a first-class result.
- Treat
stop_reason: "max_tokens" as a truncated (not failed) result: the JSON is incomplete — repair or raise max_tokens and retry once, don't ship the cutoff.
- Cap fallback depth at 1–2 models. Record which model actually served the response.
-
Stream with a heartbeat; discard partials on mid-stream error. Long generations should stream so the user sees progress and you detect stalls. Set an inter-chunk idle timeout (e.g. 20s with no new token → abort) — a stream can hang open without erroring. If the stream errors or aborts mid-way, discard the accumulated partial and either retry from scratch (step 2 rules) or degrade; never persist or render a half-message as if complete. Buffer to a scratch variable and only commit on the terminal message_stop.
-
Circuit-breaker around the provider → degraded mode. Per-provider breaker: after N consecutive failures (e.g. 5) or a failure rate over a window, open the circuit and stop calling for a cooldown (e.g. 30s), then half-open one probe. While open, skip the doomed call and serve degraded mode immediately: a cached previous answer, a canned/templated response, or a clear "this feature is temporarily unavailable" — chosen per feature, decided before the incident. This stops a provider outage from turning into 30s timeouts on every request and exhausting your own connection pool.
-
Never lose user input on failure. Before the call, persist the user's prompt/turn so any failure path (timeout, all-retries-exhausted, circuit open) returns a retryable state, not a black hole. The user should be able to resend with one tap, or the system auto-resumes — input is never silently dropped. For expensive multi-step agent runs, checkpoint so you resume from the failed step, not step 1.
Done = fault-injection tests 1–9 pass, every LLM call has an explicit per-attempt timeout + total deadline, retries use full-jitter backoff that honors Retry-After and never fires on non-retryable or already-served calls, malformed/refused/truncated output degrades to a typed safe path instead of throwing, the circuit breaker serves degraded mode under a forced outage without paying timeouts, and no failure path loses user input.