Module 6: Failures at Scale — Backoff, Circuit Breakers, and Rate Limits
The Claude 429 Rate Limit
Description
Everything you built in Lessons 3 through 5 protects the Reservo agent against one specific type of failure: a tool — one of its own dependencies, book_room — that stops responding. This lesson changes layers entirely. There's a failure no agent tool can cause, and no circuit breaker over book_room can prevent: the Claude API itself responding 429 rate_limit_error — "your organization exceeded its requests-per-minute (or tokens-per-minute) quota, right now." It isn't that Claude is down; it's working perfectly fine for everyone else. It's your own usage that, at that instant, exceeds what's allowed. This lesson builds the correct response to that case — and explains, precisely, why that response never includes a circuit breaker.
Connection to the module
We reuse, without changing a single line, Lesson 3's retry_with_backoff — the same bounded-exponential-backoff retry, applied this time to a completely different type of exception. It's proof that function was well designed from the start: retry_on exists exactly for this moment, so you don't have to rewrite the retry mechanism every time a new kind of transient failure shows up.
Why the 429 isn't "just one more tool failing"
Before writing code, it's worth being precise about the difference, because at first glance a 429 and a ConnectionError from book_room look alike — both are "something failed, probably worth retrying." The real difference is in what the failure represents:
- A downed
book_roomis a local problem: a specific Reservo dependency, unrelated to Claude, stopped responding. The rest of the system —get_quote,list_rooms, and the model's own decision — keeps working normally. That's why a circuit breaker makes sense: isolate the damage to that specific tool, without affecting the rest. - Claude's
429is a quota problem, not an availability one: the API stays up, it keeps responding, for your organization and for everyone else. All that happened is that, in this time window, what your quota allows has already run out. There's nothing to "isolate" — the next call to the model, whether aboutbook_room,get_quote, or anything else, is going to hit exactly the same quota problem, because the limit is on total API usage, not on a specific tool.
That difference has a direct consequence: a circuit breaker over calls to the model wouldn't have anything to "isolate" — the call to the model is the product. A breaker that opened after a few 429s would leave the entire agent unable to decide anything, for any user, until the cooldown was met — exactly the opposite of what you want when your quota recovers second by second. The right response to a 429 is, always, to retry with backoff — never to cut it off.
Worked example: RateLimitError, simulated deterministically
The error, and a test client that returns it twice
# resilience/claude_rate_limit.py
class RateLimitError(Exception):
"""Concepto del 429 rate_limit_error de la API de Claude. En una llamada
real, el SDK expone esto como anthropic.RateLimitError con
e.response.headers['retry-after'] (segundos); aquí se simula con un
atributo retry_after_ms fijo, sin ninguna llamada de red."""
def __init__(self, message, retry_after_ms):
super().__init__(message)
self.retry_after_ms = retry_after_ms
def make_flaky_claude_client(fail_times=2, retry_after_ms=500):
"""Simula un cliente que devuelve 429 en las primeras `fail_times`
llamadas y después responde con normalidad. Concepto: nunca hay una
llamada real a la red ni a la API de Claude."""
calls = {"count": 0}
def call_claude_api(prompt):
calls["count"] += 1
if calls["count"] <= fail_times:
raise RateLimitError(
f"429 rate_limit_error (intento {calls['count']} de este cliente)",
retry_after_ms=retry_after_ms,
)
return {
"stop_reason": "end_turn",
"content": [{"type": "text", "text": f"(concepto, claude-sonnet-5) respuesta a: {prompt!r}"}],
}
return call_claude_api
🛑 Hard rule, again: call_claude_api never makes a network call — it's a test function, with a counter closed over itself (calls), that decides in advance how many times it's going to fail. No part of this module depends on whether your real quota is or isn't exceeded right now; the 429 gets simulated exactly the same way, always, on any machine running this code.
Run for real: 429 twice, then responds normally
retry_with_backoff is literally Lesson 3's same function — the only difference is the retry_on we pass it.
print("=== 429 dos veces seguidas, después responde con normalidad ===")
call_claude_api = make_flaky_claude_client(fail_times=2, retry_after_ms=500)
response = retry_with_backoff(
call_claude_api, "Reserva Focus pro 3h para Ana",
max_retries=3, base_delay_ms=250, retry_on=(RateLimitError,),
)
print("respuesta:", response["content"][0]["text"])
What to expect:
=== 429 dos veces seguidas, después responde con normalidad ===
intento 1/3...
fallo transitorio (RateLimitError): 429 rate_limit_error (intento 1 de este cliente) -- backoff modelado: 250ms (no se duerme de verdad)
intento 2/3...
fallo transitorio (RateLimitError): 429 rate_limit_error (intento 2 de este cliente) -- backoff modelado: 500ms (no se duerme de verdad)
intento 3/3...
respuesta: (concepto, claude-sonnet-5) respuesta a: 'Reserva Focus pro 3h para Ana'
The first two attempts get the simulated 429, each with its backoff calculated (250ms, then 500ms); the third, already within quota, responds normally. Not a single line of this code changed from Lesson 3 — retry_with_backoff is exactly the same function — the only thing different is which exception type we're retrying (RateLimitError instead of ConnectionError) and what we're applying it to (a call to the model, not a tool).
Run for real: a sustained 429, the cap isn't enough
print("=== 429 sostenido (5 veces): max_retries=3 NO alcanza ===")
call_claude_api_2 = make_flaky_claude_client(fail_times=5, retry_after_ms=500)
try:
retry_with_backoff(
call_claude_api_2, "Reserva Studio pro 2h para Luis",
max_retries=3, base_delay_ms=250, retry_on=(RateLimitError,),
)
except RateLimitError as exc:
print(f"RateLimitError final: {exc} (retry_after_ms sugerido: {exc.retry_after_ms})")
What to expect:
=== 429 sostenido (5 veces): max_retries=3 NO alcanza ===
intento 1/3...
fallo transitorio (RateLimitError): 429 rate_limit_error (intento 1 de este cliente) -- backoff modelado: 250ms (no se duerme de verdad)
intento 2/3...
fallo transitorio (RateLimitError): 429 rate_limit_error (intento 2 de este cliente) -- backoff modelado: 500ms (no se duerme de verdad)
intento 3/3...
fallo transitorio (RateLimitError): 429 rate_limit_error (intento 3 de este cliente) -- backoff modelado: 1000ms (no se duerme de verdad)
RateLimitError final: 429 rate_limit_error (intento 3 de este cliente) (retry_after_ms sugerido: 500)
With a quota that takes five calls to recover and only three retries available, retry_with_backoff gives up and re-raises the real RateLimitError — never an empty text, never a made-up response. Notice the retry_after_ms traveling inside the exception: in a real system connected to the real API, that value would come from the retry-after header the Claude API returns alongside the 429 — how many seconds to wait, according to the server itself, before retrying — and a careful client would use it instead of (or in addition to) its own calculated backoff. This lesson simulates it as a fixed attribute, without implementing that additional logic — honesty matters more than completeness here: the central lesson isn't "how to read a header," it's "why this type of failure gets retried and never cut off with a breaker."
What a real system would do differently
Three details this guide simplifies, with explicit honesty, because implementing them for real wouldn't change the underlying lesson:
- The official Anthropic SDK already retries the
429for you. In both Python and TypeScript, the client automatically retries408,409,429, and5xxerrors, with its own exponential backoff, up tomax_retriestimes (the default is2). In most real systems, this lesson's logic is already solved before you write a single line — you just need to know it well enough not to reinvent it badly, or to know when to adjustmax_retrieswith judgment. - The
retry-afterheader is the source of truth, not the calculated backoff. When the Claude API responds429, it includes how many seconds to wait before retrying — a real system should prefer that number (when present) over its own exponential calculation, because it comes directly from the server that knows when the quota is going to free up. - Telling the
429apart from other non-retryable errors.429and5xxare worth retrying;400,401,403aren't — they're client-side errors a retry is never going to fix.retry_on=(RateLimitError,)in this lesson already makes that distinction by design, just likeretry_on=(ConnectionError,)did for tools in Lesson 3.
Common mistakes
-
Putting a
CircuitBreakeron Claude's429. We already explained this above: there's no "specific" dependency to isolate — the call to the model is the entire product. A breaker that opened after several429s would leave the entire agent unable to decide anything, for any tool, until the cooldown was met — the opposite of what a real429needs, which is to wait a bit and keep going. -
Confusing the exception's
retry_after_mswith the calculated backoff. They're two different numbers with different purposes:retry_after_msis what Claude's server tells you to wait (real information, when available); the calculated backoff (compute_backoff_ms) is what this client decides to wait on its own, when it doesn't have that information. In a real system, the first should win over the second when both exist. -
Retrying a
401or a403with the same mechanism as a429. A401 authentication_error(invalid API key) or a403 permission_error(no permission for that resource) don't change their result by being retried — they're, exactly like an invalidtier="premium"fromagent-fundamentalsM7, validation errors, not transient failures.retry_onexists, precisely, so this kind of error never enters the retry loop.
Exercises
Exercise 1: Calculate whether the cap is enough (Easy)
Without running Python: with fail_times=4 (the client fails the first four calls) and max_retries=3, does this lesson's retry_with_backoff manage a successful response? What about with max_retries=4? Apply the same for attempt in range(1, max_retries + 1) reasoning you already used in Lesson 3.
See solution
With max_retries=3, no — the loop goes through attempts 1, 2, and 3, all three within the four-failure window the simulated client is configured to produce; it runs out before reaching the fifth attempt (which would be the first successful one). With max_retries=4, also no — wait: fail_times=4 means calls 1 through 4 fail, and 5 succeeds, so with max_retries=4 the loop runs out on attempt 4 with no success. You need max_retries=5 to reach the first successful attempt. The rule, same as in Lesson 3: for a source that needs N total attempts to recover, max_retries has to be, at minimum, N.
Exercise 2: Simulate a 529 overloaded_error with the same mechanism (Medium)
The Claude API can also respond 529 overloaded_error — the service is temporarily overloaded, a different case from 429 but just as transient and retryable. Declare an OverloadedError(Exception) exception, a simulated client that raises it the first two times, and reuse retry_with_backoff with retry_on=(OverloadedError,) to confirm it recovers.
See solution
class OverloadedError(Exception):
"""Concepto del 529 overloaded_error de la API de Claude -- temporal,
retryable, distinto del 429 (cuota) aunque se maneje igual."""
def make_overloaded_claude_client(fail_times=2):
calls = {"count": 0}
def call_claude_api(prompt):
calls["count"] += 1
if calls["count"] <= fail_times:
raise OverloadedError(f"529 overloaded_error (intento {calls['count']})")
return {"stop_reason": "end_turn", "content": [{"type": "text", "text": f"(concepto) respuesta a: {prompt!r}"}]}
return call_claude_api
call_claude_api_overloaded = make_overloaded_claude_client(fail_times=2)
response = retry_with_backoff(
call_claude_api_overloaded, "Cancela la reserva 1",
max_retries=3, base_delay_ms=200, retry_on=(OverloadedError,),
)
print("respuesta:", response["content"][0]["text"])
Expected output:
intento 1/3...
fallo transitorio (OverloadedError): 529 overloaded_error (intento 1) -- backoff modelado: 200ms (no se duerme de verdad)
intento 2/3...
fallo transitorio (OverloadedError): 529 overloaded_error (intento 2) -- backoff modelado: 400ms (no se duerme de verdad)
intento 3/3...
respuesta: (concepto) respuesta a: 'Cancela la reserva 1'
Explanation: retry_with_backoff needed no change at all — just a different retry_on. That's exactly why it was designed with a configurable parameter instead of having the exception type hardcoded inside: any new transient failure — a 429, a 529, or something that doesn't exist yet — plugs into the same mechanism without touching a single line of its implementation.
Exercise 3: Why would retry_on=(RateLimitError, ConnectionError) be a design mistake here? (Hard)
Someone proposes simplifying Reservo's code by calling retry_with_backoff with retry_on=(RateLimitError, ConnectionError) in one place, to "cover both cases with a single call" — both book_room's failures and Claude's 429s. Explain, in one paragraph, why mixing both failure types in a single retry_with_backoff call would be a bad idea, even though it would technically work with no syntax errors.
See solution
It would technically compile and run, but it would mix two decisions that have to be made in completely different places in the system, with different parameters: retrying book_room happens inside dispatch_robust/call_with_breaker, additionally protected by Lessons 4-5's circuit breaker (because book_room genuinely can be dead, and there a breaker makes sense); retrying Claude's 429 happens at the call-to-the-model layer, well before the agent even decides which tool to call, and there a breaker never makes sense, as this lesson explained. If a single retry_with_backoff call covered both cases, you'd lose the ability to apply a circuit breaker to one and not the other — you'd have to wrap ALL that code, tool calls and model calls alike, with the same open/close logic — when the right decision is that only book_room gets that protection. Keeping the two retry_with_backoff calls separate — one for tools, with its own CircuitBreaker around it; another for the model, with none — is what lets you treat each layer according to what the failure actually represents, instead of treating them all the same because they share the same generic verb ("retry").
Summary and next step
- The Claude API's
429 rate_limit_erroris a quota problem, not an availability one — the API keeps working perfectly for everyone; your organization, at this instant, asked for more than what's allowed. That's why it never carries a circuit breaker: there's no specific dependency to isolate, and cutting off calls to the model would leave the entire agent unable to decide anything. - We reused, without changing a single line, Lesson 3's
retry_with_backoff— we only changedretry_on=(RateLimitError,)— and confirmed, run for real, that it recovers from a brief429and honestly gives up against a sustained one. - The
429always gets simulated deterministically, with a test client that decides in advance how many times it's going to fail — there's never a real call to the Claude API in this guide. - A real system already has a good chunk of this solved: the official SDK retries
429/5xxautomatically (by default, up to twice), and should prefer the server'sretry-afterover its own calculated backoff when that header is present.
Next lesson: 07 — Graceful Degradation. We go back to book_room and its circuit breaker: what the agent tells the user, clearly and quickly, when the circuit is open — without building bulkheads or load shedding, reusing the is_error protocol agent-fundamentals already built.
Additional resources
- Anthropic — Rate limits — How the Claude API's limits work per account tier (requests per minute, tokens per minute, tokens per day), and the
x-ratelimit-limit-*/x-ratelimit-remaining-*headers that report how much quota is left. - Anthropic — Errors — The complete table of Claude API error codes, including which are retryable (
429,5xx) and which aren't (400,401,403,404). - Python — User-defined exceptions — The mechanism behind
RateLimitError(Exception), with its own attribute (retry_after_ms) in addition to the message. resilience-and-reliability-patterns-guide(Module 3) — The complete theory of backoff and jitter for generic HTTP dependencies; this module applies it, with the same ideas, to a case that guide never covers: the LLM provider's rate limit.