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:
- Requests per minute (RPM): The maximum number of API calls you can make each minute.
- Input tokens per minute (ITPM): The total number of input tokens processed per minute.
- Output tokens per minute (OTPM): The total number of output tokens generated per minute.
- Concurrent requests: A cap on how many in-flight requests you can have simultaneously.
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:
- Workflow interruptions that break long-running tasks halfway through.
- Poor user experience when errors surface as raw stack traces.
- Wasted tokens and compute when requests fail after expensive processing.
- Account-level throttling that affects all users of your integration.
- Unpredictable latency when the system backs off inconsistently.
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:
retry-after: Seconds to wait before retrying.anthropic-ratelimit-requests-limit: Your RPM limit.anthropic-ratelimit-requests-remaining: Remaining requests in the window.anthropic-ratelimit-requests-reset: Time when the limit resets.anthropic-ratelimit-tokens-remaining: Remaining tokens in the window.
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:
- Use unique request IDs so you can detect and discard duplicates.
- Make file operations atomic by writing to temp files and renaming.
- Track state externally so a retried agent step does not re-apply changes.
- Log every attempt with a correlation ID for debugging.
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:
claude_request_total: Counter of all requests.claude_retry_total: Counter of retries, labeled by reason.claude_request_duration_seconds: Histogram of latency.claude_rate_limit_remaining: Gauge from response headers.claude_tokens_used: Counter split by input/output.
Best Practices
- Always use the official SDKs unless you have a compelling reason not to. They handle retries, timeouts, and edge cases for you.
- Respect the
retry-afterheader instead of guessing backoff times. - Cap total retry time so a single failing request does not hang your agent indefinitely.
- Use jitter on every retry to avoid synchronized retry storms.
- Separate retryable from non-retryable errors. Never retry
400,401,403, or422errors. - Implement client-side throttling with a token bucket when you know your RPM ceiling.
- Limit concurrency with semaphores when running parallel Claude Code sub-tasks.
- Cache responses for identical prompts to reduce redundant API calls.
- Stream responses when possible to reduce the chance of timeouts on long generations.
- Design for idempotency so retries never cause duplicate side effects.
- Monitor rate-limit headers and alert when remaining capacity drops below a threshold.
- Request a rate limit increase proactively if you anticipate sustained high traffic.
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.