Your Claude wrapper works for the first month. It works through your first thousand users. Then Tuesday afternoon happens — traffic doubles for no obvious reason, your primary path hits its rate limit, your naive retry loop stampedes Anthropic's recovery, and the site is down for eleven minutes while you frantically Google "how to handle 429 errors." This post is what you wish you had built the first time.

The wrapper below is what teams that have been through the outage converge on. It composes four independent reliability primitives — a client-side rate limiter, a jittered retry loop, a circuit breaker, and multi-provider fallback — into one class you can drop into any Python service. Every technique is drawn from patterns tested in production. The complete implementation is ~250 lines. You can copy it, adapt it, ship it.

The five failure modes a wrapper must handle

Before the code, the shape of the problem. Any wrapper claiming to be "production-ready" must handle every one of these:

  1. Rate limits — 429 responses when you exceed RPM or TPM. Guaranteed to happen at scale. Solved by client-side smoothing (proactive) and jittered retry (reactive).
  2. Overload errors — 529 on Anthropic when the provider itself is under load. Not your fault, but you still have to handle it.
  3. Sustained degradation — the provider is having a bad hour. Retrying every request is throwing gasoline on the fire. Solved by circuit breakers.
  4. Full provider outages — Anthropic direct is down. You need to route to Bedrock or a different model without user-visible failures.
  5. Your own bugs — 400s from malformed requests. Retrying makes them worse. The wrapper must distinguish these from transient failures.

A wrapper that handles only rate limits is fragile. A wrapper that handles all five is the thing you actually want. Here's how each one is built.

Requirements checklist

Before building, be explicit about what "bulletproof" means. The wrapper below satisfies every item:

  • Never retries non-retryable errors (400, 401, 403, 404, 422)
  • Always honors Retry-After when provided by the server
  • Uses full-jitter exponential backoff to prevent stampedes
  • Caps individual retry delay at 60 seconds and total attempts at 5
  • Applies client-side rate limiting to avoid tripping 429s proactively
  • Opens a circuit breaker after sustained failure to save resources
  • Falls back to Bedrock when Anthropic direct is degraded
  • Emits metrics on every retry, fallback, and breaker transition
  • Fails cleanly — never hangs the caller indefinitely
  • Distinguishes provider errors from your own bugs

Layer 1: Error classification

Everything starts here. The single most common bug in AI wrappers is retrying errors that will never succeed. Retrying a 400 wastes tokens and time; retrying a 401 wastes support tickets. The wrapper's first responsibility is knowing what to retry and what to surface immediately.

from anthropic import (
    RateLimitError, APIStatusError, APIConnectionError, BadRequestError
)

def is_retryable(exception) -> bool:
    # Never retry the caller's fault
    if isinstance(exception, BadRequestError):
        return False  # 400 — malformed request
    if isinstance(exception, APIStatusError):
        code = exception.status_code
        if code in (401, 403, 404, 422):
            return False  # auth/permission/not-found/validation
        if code in (429, 500, 502, 503, 504, 529):
            return True   # rate limit or transient server error
    if isinstance(exception, RateLimitError):
        return True
    if isinstance(exception, APIConnectionError):
        return True   # network — retry
    return False  # unknown — don't retry unknowns


def is_breaker_signal(exception) -> bool:
    # A subset of retryable errors that signal provider degradation
    if isinstance(exception, RateLimitError):
        return True
    if isinstance(exception, APIStatusError):
        return exception.status_code in (500, 502, 503, 504, 529)
    if isinstance(exception, (APIConnectionError, ConnectionError, TimeoutError)):
        return True
    return False

Note the distinction between is_retryable and is_breaker_signal. Rate limits are retryable but they're your fault (you sent too much traffic), so they shouldn't count against the provider's health score. Server errors are both retryable and signal the provider is struggling — those count.

Layer 2: Client-side rate limiting

The point of client-side rate limiting is not to enforce a limit you don't have — it's to smooth your traffic so you never hit the ceiling. A token bucket refills at a fixed rate and every request costs a proportional amount from the bucket. Requests that exceed available tokens wait.

import time, threading

class TokenBucket:
    def __init__(self, tpm: int, burst_multiplier: float = 1.5):
        self.rate_per_sec = tpm / 60.0
        self.capacity = int(tpm * burst_multiplier / 60.0 * 60.0)
        self.tokens = float(self.capacity)
        self.last_refill = time.time()
        self.lock = threading.Lock()

    def acquire(self, cost: int):
        with self.lock:
            self._refill()
            if self.tokens >= cost:
                self.tokens -= cost
                return
            # Not enough — sleep until refilled
            deficit = cost - self.tokens
            wait = deficit / self.rate_per_sec
        time.sleep(wait)
        with self.lock:
            self._refill()
            self.tokens -= cost

    def _refill(self):
        now = time.time()
        elapsed = now - self.last_refill
        self.tokens = min(self.capacity, self.tokens + elapsed * self.rate_per_sec)
        self.last_refill = now

Size the bucket at 80-85% of your tier's actual limit. The buffer accounts for measurement imprecision and gives the provider room for its own smoothing. Setting the bucket at 100% causes constant near-miss 429s.

Layer 3: Full-jitter exponential backoff

When a retry is warranted, backoff prevents the stampede. If a hundred clients all retry after exactly 2 seconds, they all retry at the same moment, and the provider's recovery is immediately re-broken.

import random

def full_jitter_delay(attempt: int, base: float = 1.0, cap: float = 60.0) -> float:
    # Every retry lands somewhere in [0, exp].
    # Spreads coordinated retries evenly across the backoff window.
    exp = min(cap, base * (2 ** attempt))
    return random.uniform(0, exp)


def parse_retry_after(exception) -> float | None:
    response = getattr(exception, "response", None)
    if not response:
        return None
    value = response.headers.get("retry-after")
    if not value:
        return None
    try:
        return float(value)
    except ValueError:
        return None

When the server provides Retry-After, honor it — it's precise information from the provider about when the limit will clear. Cap the value even so; a misconfigured proxy occasionally sends Retry-After: 86400.

Layer 4: The circuit breaker

Retries protect individual requests. The circuit breaker protects your service. When failures pile up in a window, all subsequent requests fail fast without touching the provider. This saves tokens, latency, and lets the provider recover.

from enum import Enum
from collections import deque


class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"


class CircuitOpenError(Exception):
    pass


class CircuitBreaker:
    def __init__(self, name: str, threshold: float = 0.5,
                 window_seconds: float = 30.0, min_samples: int = 20,
                 reset_timeout: float = 30.0):
        self.name = name
        self.threshold = threshold
        self.window = window_seconds
        self.min_samples = min_samples
        self.reset_timeout = reset_timeout
        self.state = CircuitState.CLOSED
        self.events: deque = deque()
        self.reopens_at: float | None = None
        self.consecutive_failures = 0
        self.lock = threading.Lock()

    def allow_request(self) -> bool:
        with self.lock:
            now = time.time()
            if self.state == CircuitState.CLOSED:
                return True
            if self.state == CircuitState.OPEN:
                if self.reopens_at and now >= self.reopens_at:
                    self.state = CircuitState.HALF_OPEN
                    return True
                return False
            if self.state == CircuitState.HALF_OPEN:
                return True  # probing

    def record_success(self):
        with self.lock:
            self.events.append((time.time(), True))
            self._prune()
            if self.state == CircuitState.HALF_OPEN:
                self.state = CircuitState.CLOSED
                self.consecutive_failures = 0

    def record_failure(self):
        with self.lock:
            now = time.time()
            self.events.append((now, False))
            self._prune()
            if self.state == CircuitState.HALF_OPEN:
                # Probe failed — back to open with longer timeout
                self.consecutive_failures += 1
                self.state = CircuitState.OPEN
                self.reopens_at = now + min(
                    300.0, self.reset_timeout * (2 ** self.consecutive_failures)
                )
                return
            if self.state == CircuitState.CLOSED:
                total = len(self.events)
                failures = sum(1 for _, ok in self.events if not ok)
                if total >= self.min_samples and failures / total >= self.threshold:
                    self.state = CircuitState.OPEN
                    self.reopens_at = now + self.reset_timeout

    def _prune(self):
        cutoff = time.time() - self.window
        while self.events and self.events[0][0] < cutoff:
            self.events.popleft()

Three states, one lock, sliding window. Trips at 50% failure rate over 30 seconds with a minimum of 20 samples (prevents false positives in quiet periods). Reset timeout backs off exponentially on repeated open transitions, capped at 5 minutes.

Layer 5: Multi-provider fallback

When the primary path is exhausted — either the circuit is open or retries are done — the wrapper falls back to Bedrock. Same model, different provider, independent quota pool. Usually works when Anthropic direct is having a bad day.

import boto3, json as jsonlib
from botocore.config import Config

def make_bedrock_client():
    config = Config(retries={"max_attempts": 3, "mode": "adaptive"})
    return boto3.client("bedrock-runtime", region_name="us-east-1", config=config)


def call_bedrock_fallback(messages: list, model: str = "anthropic.claude-sonnet-4-6-v1:0",
                          max_tokens: int = 1024) -> dict:
    client = make_bedrock_client()
    response = client.invoke_model(
        modelId=model,
        body=jsonlib.dumps({
            "messages": messages,
            "max_tokens": max_tokens,
            "anthropic_version": "bedrock-2023-05-31",
        }),
    )
    return jsonlib.loads(response["body"].read())

Putting it together: the BulletproofClaude class

Every piece from above, composed into one class. This is what you drop into your codebase.

import logging
from anthropic import Anthropic
from dataclasses import dataclass

logger = logging.getLogger(__name__)


@dataclass
class WrapperConfig:
    tpm: int = 40_000              # Tier-1 default; raise for higher tiers
    max_retries: int = 5
    base_backoff: float = 1.0
    cap_backoff: float = 60.0
    retry_after_cap: float = 300.0
    breaker_threshold: float = 0.5
    breaker_window: float = 30.0
    breaker_min_samples: int = 20
    breaker_reset_timeout: float = 30.0


class BulletproofClaude:
    def __init__(self, config: WrapperConfig | None = None):
        self.config = config or WrapperConfig()
        self.client = Anthropic()
        self.bucket = TokenBucket(tpm=int(self.config.tpm * 0.85))
        self.breaker = CircuitBreaker(
            name="anthropic-direct",
            threshold=self.config.breaker_threshold,
            window_seconds=self.config.breaker_window,
            min_samples=self.config.breaker_min_samples,
            reset_timeout=self.config.breaker_reset_timeout,
        )

    def call(self, messages: list, model: str = "claude-sonnet-4-6",
             max_tokens: int = 1024, correlation_id: str = "") -> dict:
        # Step 1: pre-flight budget check
        estimated = self._estimate_tokens(messages, max_tokens)
        self.bucket.acquire(estimated)

        # Step 2: circuit breaker check
        if not self.breaker.allow_request():
            logger.info("breaker_open", extra={"correlation_id": correlation_id})
            return self._fallback(messages, model, max_tokens, correlation_id)

        # Step 3: retry loop
        last_exception: Exception | None = None
        for attempt in range(self.config.max_retries):
            try:
                response = self.client.messages.create(
                    model=model,
                    max_tokens=max_tokens,
                    messages=messages,
                )
                self.breaker.record_success()
                return {
                    "content": [b.model_dump() for b in response.content],
                    "provider": "anthropic",
                    "model": model,
                    "usage": {
                        "input_tokens": response.usage.input_tokens,
                        "output_tokens": response.usage.output_tokens,
                    },
                }
            except Exception as e:
                last_exception = e
                if not is_retryable(e):
                    if is_breaker_signal(e):
                        self.breaker.record_failure()
                    raise
                if is_breaker_signal(e):
                    self.breaker.record_failure()

                if attempt == self.config.max_retries - 1:
                    break  # exhausted

                retry_after = parse_retry_after(e)
                if retry_after is not None:
                    wait = min(retry_after, self.config.retry_after_cap)
                else:
                    wait = full_jitter_delay(attempt, self.config.base_backoff,
                                             self.config.cap_backoff)
                logger.info("retry", extra={
                    "correlation_id": correlation_id,
                    "attempt": attempt + 1, "wait_sec": wait,
                    "error": type(e).__name__,
                })
                time.sleep(wait)

        # Step 4: retries exhausted — fall back
        logger.warning("retries_exhausted", extra={
            "correlation_id": correlation_id,
            "last_error": type(last_exception).__name__ if last_exception else None,
        })
        return self._fallback(messages, model, max_tokens, correlation_id)

    def _fallback(self, messages, model, max_tokens, correlation_id):
        try:
            bedrock_model = f"anthropic.{model}-v1:0" if "claude" in model else model
            result = call_bedrock_fallback(messages, bedrock_model, max_tokens)
            logger.info("fallback_success", extra={
                "correlation_id": correlation_id,
                "provider": "bedrock",
            })
            return {
                "content": result.get("content", []),
                "provider": "bedrock",
                "model": bedrock_model,
                "usage": result.get("usage", {}),
            }
        except Exception as e:
            logger.error("fallback_failed", extra={
                "correlation_id": correlation_id,
                "error": type(e).__name__,
            })
            raise

    def _estimate_tokens(self, messages, max_output_tokens):
        try:
            r = self.client.messages.count_tokens(
                model="claude-sonnet-4-6", messages=messages
            )
            return r.input_tokens + max_output_tokens
        except Exception:
            # Fall back to rough estimate if count_tokens fails
            chars = sum(len(str(m.get("content", ""))) for m in messages)
            return int(chars / 4) + max_output_tokens

Using it

import uuid
from bulletproof_claude import BulletproofClaude, WrapperConfig

# Configure once per service
wrapper = BulletproofClaude(WrapperConfig(
    tpm=80_000,               # Tier 2 budget
    max_retries=5,
    breaker_threshold=0.5,
))

# Call from wherever you'd normally call Anthropic
def summarize(text: str) -> str:
    result = wrapper.call(
        messages=[{"role": "user", "content": f"Summarize:\n\n{text}"}],
        model="claude-sonnet-4-6",
        max_tokens=500,
        correlation_id=str(uuid.uuid4()),
    )
    # content is the standard Anthropic format
    return result["content"][0]["text"]

What this wrapper actually gives you

  • Never hits 429 unnecessarily — the token bucket smooths outgoing traffic below your tier limit.
  • Handles transient failures gracefully — jittered retries with respect for Retry-After.
  • Protects against outages — the breaker opens after sustained failure, saving tokens during recovery.
  • Fails over to Bedrock automatically — either when the breaker is open or when direct retries are exhausted.
  • Never retries your bugs — 400s and 401s surface immediately for developer attention.
  • Never hangs the caller — every path either returns a response or raises within bounded time.
  • Emits actionable logs — correlation IDs and structured fields let you trace any request end-to-end.

What it doesn't give you (yet)

This is the minimum viable version. For higher-scale services, add these on top:

  • Distributed state — the token bucket and breaker are per-process. For multi-instance services, back them with Redis so all instances share limits and health signals.
  • Metrics emission — wire the log points to Prometheus or Datadog. Track retry rate, breaker state transitions, fallback usage, and cache hit rate.
  • Prompt caching — add cache_control markers to reduce cost for stable prefixes.
  • Cross-provider fallback — the current wrapper falls back to Bedrock (same model). For catastrophic outages, add GPT or Gemini as a tertiary tier. See the fallback strategies deep-dive linked below.
  • Context management — long conversations need history compression before hitting the wrapper. See the context window guide.

Where to go deeper

Each layer in this wrapper is a topic worth understanding on its own. If you're building at scale, the following pillar guides cover each one in detail with production considerations this post skims:

Frequently asked questions