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_room is 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 429 is 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 about book_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:

  1. The official Anthropic SDK already retries the 429 for you. In both Python and TypeScript, the client automatically retries 408, 409, 429, and 5xx errors, with its own exponential backoff, up to max_retries times (the default is 2). 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 adjust max_retries with judgment.
  2. The retry-after header is the source of truth, not the calculated backoff. When the Claude API responds 429, 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.
  3. Telling the 429 apart from other non-retryable errors. 429 and 5xx are worth retrying; 400, 401, 403 aren'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 like retry_on=(ConnectionError,) did for tools in Lesson 3.

Common mistakes

  1. Putting a CircuitBreaker on Claude's 429. 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 several 429s would leave the entire agent unable to decide anything, for any tool, until the cooldown was met — the opposite of what a real 429 needs, which is to wait a bit and keep going.

  2. Confusing the exception's retry_after_ms with the calculated backoff. They're two different numbers with different purposes: retry_after_ms is 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.

  3. Retrying a 401 or a 403 with the same mechanism as a 429. A 401 authentication_error (invalid API key) or a 403 permission_error (no permission for that resource) don't change their result by being retried — they're, exactly like an invalid tier="premium" from agent-fundamentals M7, validation errors, not transient failures. retry_on exists, 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_error is 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 changed retry_on=(RateLimitError,) — and confirmed, run for real, that it recovers from a brief 429 and honestly gives up against a sustained one.
  • The 429 always 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/5xx automatically (by default, up to twice), and should prefer the server's retry-after over 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

  1. 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.
  2. Anthropic — Errors — The complete table of Claude API error codes, including which are retryable (429, 5xx) and which aren't (400, 401, 403, 404).
  3. Python — User-defined exceptions — The mechanism behind RateLimitError(Exception), with its own attribute (retry_after_ms) in addition to the message.
  4. 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.