# Errors, retries, and timeouts

Web page: https://academy.memberpress.ai/courses/api-fundamentals/l4/

Lesson 4 of 9 in [Anthropic API Fundamentals](https://academy.memberpress.ai/courses/api-fundamentals/), 15 min.

Video: https://www.youtube.com/watch?v=oQNSeAq7jCA

Every production API integration eventually hits an error. The question is whether your code handles it gracefully or whether it surfaces a stack trace to the user.

**The five error classes**

The API returns standard HTTP status codes. Each has a distinct cause and the right response to it.

`400 invalid_request_error` — the request body is malformed or missing a required field. This is a bug in your code, not a transient condition. Do not retry. Fix the request.

`401 authentication_error` — the API key is wrong, missing, or expired. Do not retry. Fix the key.

`403 permission_error` — the key is valid but cannot access the requested model or feature. Check the console for model access. Do not retry.

`429 rate_limit_error` — you have exceeded your token-per-minute or request-per-minute quota. Retry with backoff after the wait period indicated in the `Retry-After` header.

`529 overloaded_error` — Anthropic's servers are temporarily at capacity. This is not your fault. Retry with backoff.

**429 versus 529 — different causes, same response**

A `429` means you are moving too fast relative to your tier's limits. A `529` means Anthropic's infrastructure is temporarily saturated. Both errors respond to exponential backoff, but the `529` sometimes needs a longer initial wait before the first retry.

Check the response body to confirm which error you have. Both return JSON with `{"type": "error", "error": {"type": "rate_limit_error", ...}}` or `"overloaded_error"`.

**Exponential backoff with jitter**

```python
# scripts/retry.py
import anthropic, time, random

client = anthropic.Anthropic()

def call_with_retry(messages, max_retries=3):
    delay = 1.0
    for attempt in range(max_retries + 1):
        try:
            return client.messages.create(
                model="claude-opus-4-5",
                max_tokens=256,
                messages=messages,
            )
        except anthropic.RateLimitError as e:
            if attempt == max_retries:
                raise
            jitter = random.uniform(0, delay * 0.1)
            print(f"429 rate limit, retry {attempt + 1}/{max_retries}, waiting {delay + jitter:.1f}s", flush=True)
            time.sleep(delay + jitter)
            delay *= 2
        except anthropic.APIStatusError as e:
            if e.status_code == 529 and attempt < max_retries:
                jitter = random.uniform(0, delay * 0.1)
                print(f"529 overloaded, retry {attempt + 1}/{max_retries}, waiting {delay + jitter:.1f}s", flush=True)
                time.sleep(delay + jitter)
                delay *= 2
            else:
                raise

result = call_with_retry([{"role": "user", "content": "Say hello."}])
print(result.content[0].text)
```

The jitter — `random.uniform(0, delay * 0.1)` — prevents a stampede when many clients fail at the same moment and all wake up at the same time. Without jitter, they hit the API simultaneously and trigger another `429`.

**Timeouts — the silent failure**

The Anthropic SDK does not set a default request timeout. A long-running request can hang indefinitely if the connection is dropped silently. Set a timeout explicitly:

```python
client = anthropic.Anthropic(timeout=30.0)  # seconds
```

On timeout, the SDK raises `anthropic.APITimeoutError`. Treat it like a `529` — retryable with backoff. Add it to the except chain:

```python
except anthropic.APITimeoutError:
    if attempt < max_retries:
        time.sleep(delay)
        delay *= 2
    else:
        raise
```

**What errors to surface to users**

Never surface raw error messages from the API to end users. Map them: `429` or `529` becomes "The assistant is busy — try again in a moment." A `400` or `401` means something is broken in your backend — return `500` to the caller and log the real error server-side.

**If you get stuck**

- Your retry loop retries a `400`: `400` is not retryable. Add a guard that only retries on `429`, `529`, and `APITimeoutError`. Retrying a `400` wastes quota and time.
- The backoff delay is not increasing between retries: confirm `delay *= 2` runs after `time.sleep`, not before. If it is at the wrong indentation level it may reset each iteration.
- `APIStatusError` is not catching `429`: use `anthropic.RateLimitError` for `429` specifically — it is a subclass with its own except clause. Catching `APIStatusError` alone will catch it, but then you need to inspect `e.status_code` to distinguish `429` from other status codes.
- You do not see the `Retry-After` header: the SDK surfaces it on the exception object. Print `e.response.headers.get("retry-after")` to read it.

**Cross-reference**

l5 (Rate limits and the cost of context) explains why you hit `429` — understanding your tier limits gives you the right ceiling to build against. l3 (Streaming) mentions what happens when a streaming connection drops mid-response; wrap streaming calls in the same retry logic.

**Deliverable**

A `call_with_retry` function that handles `429` and `529` with exponential backoff and jitter, logs each retry attempt to stderr, and raises after three failed attempts. Test it by temporarily setting a wrong key to trigger `401`, and confirm the function does not retry on that error class.

## Try these prompts

- Write a Python retry function that handles 429 and 529 errors with exponential backoff and jitter, but does not retry on 400 or 401.
- What is the difference between a 429 and a 529 error from the Anthropic API? How should your code handle each one differently?
- Write the except block that catches APITimeoutError and retries with the same backoff logic as a 529.

Next: [Rate limits and the cost of context](https://academy.memberpress.ai/courses/api-fundamentals/l5/)
