Skip to content

C5 · lesson 6 · 11 min read

Reliability patterns

Classify API errors, retry only what is transient, set timeouts, fall back gracefully, keep side effects idempotent and log enough to debug.

Ընտրեք տեքստը՝ հայերեն թարգմանությունը տեսնելու համար։Highlight text to see Armenian. Machine-assisted translation; English is the source of truth.

Official video by Anthropic on YouTube. Pressing play loads it from youtube-nocookie.com. ·open on YouTube

An LLM call is a network dependency like any other, with the difference that the work behind it is slow and expensive. Treat it the way you would treat a flaky upstream service: classify errors, retry only what is transient, set timeouts, plan a fallback, and log enough to debug at 3 a.m.

Know your status codes

Code Type What to do
400 invalid_request_error Fix the request. Also returned when a spend limit you set is hit
401 authentication_error Fix the key. Retrying won’t help
403 permission_error Fix access or workspace settings
404 not_found_error Check the path, model ID or resource ID
413 request_too_large Shrink the request
429 rate_limit_error Wait for retry-after, then retry
500 api_error Retry with exponential backoff
504 timeout_error Retry; for long work use streaming or batches
529 overloaded_error The API is overloaded across users. Back off and retry

Every response carries a request-id header, which the Python SDK exposes as ._request_id. Log it on every call and include it in support tickets.

API error400/401/403/404/413 → fix it, don't retry429 + retry-after → wait that long500/504/529, conn → backoff + jitterbudget spent → fallback or fail loudly
Retry only what is transient, and stop when the retry budget is spent.

Two kinds of 429

A 429 usually means you exceeded RPM, input tokens per minute or output tokens per minute, and retry-after says how long to wait. Limits use a token bucket: capacity refills continuously, and short bursts can trip a per-minute limit. A sharp jump in traffic can also trigger acceleration limits, so ramp traffic up gradually.

A 429 caused by reaching your tier’s monthly spend cap has no retry-after and keeps failing until access resumes. error.details.error_code is enforced_spend_limit_reached. Retrying is pointless here. Alert a human.

The anthropic-ratelimit-*-remaining and -reset response headers let you slow down before you get a 429.

Retries, backoff and timeouts

The official SDKs already retry connection errors, 408, 409, 429 and 5xx twice by default with exponential backoff, honouring retry-after. Know that default, and change it deliberately:

  • max_retries: raise it for batch-style jobs. Set it to 0 if you run your own retry loop, so the two don’t multiply.
  • timeout: the Python SDK default is 10 minutes. For an interactive bot, set something much shorter, and stream long generations instead of waiting on one blocking call.
  • Add jitter to your own backoff so that a fleet of workers doesn’t retry in lockstep.

When streaming, an error can arrive after the HTTP 200, as an error event in the stream. Handle that path too.

Fallbacks and graceful degradation

When the retry budget runs out on 529s or timeouts, choose a deliberate outcome:

  • Fall back to another model for a degraded but useful answer, for example a short triage summary instead of a full analysis. Your evals should tell you which fallback is good enough.
  • Defer the work to a queue or the Batches API if it isn’t urgent.
  • Fail loudly with an actionable message (“Claude unavailable; runbook link attached”) rather than posting an empty or half-finished reply.

Also check the stop reason, because an HTTP 200 doesn’t mean the answer is complete. max_tokens and model_context_window_exceeded mean the output was cut off. Don’t parse it as a finished JSON plan.

Idempotency

Retries repeat work. That is harmless for reading or summarising, and dangerous when a tool call has side effects. If the agent’s restart_service tool runs, then the next API call times out and is retried, the model may ask to restart again. Protect yourself in your own tools:

  • Give each action an idempotency key (incident ID + action + target) and skip duplicates.
  • Prefer declarative operations (“scale to 3”) over relative ones (“add 1 replica”).
  • Record completed actions in state that survives retries and restarts.

Observability

# resilient_call.py - classify errors, back off with jitter, fall back, log structurally
import json, logging, random, time
import anthropic

logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("oncall-bot")
client = anthropic.Anthropic(max_retries=0, timeout=30.0)  # we own the retry loop
PRIMARY, FALLBACK = "claude-sonnet-5-5", "claude-haiku-5-5"

def is_transient(e: Exception) -> bool:
    if isinstance(e, anthropic.APIConnectionError):  # includes APITimeoutError
        return True
    return isinstance(e, anthropic.APIStatusError) and (
        e.status_code == 429 or e.status_code >= 500)  # 500, 504, 529 ...

def call(model: str, prompt: str, attempts: int = 4):
    for attempt in range(attempts):
        start = time.monotonic()
        try:
            r = client.messages.create(model=model, max_tokens=800,
                                       messages=[{"role": "user", "content": prompt}])
            log.info(json.dumps({"model": model, "request_id": r._request_id,
                                 "stop_reason": r.stop_reason, "in": r.usage.input_tokens,
                                 "out": r.usage.output_tokens,
                                 "ms": round((time.monotonic() - start) * 1000)}))
            return r
        except (anthropic.APIConnectionError, anthropic.APIStatusError) as e:
            if not is_transient(e):
                raise  # 400/401/403/404/413: fix the request, don't retry
            resp = getattr(e, "response", None)
            hint = resp.headers.get("retry-after") if resp is not None else None
            if isinstance(e, anthropic.RateLimitError) and hint is None:
                raise  # e.g. monthly spend cap: retrying will not help
            delay = float(hint) if hint else min(30, 2 ** attempt) * random.uniform(0.5, 1.5)
            log.warning(json.dumps({"model": model, "error": type(e).__name__,
                                    "attempt": attempt + 1, "sleep_s": round(delay, 1)}))
            time.sleep(delay)
    return None

prompt = "Summarise this alert for the on-call channel: disk 97% on db-replica-2"
resp = call(PRIMARY, prompt) or call(FALLBACK, prompt, attempts=2)
if resp is None:
    raise SystemExit("Claude unavailable after retries and fallback; page a human.")
print(next(b.text for b in resp.content if b.type == "text"))

Log per call: model, request ID, stop reason, token usage (including the cache fields), latency and retry count. Never log prompts that contain secrets or customer data. Alert on rising 429/529 rates, a falling cache hit rate, and a growing share of truncated answers.

Recap

  • 4xx request errors need a fix, not a retry. 429, 500, 504, 529 and connection errors are transient.
  • Honour retry-after. A spend-cap 429 has none and won’t clear by retrying.
  • SDKs retry twice by default. Set max_retries and timeout deliberately and add jitter to your own loops.
  • Plan fallbacks, check stop_reason, make side-effecting tools idempotent, and log request IDs and usage on every call.

See it in action

Should this API error be retried?
retries used upAPI error400 / 401 / 403 / 404 / 413fix the request429 rate limitwait retry-after5xx / 529 / networkbackoff + jitterretryfallback or failbudget spent

1/5 A call fails. First decide whether retrying can help at all.

all steps
  1. A call fails. First decide whether retrying can help at all.
  2. Errors like 400 or 401 mean the request itself is wrong. Retrying repeats the same mistake, so fix it instead.
  3. 429 means you hit a rate limit. Wait as long as the retry-after header says, then retry.
  4. 5xx, 529 (overloaded) and network errors are usually transient: retry with exponential backoff plus random jitter.
  5. Keep a retry budget. When it's spent, switch to a fallback (another model, a cached answer) or fail loudly with context.

$ ./try-it.sh

Try it: a resilient wrapper for an on-call bot

  1. Export ANTHROPIC_API_KEY and save the script from this lesson as resilient_call.py.
  2. Run it normally and check the structured log line: model, request_id, stop_reason, tokens and latency.
  3. Set ANTHROPIC_API_KEY=invalid for one run and confirm it fails immediately with a 401, without retrying.
  4. Lower timeout to 0.5 and confirm you see a timeout, backoff attempts and then the fallback model or a loud failure.

Check yourself

3 questions. Original practice questions, not taken from the real exam.

  1. A deploy-review bot sometimes receives HTTP 529 overloaded_error responses during busy periods.

    1. What is the appropriate response?

  2. On the first day of a month a cost-reporting job starts getting 429 rate_limit_error responses with no retry-after header. The error details show error_code "enforced_spend_limit_reached". The SDK keeps retrying and the job runs for hours.

    2. What should the job do?

  3. An autoscaling agent has a scale_deployment tool. During an incident, the API call that follows a successful tool run times out and your wrapper retries. The model then asks for another "add 2 replicas" action, and the service ends up overscaled.

    3. Which change best prevents this class of bug?

Answer every question to check.