Errors, Retries, and Surviving a Bad Night

The Claude SDK exception taxonomy with real status codes, which errors are your bug versus the network's, what the SDK retries automatically, and building a client that degrades gracefully.

Every network call fails eventually, and a call to Claude is a network call. The API will hand you a 429 at your busiest moment, a 529 when its own capacity is stretched, a 400 the first time you fat-finger a model name. The difference between a robust integration and a fragile one isn’t whether these happen; it’s whether your code classifies each one correctly and responds in kind. Retry the ones worth retrying, fail fast on the ones that never will, and never confuse the two.

That classification is the whole chapter, and it rests on one clean line: your bug versus the network’s. We’ll walk the SDK’s exception taxonomy with the real status codes it carries and draw that line. Then we’ll see what the SDK already retries before you write a line of your own, and build a client that degrades gracefully when the night goes bad. It’s where the exam’s integration and debugging threads meet. Once the two-bucket habit is in your hands, it’s mostly the same move applied consistently.

The taxonomy, with real status codes

The SDK maps HTTP responses to typed exceptions. This is the actual hierarchy, with the status_code each carries, because several are commonly misremembered:

ExceptionStatusMeaning
BadRequestError400malformed request — your bug
AuthenticationError401bad or missing API key
PermissionDeniedError403key lacks access to the resource
NotFoundError404wrong model id or endpoint
ConflictError409resource conflict
RequestTooLargeError413payload exceeds the size limit
UnprocessableEntityError422syntactically valid, semantically rejected
RateLimitError429you’re over your rate limit
OverloadedError529Anthropic’s API is temporarily overloaded
InternalServerError5xxa server-side error
APIConnectionErrorthe request never reached the server
APITimeoutErrorthe request timed out (a connection error subtype)

Two that catch people out: OverloadedError is 529, not 503, and RequestTooLargeError is 413. The connection-level errors (APIConnectionError, APITimeoutError) carry no status code at all: the request never got a response. That’s exactly how you distinguish “the server rejected me” from “I never reached the server.”

They all descend from a common base, so you can catch broadly or narrowly. The MRO for a status error:

BadRequestError → APIStatusError → APIError → AnthropicError → Exception

So except anthropic.APIStatusError catches every HTTP error, except anthropic.APIError catches those plus connection errors, and except anthropic.BadRequestError catches just the 400s. Catch as specifically as your recovery logic is specific.

The one distinction that matters: your bug vs. the network’s

Every error falls into one of two buckets, and the bucket decides whether retrying is sane:

  • Fix-it errors (don’t retry): 400, 401, 403, 404, 413, 422. These are deterministic — the request is wrong, and sending it again produces the identical failure. Retrying a BadRequestError just wastes calls and time. Asking for model="claude-nonexistent" raises NotFoundError (404); max_tokens=-5 raises BadRequestError (400). No amount of retrying fixes a typo’d model name.
  • Transient errors (retry with backoff): 429, 529, 500-series, and the connection errors. These are about timing and capacity — the same request may well succeed a moment later. These are the ones you retry, with exponential backoff so you don’t hammer an already-struggling service.

This maps directly onto Domain 4’s “problem origin isolation.” A 400/404/422 is an integration-layer defect: your code built a bad request. A 429/529/500 is an infrastructure condition — capacity, not correctness. And a third category isn’t an exception at all. A stop_reason of refusal, or a tool call with wrong arguments, is model output: a successful HTTP call whose content is the problem. Knowing which of the three you’re looking at is most of debugging.

The SDK already retries — know what it does

Before you write a single retry loop, know that the SDK retries transient failures automatically. client.max_retries defaults to 2. On a 408, 409, 429, 5xx, or a connection error, the SDK retries up to twice with exponential backoff and jitter. It also honors a retry-after header when the server sends one. So the common case is handled for you.

You tune it rather than reimplement it:

import anthropic

client = anthropic.Anthropic(max_retries=5)          # more persistence, client-wide
resp = client.with_options(max_retries=0).messages.create(...)  # or none, per request

What the SDK does not do is retry your bugs — a 400 raises immediately, because retrying it is pointless. That’s the right default. So your own error handling exists to cover two things the SDK leaves to you: reacting to fix-it errors (log, alert, fail the request), and deciding what happens after retries are exhausted on a transient one.

A handling pattern that degrades gracefully

import anthropic
import logging

log = logging.getLogger(__name__)

def degraded_response():
    return "Sorry, the assistant is briefly unavailable."

def ask(client, **kwargs):
    try:
        return client.messages.create(**kwargs)
    except anthropic.BadRequestError as e:
        # your bug — surface it loudly, do not retry
        log.error("bad request %s (req %s)", e.status_code, e.request_id)
        raise
    except anthropic.RateLimitError:
        # SDK retries already exhausted — shed load or queue for later
        return None
    except (anthropic.APIConnectionError, anthropic.InternalServerError, anthropic.OverloadedError):
        # transient and past the SDK's retries — fall back
        return degraded_response()

The shape matters more than the specifics: catch fix-it errors to fail fast and visibly; catch transient errors to fall back gracefully. For a user-facing app the fallback might be a cached answer or an apology; for a batch job it might be re-queueing the item. What you never do is treat a 400 and a 529 the same way.

request_id: the thing support asks for

Every error (and every successful response) carries a request_id — present on both the NotFoundError and BadRequestError raised above (req_011CdX...). Log it. When something goes wrong in production and you contact support, the request_id is what lets them find the exact call. An error log without it is far harder to act on. It costs one field to record and saves an afternoon later.

Timeouts and long requests

The default request timeout is generous, but extended thinking and long generations can still bump it. Set it explicitly for those:

import anthropic
client = anthropic.Anthropic()

client.with_options(timeout=120.0).messages.create(...)   # seconds

A timeout raises APITimeoutError (a connection-error subtype, no status code) — transient, and a candidate for a retry or a fallback, not a “fix your request” error. Pair generous timeouts with streaming (chapter 2) for long generations, since streaming sidesteps the single-request socket timeout entirely.

Final thoughts

Error handling is a two-bucket decision. Fix-it errors400, 401, 403, 404, 413, 422 — are deterministic bugs in your request; surface them, never retry them. Transient errors429, 529, 5xx, connection failures — are timing and capacity; the SDK already retries them twice with backoff, and your job is the graceful fallback after that. Keep the real status codes straight (OverloadedError is 529), log the request_id on everything, and remember the third category that isn’t an exception at all — a refusal or a bad tool call is model output, not an integration failure. Get those distinctions right and your integration survives the nights the network doesn’t.

Next: Bedrock and batch — reaching Claude through Amazon Bedrock, and processing thousands of requests overnight at half price.

Comments