← Back to DevBytes

Rate Limiting and Retry Strategies with Claude Code: Complete Guide

Introduction to Rate Limiting and Retry Strategies with Claude Code

When building applications on top of Claude Code and the Anthropic API, two operational concerns quickly become critical: rate limiting and retry strategies. Claude Code is a powerful agentic coding tool that can issue many API requests in rapid succession, and without proper handling, your workflows can stall, fail, or even get throttled. This guide walks through everything you need to know to build resilient, production-grade integrations.

What Is Rate Limiting?

Rate limiting is a mechanism used by API providers to control the volume of requests a client can make within a given time window. Anthropic enforces several types of limits on the Claude API:

When you exceed any of these limits, the API responds with an HTTP 429 Too Many Requests status code. This response typically includes headers that tell you when you can safely retry.

Why Rate Limiting and Retries Matter

Claude Code operates as an autonomous agent that can chain multiple tool calls, file edits, and reasoning steps together. A single user session might generate dozens of API requests in seconds. Without a deliberate strategy, you risk:

A well-designed retry and rate-limiting layer turns these failures into transparent, recoverable events.

Understanding the 429 Response

When Anthropic rate-limits a request, the response includes useful headers you should parse:

Reading these headers lets you build smarter clients that proactively slow down before hitting limits.

Building a Basic Retry Wrapper

The simplest retry strategy is exponential backoff with jitter. The idea is to wait progressively longer between retries, with a small random offset to avoid thundering-herd effects when many clients retry simultaneously.

Python Implementation

import time
import random
import requests

def call_claude_with_retry(
    payload,
    api_key,
    max_retries=5,
    base_url="https://api.anthropic.com/v1/messages"
):
    headers = {
        "x-api-key": api_key,
        "anthropic-version": "2023-06-01",
        "content-type": "application/json",
    }

    for attempt in range(max_retries + 1):
        response = requests.post(base_url, headers=headers, json=payload)

        if response.status_code == 200:
            return response.json()

        if response.status_code == 429:
            retry_after = int(response.headers.get("retry-after", 2 ** attempt))
            jitter = random.uniform(0, 0.5)
            sleep_time = retry_after + jitter
            print(f"Rate limited. Retrying in {sleep_time:.2f}s (attempt {attempt + 1})")
            time.sleep(sleep_time)
            continue

        if 500 <= response.status_code < 600:
            backoff = (2 ** attempt) + random.uniform(0, 1)
            print(f"Server error {response.status_code}. Retrying in {backoff:.2f}s")
            time.sleep(backoff)
            continue

        # Non-retryable error
        response.raise_for_status()

    raise Exception(f"Max retries ({max_retries}) exceeded")

This wrapper handles both rate-limit errors and transient server errors, while immediately raising on client errors like 401 or 400 that retries cannot fix.

TypeScript Implementation

interface ClaudeResponse {
  id: string;
  content: Array<{ type: string; text: string }>;
}

async function callClaudeWithRetry(
  payload: object,
  apiKey: string,
  maxRetries = 5
): Promise<ClaudeResponse> {
  const url = "https://api.anthropic.com/v1/messages";
  const headers = {
    "x-api-key": apiKey,
    "anthropic-version": "2023-06-01",
    "content-type": "application/json",
  };

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const res = await fetch(url, {
        method: "POST",
        headers,
        body: JSON.stringify(payload),
      });

      if (res.ok) {
        return (await res.json()) as ClaudeResponse;
      }

      if (res.status === 429) {
        const retryAfter = parseInt(res.headers.get("retry-after") ?? String(2 ** attempt));
        const jitter = Math.random() * 0.5;
        const wait = retryAfter + jitter;
        console.log(`Rate limited. Retrying in ${wait.toFixed(2)}s (attempt ${attempt + 1})`);
        await new Promise((r) => setTimeout(r, wait * 1000));
        continue;
      }

      if (res.status >= 500) {
        const backoff = 2 ** attempt + Math.random();
        console.log(`Server error ${res.status}. Retrying in ${backoff.toFixed(2)}s`);
        await new Promise((r) => setTimeout(r, backoff * 1000));
        continue;
      }

      throw new Error(`Request failed with status ${res.status}: ${await res.text()}`);
    } catch (err) {
      if (attempt === maxRetries) throw err;
      const backoff = 2 ** attempt + Math.random();
      await new Promise((r) => setTimeout(r, backoff * 1000));
    }
  }

  throw new Error(`Max retries (${maxRetries}) exceeded`);
}

Using the Official Anthropic SDKs

Both the Python and TypeScript SDKs ship with built-in retry logic. You rarely need to roll your own unless you have specialized requirements.

Python SDK

from anthropic import Anthropic

client = Anthropic(
    max_retries=5,
    timeout=60.0,
)

response = client.messages.create(
    model="claude-sonnet-4-5-20250929",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Explain rate limiting in one sentence."}],
)
print(response.content[0].text)

The SDK automatically applies exponential backoff for 429 and 5xx responses. You can also customize the backoff strategy:

from anthropic import Anthropic
from anthropic._utils import RetryOptions

client = Anthropic(
    max_retries=6,
    timeout=120.0,
    default_headers={"X-Custom-Header": "my-app"},
)

# Override per-request
response = client.messages.with_raw_response.create(
    model="claude-sonnet-4-5-20250929",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello"}],
    extra_headers={"anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15"},
)

TypeScript SDK

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic({
  maxRetries: 5,
  timeout: 60_000,
});

const response = await client.messages.create({
  model: "claude-sonnet-4-5-20250929",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Explain rate limiting in one sentence." }],
});

console.log(response.content[0].text);

Proactive Rate Limiting with a Token Bucket

Retries are reactive. A more robust approach is to throttle outgoing requests before they hit the API. A token bucket is a classic algorithm: tokens refill at a fixed rate, and each request consumes one. If the bucket is empty, the client waits.

import time
import threading

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate          # tokens per second
        self.capacity = capacity  # max tokens
        self.tokens = capacity
        self.last_refill = time.monotonic()
        self.lock = threading.Lock()

    def acquire(self, tokens=1):
        with self.lock:
            now = time.monotonic()
            elapsed = now - self.last_refill
            self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
            self.last_refill = now

            if self.tokens >= tokens:
                self.tokens -= tokens
                return 0.0
            else:
                deficit = tokens - self.tokens
                return deficit / self.rate

# Usage: 50 requests per minute = ~0.83 tokens/sec
bucket = TokenBucket(rate=50 / 60, capacity=50)

def send_request():
    wait = bucket.acquire()
    if wait > 0:
        time.sleep(wait)
    # ... make API call

Combine a token bucket with the SDK's built-in retries for defense in depth: the bucket smooths traffic, and retries catch the occasional burst that still slips through.

Handling Concurrency in Claude Code Workflows

Claude Code often runs multiple sub-agents or tool calls in parallel. If you spawn many concurrent requests, you can blow through your concurrent-request limit even when your RPM is fine. Use a semaphore to cap parallelism:

import asyncio
from anthropic import AsyncAnthropic

client = AsyncAnthropic(max_retries=5)
semaphore = asyncio.Semaphore(5)  # max 5 concurrent requests

async def ask_claude(prompt: str) -> str:
    async with semaphore:
        response = await client.messages.create(
            model="claude-sonnet-4-5-20250929",
            max_tokens=1024,
            messages=[{"role": "user", "content": prompt}],
        )
        return response.content[0].text

async def main():
    prompts = [f"Summarize topic {i}" for i in range(50)]
    results = await asyncio.gather(*[ask_claude(p) for p in prompts])
    for r in results:
        print(r)

asyncio.run(main())

Retry Strategies: Choosing the Right Approach

Not all failures should be retried the same way. Here is a decision framework:

1. Fixed Delay

Wait a constant amount of time between retries. Simple but inefficient under heavy load.

for attempt in range(max_retries):
    try:
        return do_request()
    except RetryableError:
        time.sleep(2)

2. Exponential Backoff

Double the wait time after each failure. Good default for most APIs.

for attempt in range(max_retries):
    try:
        return do_request()
    except RetryableError:
        time.sleep(2 ** attempt)

3. Exponential Backoff with Jitter

Add randomness to avoid synchronized retry storms. This is the recommended default for Claude.

import random
for attempt in range(max_retries):
    try:
        return do_request()
    except RetryableError:
        sleep_time = (2 ** attempt) + random.uniform(0, 1)
        time.sleep(sleep_time)

4. Decorrelated Jitter

Track the previous sleep time and base the next one on it. Useful when retry windows are unpredictable.

import random
sleep_time = 1.0
for attempt in range(max_retries):
    try:
        return do_request()
    except RetryableError:
        sleep_time = min(cap, random.uniform(1, sleep_time * 3))
        time.sleep(sleep_time)

Idempotency: Making Retries Safe

Retries are only safe if repeating a request does not cause unintended side effects. For Claude's Messages API, requests are naturally idempotent for read-only prompts, but if you are using tools that write files or run commands through Claude Code, you must design for idempotency:

import uuid

def safe_agent_step(prompt, state_store):
    request_id = str(uuid.uuid4())
    if state_store.already_processed(request_id):
        return state_store.get_result(request_id)

    result = call_claude_with_retry({"prompt": prompt, "request_id": request_id})
    state_store.save_result(request_id, result)
    return result

Monitoring and Observability

You cannot manage what you cannot see. Instrument your client to log rate-limit headers and retry events:

import logging
import time

logger = logging.getLogger("claude-client")

def call_with_observability(payload, client):
    start = time.monotonic()
    response = client.messages.create(**payload)

    logger.info(
        "claude_request",
        extra={
            "model": payload.get("model"),
            "duration_ms": (time.monotonic() - start) * 1000,
            "requests_remaining": response.usage.input_tokens,
            "input_tokens": response.usage.input_tokens,
            "output_tokens": response.usage.output_tokens,
        },
    )
    return response

Track these key metrics:

Best Practices

Putting It All Together

Here is a complete production-ready example combining the SDK, a token bucket, concurrency control, and observability:

import asyncio
import logging
import time
from anthropic import AsyncAnthropic

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("claude-prod")

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate
        self.capacity = capacity
        self.tokens = capacity
        self.last = time.monotonic()

    async def acquire(self):
        while True:
            now = time.monotonic()
            self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
            self.last = now
            if self.tokens >= 1:
                self.tokens -= 1
                return
            await asyncio.sleep((1 - self.tokens) / self.rate)

class ClaudeClient:
    def __init__(self, api_key, rpm=50, max_concurrency=5):
        self.client = AsyncAnthropic(api_key=api_key, max_retries=5, timeout=120)
        self.bucket = TokenBucket(rate=rpm / 60, capacity=rpm)
        self.semaphore = asyncio.Semaphore(max_concurrency)

    async def complete(self, prompt, model="claude-sonnet-4-5-20250929", max_tokens=1024):
        async with self.semaphore:
            await self.bucket.acquire()
            start = time.monotonic()
            try:
                response = await self.client.messages.create(
                    model=model,
                    max_tokens=max_tokens,
                    messages=[{"role": "user", "content": prompt}],
                )
                logger.info(
                    "request_ok duration_ms=%.0f input=%d output=%d",
                    (time.monotonic() - start) * 1000,
                    response.usage.input_tokens,
                    response.usage.output_tokens,
                )
                return response.content[0].text
            except Exception as e:
                logger.error("request_failed error=%s duration_ms=%.0f",
                             str(e), (time.monotonic() - start) * 1000)
                raise

async def main():
    cc = ClaudeClient(api_key="sk-ant-...", rpm=50, max_concurrency=5)
    prompts = [f"Write a haiku about topic {i}" for i in range(30)]
    results = await asyncio.gather(*[cc.complete(p) for p in prompts], return_exceptions=True)
    for i, r in enumerate(results):
        if isinstance(r, Exception):
            print(f"[{i}] FAILED: {r}")
        else:
            print(f"[{i}] {r}")

asyncio.run(main())

Conclusion

Rate limiting and retry strategies are not optional polish — they are foundational to any reliable Claude Code integration. By combining the official SDK's built-in retries with proactive client-side throttling, concurrency control, idempotent design, and thorough observability, you can build agentic workflows that stay fast under load and recover gracefully from transient failures. Start with the SDK defaults, add a token bucket when traffic grows, and always respect the retry-after header. With these patterns in place, your Claude-powered applications will be ready for production scale.

— Ad —

Google AdSense will appear here after approval

← Back to all articles