Claude API Error Handling Patterns in Python
When you call the Claude API from Python, errors fall into a handful of predictable categories: rate limits, overloaded servers, invalid requests, authentication failures, and network timeouts. Each one needs a different response — retrying a 500 blindly is fine, retrying a 400 just wastes time and money. This article covers the concrete patterns for catching, classifying, and recovering from each error type so your app degrades gracefully instead of crashing on the first hiccup.
The short version: wrap calls in a retry loop with exponential backoff and jitter for transient errors (429, 500, 503, 529), fail fast on client errors (400, 401, 403, 404), set explicit timeouts on every request, and handle streaming disconnects separately from normal request/response errors. The rest of this post walks through each pattern with code.
Classify errors before you retry
The Anthropic Python SDK raises typed exceptions you can match on. The key distinction is transient (worth retrying) versus permanent (retrying won't help).
import anthropic
client = anthropic.Anthropic(api_key="sk-ant-...")
TRANSIENT_ERRORS = (
anthropic.RateLimitError,
anthropic.APIConnectionError,
anthropic.APITimeoutError,
anthropic.InternalServerError, # covers 500/503
)
PERMANENT_ERRORS = (
anthropic.AuthenticationError, # 401
anthropic.PermissionDeniedError, # 403
anthropic.NotFoundError, # 404
anthropic.BadRequestError, # 400
)
If you're routing through a proxy or wrapper, check the HTTP status code directly instead — most libraries expose status_code on the error object. A 529 ("overloaded") from Anthropic's own API is also transient and should be retried with backoff, same as a 429.
Exponential backoff with jitter
A naive retry loop that hammers the API every second during an outage makes rate limiting worse for everyone, including you. Use exponential backoff with jitter:
import time
import random
def call_with_retry(fn, max_retries=5, base_delay=1.0):
for attempt in range(max_retries):
try:
return fn()
except TRANSIENT_ERRORS as e:
if attempt == max_retries - 1:
raise
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.5)
time.sleep(delay)
except PERMANENT_ERRORS:
raise # no point retrying
def make_request():
return client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Summarize this ticket."}],
)
response = call_with_retry(make_request)
Five retries with a 1-second base delay caps total wait time around 30–60 seconds, which is reasonable for a background job but too slow for a synchronous web request. Tune max_retries and base_delay based on whether the caller is a human waiting on a response or a batch job.
Respect the Retry-After header
On a 429, the API often returns a Retry-After header telling you exactly how long to wait. Use it instead of guessing:
try:
response = make_request()
except anthropic.RateLimitError as e:
retry_after = e.response.headers.get("retry-after")
wait = int(retry_after) if retry_after else 5
time.sleep(wait)
response = make_request()
This is more precise than fixed backoff and avoids both under-waiting (getting rate limited again immediately) and over-waiting (padding delays unnecessarily).
Timeouts are not optional
Without an explicit timeout, a hung connection can block a request indefinitely. Set both connect and read timeouts:
client = anthropic.Anthropic(
api_key="sk-ant-...",
timeout=httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0),
)
For long generations, a 60-second read timeout is often too short — raise it for completion-heavy workloads, but keep connect timeouts tight so you fail fast on network issues rather than hanging.
Streaming failures need their own handler
Streaming responses can fail mid-stream after you've already received partial content. Don't just catch at the top level — track what you've received so you can resume or display a partial result:
def stream_with_recovery(messages):
collected = ""
try:
with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=messages,
) as stream:
for text in stream.text_stream:
collected += text
yield text
except anthropic.APIConnectionError:
if collected:
yield "\n[Connection dropped — partial response above]"
else:
raise
For user-facing chat UIs, showing the partial response with a clear "connection dropped" indicator is far better than discarding everything and showing a generic error.
Validate inputs before sending
A large share of 400 errors are preventable: exceeding context length, malformed tool schemas, or missing required fields. Catch these locally before they hit the network:
def validate_messages(messages):
if not messages:
raise ValueError("messages cannot be empty")
for m in messages:
if m.get("role") not in ("user", "assistant"):
raise ValueError(f"invalid role: {m.get('role')}")
return messages
This saves a round trip and gives you a faster, more specific error message than whatever the API returns.
Centralize error handling behind one gateway
If multiple parts of your codebase call Claude directly, you end up duplicating retry logic everywhere and it drifts out of sync. A common fix is to route all calls through one internal client wrapper — or through a hosted API gateway that already handles retries, backoff, and timeout tuning for you. SubToAPI exposes your existing Claude access as a standard HTTPS API with consistent error responses and usage metadata, so your application code just needs to handle one predictable error shape instead of reimplementing backoff logic in five different services. See the quickstart and messages docs for the request/response format, or the streaming docs if you're building a chat interface.
Log structured error data, not just stack traces
When debugging production issues weeks later, a bare exception message isn't enough. Log the request ID, model, token count, and error type together:
import logging
logger = logging.getLogger("claude_client")
def log_error(e, context):
logger.error(
"claude_api_error",
extra={
"error_type": type(e).__name__,
"status_code": getattr(e, "status_code", None),
"request_id": getattr(e, "request_id", None),
"model": context.get("model"),
},
)
This makes it possible to spot patterns — a spike in 529s pointing to a model-specific outage, or a cluster of 400s from a single misbehaving client.
questions
Should I retry 400 Bad Request errors? No. A 400 means the request itself is malformed — bad JSON, invalid parameters, oversized context. Retrying the same payload will fail the same way every time. Fix the request instead.
How many retries are reasonable for transient errors? Three to five retries with exponential backoff is typical. For synchronous user-facing requests, cap total retry time under 10–15 seconds; background jobs can tolerate longer backoff windows.
What's the difference between a 429 and a 529 error? A 429 means you've hit a rate limit tied to your account or API key. A 529 means Anthropic's infrastructure is overloaded generally — both are transient and retryable, but a 529 often benefits from longer backoff since it reflects broader capacity issues, not just your usage.