CLAUDE LABJP
FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as OctoberFORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Articles/API & SDK
API & SDK/2026-04-24Advanced

Running the Claude API in Python Production — Rate Limits, Retries, and Timeouts

If you put Claude API into a real backend service, how you handle 429, 503, and read timeouts decides your reliability ceiling. This is the design I settled on after operating it in production.

claude-api81python22production111reliability17retry7timeout9

When you first integrate the Claude API, it feels complete the moment anthropic.Anthropic().messages.create(...) returns a response. Once you put that call inside a real service, though, a much larger world opens up: sudden 429s, flaky connections, scattered 503s, hanging requests. How you handle these determines the quality of your production service.

This article is the design I settled on after running the Claude API inside a Python backend for a while. I am skipping the basics and going straight to the reliability layer most guides omit.

Retry design breaks first in production, timeout design breaks second

When services that use the Claude API have incidents, the cause is almost always one of three things:

  • Sloppy retries that cannot absorb transient errors
  • Coarse timeouts that let one-way hangs drag the whole service down
  • Misreading rate-limit responses, which turns a spike into a cascade

Once you have these three in focus, wrapping the SDK is not enough. The SDK is built to handle the happy path; you have to build the unhappy path yourself.

Decide what retries target before writing any retry code

The single most important decision in retry design is defining what you retry. Errors from the Claude API fall into these buckets:

  • Retry: 429 (rate limit), 503 (temporary overload), connection errors, read timeouts
  • Do not retry: most 4xx (malformed request), 401 (auth), 403 (permission), context overflow
  • Case by case: 500 (server), 504 (gateway timeout)

Ignoring this split and writing "retry three times on any error" creates the classic incident where a brief auth outage triples your error load simply from the retry attempts.

My baseline looks like this:

import random
import time
from anthropic import Anthropic, APIStatusError, APIConnectionError, APITimeoutError
 
client = Anthropic(timeout=60.0, max_retries=0)
 
RETRYABLE_STATUS = {429, 503, 504, 529}
MAX_ATTEMPTS = 5
BASE_DELAY = 1.0
MAX_DELAY = 30.0
 
def call_with_retry(payload: dict):
    attempt = 0
    while True:
        attempt += 1
        try:
            return client.messages.create(**payload)
 
        except APIStatusError as e:
            if e.status_code not in RETRYABLE_STATUS or attempt >= MAX_ATTEMPTS:
                raise
            sleep_for = _retry_delay(e, attempt)
 
        except (APIConnectionError, APITimeoutError):
            if attempt >= MAX_ATTEMPTS:
                raise
            sleep_for = _backoff(attempt)
 
        time.sleep(sleep_for)
 
def _backoff(attempt: int) -> float:
    cap = min(BASE_DELAY * 2 ** (attempt - 1), MAX_DELAY)
    return random.uniform(0, cap)
 
def _retry_delay(err: APIStatusError, attempt: int) -> float:
    retry_after = getattr(err.response, "headers", {}).get("retry-after")
    if retry_after:
        try:
            return min(float(retry_after), MAX_DELAY)
        except ValueError:
            pass
    return _backoff(attempt)

I intentionally set the SDK's max_retries to 0 and handle retries myself for two reasons. First, I want to read and log the Retry-After header. Second, I want every sleep to happen in one place so I can reason about timing during incidents. When retries are hidden inside the SDK you lose visibility into where your seconds are going.

When you see a 429, find out who emitted it and why

A 429 is Anthropic telling you to slow down, but the "who" and "why" behind it vary. Account-wide token rate, per-model RPM, concurrent connection caps on a single key — these are different signals surfaced the same way.

What saves you later is logging status errors with structure, including the Retry-After header and the request ID:

except APIStatusError as e:
    log.warning({
        "event": "anthropic_status_error",
        "status": e.status_code,
        "message": getattr(e, "message", None),
        "request_id": getattr(e, "request_id", None),
        "retry_after": getattr(e.response, "headers", {}).get("retry-after"),
        "attempt": attempt,
    })

The request_id is decisive if you ever contact Anthropic support. Teams that forget to log it lose the ability to correlate server-side traces with their incidents.

In one incident I had to debug, sporadic late-night 429s were appearing. Because I had request IDs and timestamps, I could see that the spikes were confined to one specific model. My application had not changed, so I could conclude quickly that the issue was not on our side.

Separate connect timeout from read timeout

Python's httpx lets you configure connect and read timeouts independently. For a service like the Claude API, where request time varies wildly with prompt size and model, this separation is not optional.

Connect timeout is about "can we establish a TCP connection at all," which should fail fast. I use 5 seconds. Read timeout covers "Claude is thinking," which should be generous. Depending on model and max_tokens, I set 60 to 180 seconds.

import httpx
from anthropic import Anthropic
 
http_client = httpx.Client(
    timeout=httpx.Timeout(
        connect=5.0,
        read=120.0,
        write=10.0,
        pool=5.0,
    ),
    limits=httpx.Limits(max_connections=50, max_keepalive_connections=20),
)
 
client = Anthropic(http_client=http_client, max_retries=0)

Another subtle point: passing a scalar timeout=60.0 applies to every phase including connect. A 60-second connect timeout is too long — it masks DNS and TCP issues. Always split it.

Streaming adds another wrinkle. With streaming you have two concepts: time to first byte and time to completion. Read timeout is effectively the idle interval between chunks, so 60 seconds is usually fine. For non-streaming long outputs, 120 to 180 is safer.

Design retry and timeout together

This is where people trip. Setting read timeout to 120 and retries to 5 means the worst case is over ten minutes. Most upstream requests have SLAs of seconds to tens of seconds. Exceeding that inside your API layer creates incidents.

My rules:

  • Cap total time at 70% of the upstream SLA
  • Work out per-attempt timeout × max retries so they stay under the total cap
  • Limit user-facing synchronous calls to max_attempts=3 — 5+ belongs to async queues

Concretely:

import time
 
def call_with_budget(payload: dict, total_budget_s: float):
    start = time.monotonic()
    attempt = 0
    while True:
        attempt += 1
        remaining = total_budget_s - (time.monotonic() - start)
        if remaining <= 0:
            raise TimeoutError("budget exhausted")
 
        per_attempt_timeout = min(remaining, 30.0)
        try:
            return client.with_options(timeout=per_attempt_timeout).messages.create(**payload)
        except (APIConnectionError, APITimeoutError):
            if attempt >= MAX_ATTEMPTS or (time.monotonic() - start) >= total_budget_s:
                raise
            time.sleep(_backoff(attempt))

with_options(timeout=...) lets you shrink the per-attempt budget as remaining time shrinks. With this pattern you never blow the upstream SLA.

Concurrency — do not burn your rate limit silently

Batch jobs and multi-tenant services that fan out calls always hit 429 if concurrency is uncapped. asyncio.gather on 50 requests is a production anti-pattern.

I use asyncio.Semaphore for an explicit parallelism ceiling and layer a token bucket on top for per-second rate control.

import asyncio
from anthropic import AsyncAnthropic
 
client = AsyncAnthropic()
sem = asyncio.Semaphore(10)
 
async def call_async(payload: dict):
    async with sem:
        return await client.messages.create(**payload)

A semaphore alone will not cap requests per second. When bursts are expected, add a bucket:

class TokenBucket:
    def __init__(self, rate: float, capacity: int):
        self.rate = rate
        self.capacity = capacity
        self.tokens = capacity
        self.last = time.monotonic()
        self.lock = asyncio.Lock()
 
    async def acquire(self):
        async with self.lock:
            now = time.monotonic()
            elapsed = now - self.last
            self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
            self.last = now
            if self.tokens < 1:
                wait = (1 - self.tokens) / self.rate
                await asyncio.sleep(wait)
                self.tokens = 0
            else:
                self.tokens -= 1

I target 70 to 80 percent of the Anthropic RPM limit. That headroom protects against other clients sharing the same key and against small demand spikes.

Standardize your error logging

The single biggest operational difference between mature and immature services is the granularity of error logs. Do not re-raise SDK exceptions bare — attach metadata and write structured logs first.

def call_safely(payload: dict):
    try:
        return client.messages.create(**payload)
    except APIStatusError as e:
        log.error({
            "event": "claude_api_status_error",
            "status": e.status_code,
            "request_id": getattr(e, "request_id", None),
            "payload_model": payload.get("model"),
            "payload_max_tokens": payload.get("max_tokens"),
            "input_tokens_estimate": _estimate_tokens(payload),
        })
        raise
    except (APIConnectionError, APITimeoutError) as e:
        log.error({
            "event": "claude_api_network_error",
            "error_type": type(e).__name__,
        })
        raise

Request ID, model, max_tokens, and an estimated input token count — those four fields let you answer "which request was heavy" and "which model was unstable" months later. Without them, the data is gone.

Three production failure modes worth knowing

Mode 1: silent streaming disconnects. When the network drops mid-stream, some clients simply stop emitting events rather than raising an error. Add an idle guard:

async def stream_with_idle_guard(payload, idle_limit_s=20.0):
    last = time.monotonic()
    async with client.messages.stream(**payload) as stream:
        async for event in stream:
            now = time.monotonic()
            if now - last > idle_limit_s:
                raise TimeoutError("stream idle exceeded")
            last = now
            yield event

Mode 2: context overflow from user-generated input. Services that feed raw user content into Claude eventually start getting context overflow errors. Check input size before the API call and summarize or chunk when it gets close.

Mode 3: retry storms from ignored Retry-After. When a 429 comes with a Retry-After value and your code keeps its own exponential backoff without reading the header, you fight the server's guidance. Respect Retry-After first, backoff second.

Closing thought

Running the Claude API in production is about quiet defenses, not clever optimizations. Split connect and read timeouts. Honor Retry-After. Log request IDs. Work back from an overall time budget. These simple habits move the reliability needle more than any tuning trick.

If you only add one thing today, add request-ID logging. The next time something goes wrong, you will be glad you did.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API & SDK2026-06-27
When Claude API Streaming Stops Without an Error: Detecting Silent Stalls and Resuming Mid-Stream
How to catch the 'silent stall' where Claude API streaming stops with no exception at all, using a content-level watchdog that times the gap between tokens, plus a resume path that carries received text forward as an assistant prefill, and a four-layer timeout budget for long-running automation.
API & SDK2026-07-13
Coalescing Concurrent Claude API Calls: Single-Flight Against Duplicate Inference and Cache Stampede
A design for collapsing identical prompts that fire at the same instant into a single upstream Claude call, using single-flight (request coalescing). In-process and distributed implementations, jittered retries, and negative caching, with measured results.
API & SDK2026-07-03
How Many Concurrent Claude API Requests Can You Actually Hold? Sizing Production Infrastructure with Little's Law and Measured Memory
Concurrency, queue depth, and memory are numbers you can derive, not guess. A working method for sizing Claude API production deployments with Little's Law, a memory probe, and a 30-minute load check — learned the hard way from an OOM crash.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →