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

CLOSED, OPEN, HALF_OPEN

Descripción

La Lección 4 mostró el ciclo completo del circuit breaker con un final feliz: la sonda de HALF_OPEN tuvo éxito al primer intento, y el circuito volvió a CLOSED sin sobresaltos. La realidad no siempre es tan prolija — a veces la tool sigue caída justo cuando el breaker decide probar de nuevo, y la sonda también falla. Esta lección completa el mapa de transiciones que la anterior dejó a medias: qué pasa cuando HALF_OPEN no funciona, cuánto cuesta —en llamadas reales— cada ciclo completo de apertura y sondeo, y por qué el "sondeo" de este breaker específico opera a un nivel distinto del que opera el breaker genérico de una dependencia HTTP.

Conexión con el módulo

Esta lección no agrega ningún mecanismo nuevo — el CircuitBreaker y call_with_breaker son exactamente los de la Lección 4, sin cambiar una línea. Lo que cambia es el escenario: un apagón más largo, que sobrevive a dos sondas seguidas antes de que la tercera coincida con la recuperación real. Es la misma máquina de estados, puesta a prueba contra el caso que la Lección 4 no llegó a mostrar.


El mapa completo de transiciones

                 failure_count == failure_threshold
        ┌──────────────────────────────────────────────┐
        │                                                ▼
    ┌────────┐                                      ┌────────┐
    │ CLOSED │◀─────────────────────────────────────│  OPEN  │
    └────────┘   la sonda de HALF_OPEN tiene éxito   └────────┘
        │                                                  ▲
        │           _calls_while_open >= cooldown_calls    │
        │                        │                          │
        │                        ▼                          │
        │                  ┌───────────┐                    │
        └─────────────────▶│ HALF_OPEN │────────────────────┘
         (nunca ocurre       └───────────┘  la sonda de HALF_OPEN falla
          directamente --
          CLOSED solo llega
          a HALF_OPEN vía OPEN)

Tres transiciones, y solo tres: CLOSED → OPEN cuando failure_count alcanza failure_threshold (Lección 4); OPEN → HALF_OPEN cuando el cooldown de rechazos se cumple; y desde HALF_OPEN, dos salidas posibles según el resultado de la sonda — a CLOSED si tuvo éxito, de vuelta a OPEN (con un cooldown nuevo, _calls_while_open en cero otra vez) si falló. No hay ninguna otra flecha en todo el diagrama — cualquier comportamiento que no esté en este mapa es un bug.


Ejecutado: un apagón que sobrevive a dos sondas seguidas

Usamos la misma flaky_book_room de las lecciones anteriores, esta vez con OUTAGE_CALLS = 15 — un apagón lo bastante largo para que dos sondas completas de HALF_OPEN (cada una con sus propios max_retries=3 intentos reales) caigan todavía dentro de la ventana caída, antes de que la tercera por fin coincida con la recuperación.

breaker = CircuitBreaker("book_room", failure_threshold=3, cooldown_calls=2)

print("=== apagón de 15 llamadas reales: DOS sondas HALF_OPEN fallan antes de la que recupera ===")
for run_n in range(1, 14):
    before_state = breaker.state
    before_real = _state["count"]
    try:
        result = call_with_breaker(
            breaker, flaky_book_room, room="Focus", tier="pro", hours=3, member=f"user{run_n}",
            max_retries=3, base_delay_ms=100,
        )
        outcome = "OK -- reserva confirmada"
    except CircuitOpenError:
        outcome = "RECHAZADO SIN LLAMAR A LA TOOL"
    except ConnectionError:
        outcome = "FALLO (tope de reintentos agotado)"
    real_calls_used = _state["count"] - before_real
    print(f"run {run_n:2}: breaker {before_state:9} -> {breaker.state:9} | llamadas reales: {real_calls_used} | {outcome}")

print()
print(f"llamadas reales totales a book_room: {_state['count']}")

Qué esperar:

=== apagón de 15 llamadas reales: DOS sondas HALF_OPEN fallan antes de la que recupera ===
run  1: breaker CLOSED    -> CLOSED    | llamadas reales: 3 | FALLO (tope de reintentos agotado)
run  2: breaker CLOSED    -> CLOSED    | llamadas reales: 3 | FALLO (tope de reintentos agotado)
run  3: breaker CLOSED    -> OPEN      | llamadas reales: 3 | FALLO (tope de reintentos agotado)
run  4: breaker OPEN      -> OPEN      | llamadas reales: 0 | RECHAZADO SIN LLAMAR A LA TOOL
run  5: breaker OPEN      -> OPEN      | llamadas reales: 0 | RECHAZADO SIN LLAMAR A LA TOOL
run  6: breaker OPEN      -> OPEN      | llamadas reales: 3 | FALLO (tope de reintentos agotado)
run  7: breaker OPEN      -> OPEN      | llamadas reales: 0 | RECHAZADO SIN LLAMAR A LA TOOL
run  8: breaker OPEN      -> OPEN      | llamadas reales: 0 | RECHAZADO SIN LLAMAR A LA TOOL
run  9: breaker OPEN      -> OPEN      | llamadas reales: 3 | FALLO (tope de reintentos agotado)
run 10: breaker OPEN      -> OPEN      | llamadas reales: 0 | RECHAZADO SIN LLAMAR A LA TOOL
run 11: breaker OPEN      -> OPEN      | llamadas reales: 0 | RECHAZADO SIN LLAMAR A LA TOOL
run 12: breaker OPEN      -> CLOSED    | llamadas reales: 1 | OK -- reserva confirmada
run 13: breaker CLOSED    -> CLOSED    | llamadas reales: 1 | OK -- reserva confirmada

llamadas reales totales a book_room: 17

Sigue el hilo con cuidado, porque hay un detalle que ni el before/after de esta tabla muestra directamente. Runs 1-3: fallan, CLOSED → OPEN en el run 3, exactamente como en la Lección 4. Runs 4-5: rechazados de plano, 0 llamadas reales — el cooldown de dos rechazos se está contando. Run 6: aquí pasa algo que la columna "breaker ANTES/DESPUÉS" no deja ver directamente — dentro de esta única llamada, el breaker pasa de OPEN a HALF_OPEN (el cooldown ya se cumplió), deja pasar la sonda, la sonda usa sus propios tres intentos con backoff (llamadas reales 10, 11, 12todavía dentro del apagón de quince), la sonda entera falla, y el breaker vuelve a OPEN en el mismo instante — por eso la columna muestra OPEN → OPEN, aunque por dentro pasó por HALF_OPEN y volvió. Runs 7-8: rechazados otra vez, un cooldown nuevo. Run 9: segunda sonda, llamadas reales 13, 14, 15de nuevo dentro del apagón, exactamente en el borde — falla otra vez, de vuelta a OPEN. Runs 10-11: rechazados, tercer cooldown. Run 12: tercera sonda — llamada real 16, ya fuera del apagón de quince — tiene éxito al primer intento, y el circuito por fin cierra. Run 13: normal, CLOSED de punta a punta.

Diecisiete llamadas reales en total para trece runs — compáralo con lo que habría costado sin ningún breaker: cada uno de los trece runs agotando hasta tres intentos reales, hasta 39 llamadas en el peor caso. Y de esas diecisiete, seis rechazos (runs 4, 5, 7, 8, 10, 11) no tocaron la tool ni una sola vez.


Por qué la "sonda" de este breaker no es una sola llamada HTTP

Vale la pena detenerse en algo que ya se insinuó en la Lección 4: cuando resilience-and-reliability-patterns-guide habla de la sonda de HALF_OPEN, se refiere literalmente a una llamada HTTP — un GET o un POST puntual a la dependencia, que sube o baja el circuito según su resultado inmediato. En este módulo, la "sonda" del run 6 (por ejemplo) en realidad son tres llamadas reales a book_room —los tres intentos con backoff de retry_with_backoff, corriendo dentro de ese mismo run— antes de que el breaker se entere de si la sonda, en conjunto, tuvo éxito o no.

Esto no es un error — es una consecuencia directa de la granularidad en la que este breaker opera: a nivel de run, no a nivel de llamada individual de bajo nivel. Cada run del agente de Reservo ya trae su propio presupuesto de reintentos con backoff (la Lección 3), y el circuit breaker decide algo previo y distinto: si ese run entero —con todo su propio backoff incluido— vale la pena intentarlo, o si ya sabemos, por runs anteriores, que no. Es una decisión honesta de diseño, no un accidente: si quisieras un breaker que corte dentro de un mismo run, después de la primera llamada fallida, sin dejarle a retry_with_backoff sus tres oportunidades, tendrías que envolver el breaker alrededor de cada intento individual en vez de alrededor del run completo — un diseño válido, pero distinto del que construyó esta guía, y que cambiaría cuántas llamadas reales cuesta cada sonda.


Eligiendo failure_threshold y cooldown_calls

El mismo criterio que agent-fundamentals M4 usó para max_iterations, y M7 para max_retries, aplica aquí, con el ajuste correspondiente: ninguno de los dos números tiene un valor "correcto" universal — cada uno es un trade-off explícito.

  • failure_threshold bajo (por ejemplo, 1) abre el circuito casi de inmediato — protege mejor contra apagones cortos, pero corre más riesgo de abrir por un fallo aislado que no era, en realidad, el inicio de una caída sostenida.
  • failure_threshold alto (por ejemplo, 10) es más difícil de confundir con ruido —hace falta un patrón de fallos mucho más claro—, pero paga un peaje de detección más caro: más llamadas reales desperdiciadas antes de que el breaker reaccione.
  • cooldown_calls bajo sondea la recuperación con más frecuencia —detecta que la tool volvió más rápido—, pero cada sonda fallida (como los runs 6 y 9 del ejemplo) le cuesta al sistema hasta max_retries llamadas reales completas, no una sola.
  • cooldown_calls alto desperdicia menos en sondas fallidas, pero deja pasar más tiempo —más usuarios rechazados de plano— antes de notar que la tool ya está sana otra vez.

resilience-and-reliability-patterns-guide desarrolla este mismo trade-off a fondo, con fórmulas y mediciones sobre apagones de distinta duración — esa es la referencia si necesitas calibrar estos números con criterio matemático, no solo intuitivo, para un sistema real.


Errores comunes

  1. Asumir que OPEN → OPEN en la tabla significa "no pasó nada". Como viste en los runs 6 y 9 del ejemplo, OPEN → OPEN puede esconder un ciclo completo OPEN → HALF_OPEN → OPEN — una sonda que sí se intentó, sí gastó llamadas reales, y sí falló. Para verla, hace falta mirar la columna de llamadas reales gastadas, no solo el estado antes/después.

  2. Pensar que reducir cooldown_calls a 0 es "más agresivo, y por lo tanto mejor". Con cooldown_calls=0, el breaker sondearía en la primerísima llamada después de abrir — básicamente sin ningún respiro para la tool caída, muy parecido a no tener breaker del todo durante ese instante. El cooldown existe, precisamente, para dar un margen mínimo antes de la primera prueba.

  3. Olvidar que cada sonda fallida reinicia el cooldown desde cero. on_failure(), en la rama HALF_OPEN, hace self._calls_while_open = 0 — el conteo de rechazos para la siguiente sonda empieza de nuevo, no continúa donde había quedado. Un apagón largo puede, por eso, generar varios ciclos completos de cooldown antes de recuperarse, como en el ejemplo de esta lección.


Ejercicios

Ejercicio 1: Cuenta los ciclos completos (Fácil)

Sin ejecutar Python: en el ejemplo trabajado de esta lección (OUTAGE_CALLS=15, failure_threshold=3, cooldown_calls=2, max_retries=3), ¿cuántos ciclos completos de OPEN → HALF_OPEN → OPEN (sonda fallida) ocurren antes del ciclo final que sí cierra el circuito? Confirma contando las líneas FALLO que aparecen con el breaker ya en estado OPEN.

Ver solución

Dos ciclos completos de sonda fallida: el run 6 (llamadas reales 10-12, todavía dentro del apagón de 15) y el run 9 (llamadas reales 13-15, justo en el borde). El tercer ciclo, en el run 12 (llamada real 16), por fin cae fuera del apagón y cierra el circuito.

Ejercicio 2: Un apagón que termina exactamente en el peor momento (Medio)

Con OUTAGE_CALLS = 12 (en vez de 15) y los mismos failure_threshold=3, cooldown_calls=2, max_retries=3, ejecuta el mismo bucle de trece runs. ¿En qué run se cierra finalmente el circuito? (Pista: recalcula qué llamadas reales usa cada sonda, y compáralas contra el nuevo OUTAGE_CALLS.)

Ver solución
_state["count"] = 0
OUTAGE_CALLS = 12
breaker_12 = CircuitBreaker("book_room", failure_threshold=3, cooldown_calls=2)

for run_n in range(1, 14):
    before_real = _state["count"]
    try:
        call_with_breaker(breaker_12, flaky_book_room, room="Focus", tier="pro",
                           hours=3, member=f"user{run_n}", max_retries=3, base_delay_ms=100)
        outcome = "OK"
    except CircuitOpenError:
        outcome = "RECHAZADO"
    except ConnectionError:
        outcome = "FALLO"
    print(f"run {run_n:2}: {outcome} (llamadas reales: {_state['count'] - before_real})")

Con el mismo ritmo de fallos que el ejemplo trabajado —runs 1-3 fallan (llamadas 1-9, abren el circuito), runs 4-5 rechazados, run 6 sondea con llamadas 10-12—, el nuevo OUTAGE_CALLS=12 hace que la llamada real 10 y la 11 sigan cayendo dentro del apagón, pero la 12 también (12 <= 12), así que la sonda del run 6 todavía falla, exactamente como con OUTAGE_CALLS=15. El circuito recién cierra en el run 9, cuya sonda usa la llamada real 13 — la primera que ya está fuera del apagón (13 > 12) — y tiene éxito en el primer intento. El circuito se cierra tres runs antes que en el ejemplo original, simplemente porque el apagón terminó tres llamadas reales antes.

Ejercicio 3: Diseña un breaker que sondee sin gastar el backoff completo (Difícil)

El diseño de esta lección hace que cada sonda de HALF_OPEN use hasta max_retries llamadas reales, porque call_with_breaker siempre invoca retry_with_backoff con el mismo max_retries, sin importar el estado del breaker. Describe, en prosa (no hace falta que escribas el código completo), cómo cambiarías call_with_breaker para que, específicamente cuando el breaker está en HALF_OPEN, la sonda use un max_retries=1 —una sola oportunidad real, sin backoff extendido— en vez de los max_retries normales del run. ¿Qué trade-off nuevo introduce ese cambio, comparado con el diseño actual?

Ver solución

El cambio consistiría en que call_with_breaker consulte breaker.state antes de llamar a retry_with_backoff, y si es HALF_OPEN, pase max_retries=1 en vez del max_retries que recibió como argumento —algo como effective_max_retries = 1 if breaker.state == HALF_OPEN else max_retries—. El trade-off es directo: con una sola oportunidad real por sonda, cada ciclo de HALF_OPEN cuesta como mucho una llamada real en vez de hasta max_retries —más barato cuando la sonda falla, como los runs 6 y 9 de esta lección—, pero también más propenso a fallar por mala suerte pura: si la tool ya se recuperó pero esa primera llamada puntual tropieza con un hipo transitorio genuino (no relacionado con el apagón), la sonda entera se da por perdida sin la segunda oportunidad que el backoff normal le habría dado. Es exactamente el mismo tipo de decisión que failure_threshold y cooldown_calls — no hay una respuesta única, hay un trade-off entre costo de sondeo y tolerancia a mala suerte puntual, y la elección correcta depende de cuánto cuesta, en tu sistema real, cada llamada real desperdiciada.


Resumen y siguiente paso

  • Completamos el mapa de transiciones del CircuitBreaker: CLOSED → OPEN por umbral, OPEN → HALF_OPEN por cooldown, y desde HALF_OPEN, dos salidas — CLOSED si la sonda tiene éxito, de vuelta a OPEN (con un cooldown nuevo) si falla.
  • Ejecutamos un apagón de quince llamadas reales que sobrevive a dos ciclos completos de sonda fallida antes de que el tercero, por fin, coincida con la recuperación real — trece runs, diecisiete llamadas reales, seis rechazos que no tocaron la tool.
  • Explicamos por qué la "sonda" de este breaker específico son, en realidad, hasta max_retries llamadas reales dentro de un mismo run —una decisión de diseño consciente, distinta del breaker genérico de una sola llamada HTTP de resilience-and-reliability-patterns-guide— y qué trade-off nuevo introduciría cambiar esa granularidad.
  • failure_threshold y cooldown_calls son, los dos, trade-offs explícitos sin un valor universal correcto — el mismo criterio que ya conoces de max_iterations y max_retries en agent-fundamentals.

Siguiente lección: 06 — El 429 de Claude. Cambiamos de capa por completo: del circuit breaker por tool pasamos al rate limit de la propia API de Claude — un fallo que nunca lleva un breaker, y por qué.


Recursos adicionales

  1. resilience-and-reliability-patterns-guide (Módulo 5, Lecciones 4-7) — El desarrollo completo de cada transición, con cooldown_s real, mediciones de hilos-segundo ahorrados, y el criterio matemático para elegir failure_threshold y cooldown_s contra apagones de distinta duración.
  2. Anthropic — Building effective agents — Sobre el criterio general de diseñar salvaguardas a la granularidad correcta del sistema, no a la primera granularidad disponible.
  3. Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de esta lección.