Módulo 6: Fallos a escala — backoff, circuit breakers y rate limits

El 429 de Claude

Descripción

Todo lo que construiste en las Lecciones 3 a 5 protege al agente de Reservo contra un tipo de fallo específico: una tool —una dependencia propia, book_room— que deja de responder. Esta lección cambia de capa por completo. Hay un fallo que ninguna tool del agente puede causar, y que ningún circuit breaker sobre book_room puede prevenir: la propia API de Claude respondiendo 429 rate_limit_error — "tu organización superó su cuota de peticiones (o de tokens) por minuto, ahora mismo". No es que Claude esté caído; está funcionando perfectamente bien para todos los demás. Es tu propio uso el que, en ese instante, excede lo permitido. Esta lección construye la respuesta correcta a ese caso — y explica, con precisión, por qué esa respuesta nunca incluye un circuit breaker.

Conexión con el módulo

Reusamos, sin cambiar una línea, retry_with_backoff de la Lección 3 — el mismo reintento con backoff exponencial acotado, aplicado esta vez a un tipo de excepción completamente distinto. Es la prueba de que esa función se diseñó bien desde el principio: retry_on existe exactamente para este momento, para no tener que reescribir el mecanismo de reintento cada vez que aparece un fallo transitorio nuevo.


Por qué el 429 no es "una tool más que falla"

Antes de escribir código, vale la pena ser preciso sobre la diferencia, porque a primera vista un 429 y un ConnectionError de book_room se parecen — ambos son "algo falló, probablemente valga la pena reintentar". La diferencia real está en qué representa el fallo:

  • book_room caído es un problema local: una dependencia específica de Reservo, ajena a Claude, dejó de responder. El resto del sistema —get_quote, list_rooms, y la propia decisión del modelo— sigue funcionando con normalidad. Por eso tiene sentido un circuit breaker: aislar el daño a esa tool puntual, sin afectar al resto.
  • El 429 de Claude es un problema de cuota, no de disponibilidad: la API sigue arriba, sigue respondiendo, para tu organización y para cualquier otra. Lo único que pasó es que, en esta ventana de tiempo, ya se agotó lo que tu cuota permite. No hay nada que "aislar" — la próxima llamada al modelo, sea sobre book_room, get_quote o cualquier otra cosa, va a tener exactamente el mismo problema de cuota, porque el límite es sobre el uso total de la API, no sobre una tool específica.

Esa diferencia tiene una consecuencia directa: un circuit breaker sobre las llamadas al modelo no tendría a qué "aislar" — la llamada al modelo es el producto. Un breaker que abriera después de unos cuantos 429 dejaría al agente completo sin poder decidir nada, para cualquier usuario, hasta que el cooldown se cumpliera — exactamente lo contrario de lo que quieres cuando tu cuota se recupera segundo a segundo. La respuesta correcta a un 429 es, siempre, reintentar con backoff — nunca cortar el paso.


Ejemplo trabajado: RateLimitError, simulado de forma determinista

El error, y un cliente de prueba que lo devuelve dos veces

# 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

🛑 Regla dura, otra vez: call_claude_api nunca hace una llamada de red — es una función de prueba, con un contador cerrado sobre sí misma (calls), que decide de antemano cuántas veces va a fallar. Ninguna parte de este módulo depende de si tu cuota real está o no excedida en este momento; el 429 se simula exactamente igual, siempre, en cualquier máquina que corra este código.

Ejecutado: 429 dos veces, después responde con normalidad

retry_with_backoff es literalmente la misma función de la Lección 3 — la única diferencia es el retry_on que le pasamos.

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"])

Qué esperar:

=== 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'

Los primeros dos intentos reciben el 429 simulado, cada uno con su backoff calculado (250ms, después 500ms); el tercero, ya dentro de la cuota, responde con normalidad. Ni una línea de este código cambió respecto a la Lección 3 —retry_with_backoff es exactamente la misma función—; lo único distinto es qué tipo de excepción estamos retentando (RateLimitError en vez de ConnectionError) y contra qué la aplicamos (una llamada al modelo, no una tool).

Ejecutado: un 429 sostenido, el tope no alcanza

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})")

Qué esperar:

=== 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)

Con una cuota que tarda cinco llamadas en recuperarse y solo tres reintentos disponibles, retry_with_backoff se rinde y relanza el RateLimitError real — nunca un texto vacío, nunca una respuesta inventada. Nota el retry_after_ms que viaja adentro de la excepción: en un sistema real conectado a la API de verdad, ese valor vendría del header retry-after que la API de Claude devuelve junto con el 429 —cuántos segundos esperar, según el propio servidor, antes de reintentar—, y un cliente cuidadoso lo usaría en vez de (o además de) su propio backoff calculado. Esta lección lo simula como un atributo fijo, sin implementar esa lógica adicional — la honestidad importa más que la completitud aquí: la lección central no es "cómo leer un header", es "por qué este tipo de fallo se retenta y nunca se corta con un breaker".


Lo que un sistema real haría distinto

Tres detalles que esta guía simplifica, con honestidad explícita, porque implementarlos de verdad no cambiaría la lección de fondo:

  1. El SDK oficial de Anthropic ya reintenta el 429 por ti. Tanto en Python como en TypeScript, el cliente reintenta automáticamente errores 408, 409, 429 y 5xx, con backoff exponencial propio, hasta max_retries veces (el valor por defecto es 2). En la mayoría de los sistemas reales, la lógica de esta lección ya está resuelta antes de que escribas una sola línea — solo hace falta conocerla lo suficiente como para no reinventarla mal, o para saber cuándo ajustar max_retries con criterio.
  2. El header retry-after es la fuente de verdad, no el backoff calculado. Cuando la API de Claude responde 429, incluye cuántos segundos esperar antes de reintentar — un sistema real debería preferir ese número (cuando está presente) sobre su propio cálculo exponencial, porque viene directamente del servidor que sabe cuándo se va a liberar la cuota.
  3. Distinguir el 429 de otros errores no-retryables. 429 y 5xx vale la pena reintentarlos; 400, 401, 403 no —son errores del lado del cliente que un reintento nunca va a arreglar—. retry_on=(RateLimitError,) en esta lección ya hace esa distinción por diseño, igual que retry_on=(ConnectionError,) la hacía para tools en la Lección 3.

Errores comunes

  1. Ponerle un CircuitBreaker al 429 de Claude. Ya lo explicamos arriba: no hay ninguna dependencia "puntual" que aislar — la llamada al modelo es todo el producto. Un breaker que abriera después de varios 429 dejaría al agente entero incapaz de decidir nada, para cualquier tool, hasta que el cooldown se cumpliera — el problema inverso de lo que un 429 real necesita, que es esperar un poco y seguir andando.

  2. Confundir el retry_after_ms de la excepción con el backoff calculado. Son dos números distintos con propósitos distintos: retry_after_ms es lo que el servidor de Claude te dice que esperes (información real, cuando está disponible); el backoff calculado (compute_backoff_ms) es lo que este cliente decide esperar por su cuenta, cuando no tiene esa información. En un sistema real, el primero debería ganarle al segundo cuando ambos existen.

  3. Reintentar un 401 o un 403 con el mismo mecanismo que un 429. Un 401 authentication_error (API key inválida) o un 403 permission_error (sin permiso para ese recurso) no cambian de resultado por reintentarlos — son, exactamente igual que un tier="premium" inválido de agent-fundamentals M7, errores de validación, no fallos transitorios. retry_on existe, precisamente, para que este tipo de error nunca entre al bucle de reintentos.


Ejercicios

Ejercicio 1: Calcula si el tope alcanza (Fácil)

Sin ejecutar Python: con fail_times=4 (el cliente falla las primeras cuatro llamadas) y max_retries=3, ¿el retry_with_backoff de esta lección logra una respuesta exitosa? ¿Y con max_retries=4? Aplica el mismo razonamiento de for attempt in range(1, max_retries + 1) que ya usaste en la Lección 3.

Ver solución

Con max_retries=3, no — el bucle recorre los intentos 1, 2 y 3, los tres dentro de la ventana de cuatro fallos que el cliente simulado está configurado para producir; se agota antes de llegar al quinto intento (que sería el primero exitoso). Con max_retries=4, — el bucle llega hasta el intento 4, todavía dentro de los cuatro fallos configurados... espera: fail_times=4 significa que las llamadas 1 a 4 fallan, y la 5 tiene éxito, así que con max_retries=4 el bucle se agota en el intento 4 sin éxito. Hace falta max_retries=5 para alcanzar el primer intento exitoso. La regla, igual que en la Lección 3: para una fuente que necesita N intentos totales para recuperarse, max_retries tiene que ser, como mínimo, N.

Ejercicio 2: Simula un 529 overloaded_error con el mismo mecanismo (Medio)

La API de Claude también puede responder 529 overloaded_error —el servicio está temporalmente sobrecargado, un caso distinto del 429 pero igual de transitorio y retryable—. Declara una excepción OverloadedError(Exception), un cliente simulado que la lance las primeras dos veces, y reutiliza retry_with_backoff con retry_on=(OverloadedError,) para confirmar que se recupera.

Ver solución
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"])

Salida esperada:

    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'

Explicación: retry_with_backoff no necesitó ningún cambio — solo un retry_on distinto. Esa es exactamente la razón por la que se diseñó con un parámetro configurable en vez de tener el tipo de excepción escrito adentro: cualquier fallo transitorio nuevo —un 429, un 529, o algo que todavía no existe— se conecta al mismo mecanismo sin tocar una línea de su implementación.

Ejercicio 3: ¿Por qué retry_on=(RateLimitError, ConnectionError) sería un error de diseño aquí? (Difícil)

Alguien propone simplificar el código de Reservo llamando a retry_with_backoff con retry_on=(RateLimitError, ConnectionError) en un solo lugar, para "cubrir los dos casos con una sola llamada" — tanto los fallos de book_room como los 429 de Claude. Explica, en un párrafo, por qué mezclar los dos tipos de fallo en una sola llamada a retry_with_backoff sería una mala idea, aunque técnicamente funcionaría sin errores de sintaxis.

Ver solución

Técnicamente compilaría y correría, pero mezclaría dos decisiones que tienen que tomarse en lugares completamente distintos del sistema, con parámetros distintos: reintentar book_room ocurre dentro de dispatch_robust/call_with_breaker, protegido además por el circuit breaker de las Lecciones 4-5 (porque book_room sí puede estar muerta de verdad, y ahí un breaker tiene sentido); reintentar el 429 de Claude ocurre en la capa de la llamada al modelo, mucho antes de que el agente siquiera decida qué tool llamar, y ahí un breaker nunca tiene sentido, como explicó esta lección. Si una sola llamada a retry_with_backoff cubriera ambos casos, perderías la capacidad de aplicarle un circuit breaker a uno y no al otro —tendrías que envolver TODO ese código, tool calls y llamadas al modelo por igual, con la misma lógica de apertura/cierre—, cuando la decisión correcta es que solo book_room tenga esa protección. Mantener las dos llamadas a retry_with_backoff separadas —una para tools, con su propio CircuitBreaker alrededor; otra para el modelo, sin ninguno— es lo que te permite tratar cada capa según lo que de verdad representa el fallo, en vez de tratarlas todas igual porque comparten el mismo verbo genérico ("reintentar").


Resumen y siguiente paso

  • El 429 rate_limit_error de la API de Claude es un problema de cuota, no de disponibilidad — la API sigue funcionando perfectamente para todos; tu organización, en este instante, pidió más de lo permitido. Por eso nunca lleva un circuit breaker: no hay ninguna dependencia puntual que aislar, y cortar las llamadas al modelo dejaría al agente entero sin poder decidir nada.
  • Reusamos, sin cambiar una línea, el retry_with_backoff de la Lección 3 —solo cambiamos retry_on=(RateLimitError,)— y confirmamos, ejecutado, que se recupera de un 429 breve y se rinde con honestidad ante uno sostenido.
  • El 429 se simula siempre de forma determinista, con un cliente de prueba que decide de antemano cuántas veces va a fallar — nunca hay una llamada real a la API de Claude en esta guía.
  • Un sistema real ya trae buena parte de esto resuelto: el SDK oficial reintenta 429/5xx automáticamente (por defecto, hasta dos veces), y debería preferir el retry-after del servidor sobre su propio backoff calculado cuando ese header está presente.

Siguiente lección: 07 — Degradación elegante. Volvemos a book_room y su circuit breaker: qué le dice el agente al usuario, con claridad y rapidez, cuando el circuito está abierto — sin construir bulkheads ni load shedding, reusando el protocolo is_error que agent-fundamentals ya construyó.


Recursos adicionales

  1. Anthropic — Rate limits — Cómo funcionan los límites de la API de Claude por nivel de cuenta (peticiones por minuto, tokens por minuto, tokens por día), y los headers x-ratelimit-limit-* / x-ratelimit-remaining-* que reportan cuánta cuota queda.
  2. Anthropic — Errors — La tabla completa de códigos de error de la API de Claude, incluido cuáles son retryables (429, 5xx) y cuáles no (400, 401, 403, 404).
  3. Python — Excepciones definidas por el usuario — El mecanismo detrás de RateLimitError(Exception), con un atributo propio (retry_after_ms) además del mensaje.
  4. resilience-and-reliability-patterns-guide (Módulo 3) — La teoría completa de backoff y jitter para dependencias HTTP genéricas; este módulo la aplica, con las mismas ideas, a un caso que esa guía nunca cubre: el rate limit del proveedor del LLM.