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

El patrón circuit breaker

Descripción

La Lección 3 cerró con un dato incómodo: ni el mejor backoff exponencial salva a un run individual de un apagón que dura más que su propio tope de reintentos. Y peor — cada usuario nuevo que llega mientras la caída sigue activa repite exactamente la misma secuencia de intentos fallidos, porque nada en el sistema recuerda lo que ya se aprendió. Esta lección construye esa memoria: un circuit breaker — un objeto que vive más allá de cualquier run individual, cuenta los fallos consecutivos de una tool específica, y cuando esa cuenta cruza un umbral, deja de llamarla del todo — sin esperar el timeout, sin gastar un solo backoff más — hasta que un chequeo periódico confirma que la tool volvió.

Conexión con el módulo

El vocabulario que construyes en esta lección —CLOSED, OPEN, HALF_OPEN— es exactamente el mismo que usa resilience-and-reliability-patterns-guide (Módulo 5, "Circuit breakers") para cualquier dependencia HTTP genérica de un sistema distribuido. Esta lección lo reusa, citándolo, con un alcance mucho más angosto: un breaker por tool de un agente, aplicado a book_room. Y a diferencia de retry_with_backoff (Lección 3), que resuelve "¿cuántas veces reintento dentro de este run?", el CircuitBreaker de esta lección resuelve una pregunta que solo tiene sentido entre runs: "¿esta tool ya demostró, en runs anteriores, que está muerta?"


Analogía: el interruptor térmico que aprende del apagón

Ya la conoces desde la Lección 1 del módulo, ahora con más detalle. El interruptor térmico de tu casa no salta ante el primer parpadeo de luz — un parpadeo aislado no significa nada, cualquier instalación sana tiene alguno de vez en cuando. Salta cuando detecta un patrón real: una corriente sostenida fuera de lo normal, señal de que algo —un cortocircuito, un aparato roto— está mal de verdad, no fue mala suerte puntual. En cuanto salta, corta la corriente a ese circuito específico, sin apagar el resto de la casa. Después de un rato prudente, alguien baja la palanca de nuevo, como una prueba — si el problema se resolvió, la luz vuelve con normalidad; si el cortocircuito sigue ahí, vuelve a saltar de inmediato.

Traducido al código de esta lección: "un parpadeo aislado no significa nada" es failure_count que se resetea con cada éxito — nunca se abre por un fallo suelto. "Una corriente sostenida fuera de lo normal" es failure_count llegando a failure_threshold — varios fallos seguidos, sin ningún éxito de por medio. "Salta y corta la corriente a ese circuito" es el breaker pasando a OPEN y rechazando toda llamada a book_room, sin tocar get_quote ni list_rooms. "Alguien baja la palanca, como prueba" es HALF_OPEN — después de un cooldown, se deja pasar una llamada, y el resultado de esa única llamada decide si el circuito vuelve a la normalidad o vuelve a saltar.


Un aviso sobre los nombres, antes del código

CLOSED (cerrado) suena, en el lenguaje cotidiano, a "no pasa nada" — pero en un circuito eléctrico significa exactamente lo contrario: un circuito cerrado es un lazo completo, y la corriente fluye. OPEN (abierto) suena a "puede pasar" — pero en un circuito significa que el lazo está roto, y nada fluye. Es al revés de la intuición de una puerta, y tiene sentido en el mundo eléctrico de donde viene el patrón: cerrar el circuito completa el lazo; abrirlo lo corta. Si en algún momento dudas, vuelve al interruptor de tu casa: cuando salta para protegerte, queda abierto (OPEN) y corta la luz; cuando todo está normal, está cerrado (CLOSED) y la luz fluye. Esta convención —CLOSED = conduce = las llamadas pasan; OPEN = corta = las llamadas se rechazan— es la misma que usa resilience-and-reliability-patterns-guide, y la que usamos aquí, sin variarla.


Ejemplo trabajado: CircuitBreaker, construido entero

Los tres estados y el error que lanza al rechazar

# resilience/tool_circuit_breaker.py

CLOSED, OPEN, HALF_OPEN = "CLOSED", "OPEN", "HALF_OPEN"


class CircuitOpenError(Exception):
    """El breaker rechaza la llamada sin tocar la tool real (falla rápido)."""

CircuitOpenError es la clave de "fallar rápido": cuando el breaker está OPEN, la llamada se rechaza en el mismo instante, sin tocar book_room, sin esperar ningún timeout — nada que se parezca al costo de un intento real. Quien reciba esta excepción decide qué hacer con ella; la Lección 7 construye esa parte.

El constructor y el estado que persiste

class CircuitBreaker:
    """Máquina de tres estados, con estado que persiste ENTRE runs -- a
    diferencia del reintento dentro-del-run de agent-fundamentals M7. El
    cooldown se mide en LLAMADAS rechazadas, no en segundos: no hay reloj
    real en esta guía (ver honestidad más abajo)."""

    def __init__(self, name, failure_threshold=3, cooldown_calls=2):
        self.name = name
        self.failure_threshold = failure_threshold   # fallos seguidos para abrir
        self.cooldown_calls = cooldown_calls          # rechazos antes de una sonda
        self.state = CLOSED                            # arranca sano
        self.failure_count = 0                          # fallos consecutivos en CLOSED
        self._calls_while_open = 0                       # rechazos ya contados en OPEN

Honestidad, antes de seguir: resilience-and-reliability-patterns-guide mide el cooldown de su circuit breaker en segundos reales, con time.monotonic() — tiene sentido ahí, porque su caso son microservicios HTTP que reciben tráfico continuo, muchas llamadas por segundo. Esta guía no puede usar un reloj real en ningún bloque "Qué esperar" (la misma regla que prohíbe time.sleep() en el backoff), así que este CircuitBreaker mide el cooldown en llamadas rechazadas, no en segundos — cooldown_calls=2 significa "rechaza las próximas dos llamadas, y a la tercera, deja pasar una sonda". Es una simplificación deliberada para mantener el ejemplo determinista y reproducible; el criterio de fondo —dar tiempo antes de volver a probar— es el mismo.

Decidir si una llamada pasa: before_call

    def before_call(self):
        if self.state == OPEN:
            if self._calls_while_open >= self.cooldown_calls:
                self.state = HALF_OPEN
                return
            self._calls_while_open += 1
            raise CircuitOpenError(
                f"{self.name}: circuito abierto (OPEN), llamada rechazada sin tocar la tool"
            )

Si el breaker está CLOSED o ya pasó a HALF_OPEN, before_call no hace nada — la llamada sigue su curso normal. Si está OPEN, cuenta cuántas llamadas ya rechazó (_calls_while_open); en cuanto ese conteo alcanza cooldown_calls, en vez de rechazar otra vez, pasa a HALF_OPEN y deja pasar esta llamada como sonda.

Registrar el resultado: on_success y on_failure

    def on_success(self):
        if self.state == HALF_OPEN:
            self.state = CLOSED
        self.failure_count = 0

    def on_failure(self):
        if self.state == HALF_OPEN:
            self.state = OPEN
            self._calls_while_open = 0
            return
        self.failure_count += 1
        if self.failure_count >= self.failure_threshold:
            self.state = OPEN
            self._calls_while_open = 0

Fíjate en la línea self.failure_count = 0 dentro de on_success: cualquier éxito borra la cuenta de fallos seguidos, sin importar si el breaker estaba CLOSED o recién saliendo de HALF_OPEN. Esa línea es la que hace que el breaker cuente fallos consecutivos, no fallos totales acumulados a lo largo de toda la vida del proceso — sin ella, fallos aislados y sin relación entre sí (un hipo hoy, otro la semana que viene) terminarían sumando hasta el umbral y abriendo el circuito sobre una tool perfectamente sana. on_failure, del lado HALF_OPEN, es igual de importante: si la sonda falla, el breaker no vuelve a contar desde cero — vuelve directo a OPEN, con un cooldown nuevo, sin darle a esa tool una segunda sonda inmediata.

Componiendo el breaker con el backoff de la Lección 3

def call_with_breaker(breaker, fn, *args, max_retries=3, base_delay_ms=100,
                       retry_on=(ConnectionError,), **kwargs):
    """Compone el circuit breaker (decide si la llamada pasa) con el
    reintento con backoff (decide cuántas veces intentarla)."""
    breaker.before_call()
    try:
        result = retry_with_backoff(
            fn, *args, max_retries=max_retries, base_delay_ms=base_delay_ms,
            retry_on=retry_on, **kwargs,
        )
    except Exception:
        breaker.on_failure()
        raise
    else:
        breaker.on_success()
        return result

Nota la granularidad exacta de esta composición: breaker.before_call() decide, una vez, si esta llamada entra siquiera a intentarlo. Si entra, retry_with_backoff corre con su propio tope de reintentos —los mismos max_retries con backoff de la Lección 3—, y solo cuando ese run entero se rinde (agota su propio tope), el breaker se entera y suma un fallo. Esto es distinto del breaker genérico de resilience-and-reliability-patterns-guide, que corta antes de cada llamada HTTP individual: este breaker opera a nivel de run completo — dentro de un run que sí logra pasar el before_call, el backoff de la Lección 3 sigue funcionando exactamente igual que siempre. La Lección 5 vuelve sobre este detalle con más profundidad.


Ejecutado: el ciclo completo, CLOSED → OPEN → HALF_OPEN → CLOSED

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

print("=== 7 runs independientes, book_room caído las primeras 9 llamadas reales ===")
for run_n in range(1, 8):
    print(f"--- run {run_n} (estado del breaker ANTES: {breaker.state}) ---")
    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,
        )
        print(f"  OK -> {result}")
    except CircuitOpenError as exc:
        print(f"  RECHAZADO SIN LLAMAR A LA TOOL: {exc}")
    except ConnectionError as exc:
        print(f"  FALLO (tope de reintentos agotado): {exc}")
    print(f"  estado del breaker DESPUÉS: {breaker.state} (failure_count={breaker.failure_count})")

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

flaky_book_room es la misma tool de la Lección 2, esta vez con OUTAGE_CALLS = 9 — nueve llamadas reales caídas antes de recuperarse.

Qué esperar:

=== 7 runs independientes, book_room caído las primeras 9 llamadas reales ===
--- run 1 (estado del breaker ANTES: CLOSED) ---
    intento 1/3...
      fallo transitorio (ConnectionError): timeout de red simulado (llamada real #1) -- backoff modelado: 100ms (no se duerme de verdad)
    intento 2/3...
      fallo transitorio (ConnectionError): timeout de red simulado (llamada real #2) -- backoff modelado: 200ms (no se duerme de verdad)
    intento 3/3...
      fallo transitorio (ConnectionError): timeout de red simulado (llamada real #3) -- backoff modelado: 400ms (no se duerme de verdad)
  FALLO (tope de reintentos agotado): timeout de red simulado (llamada real #3)
  estado del breaker DESPUÉS: CLOSED (failure_count=1)
--- run 2 (estado del breaker ANTES: CLOSED) ---
    intento 1/3...
      fallo transitorio (ConnectionError): timeout de red simulado (llamada real #4) -- backoff modelado: 100ms (no se duerme de verdad)
    intento 2/3...
      fallo transitorio (ConnectionError): timeout de red simulado (llamada real #5) -- backoff modelado: 200ms (no se duerme de verdad)
    intento 3/3...
      fallo transitorio (ConnectionError): timeout de red simulado (llamada real #6) -- backoff modelado: 400ms (no se duerme de verdad)
  FALLO (tope de reintentos agotado): timeout de red simulado (llamada real #6)
  estado del breaker DESPUÉS: CLOSED (failure_count=2)
--- run 3 (estado del breaker ANTES: CLOSED) ---
    intento 1/3...
      fallo transitorio (ConnectionError): timeout de red simulado (llamada real #7) -- backoff modelado: 100ms (no se duerme de verdad)
    intento 2/3...
      fallo transitorio (ConnectionError): timeout de red simulado (llamada real #8) -- backoff modelado: 200ms (no se duerme de verdad)
    intento 3/3...
      fallo transitorio (ConnectionError): timeout de red simulado (llamada real #9) -- backoff modelado: 400ms (no se duerme de verdad)
  FALLO (tope de reintentos agotado): timeout de red simulado (llamada real #9)
  estado del breaker DESPUÉS: OPEN (failure_count=3)
--- run 4 (estado del breaker ANTES: OPEN) ---
  RECHAZADO SIN LLAMAR A LA TOOL: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool
  estado del breaker DESPUÉS: OPEN (failure_count=3)
--- run 5 (estado del breaker ANTES: OPEN) ---
  RECHAZADO SIN LLAMAR A LA TOOL: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool
  estado del breaker DESPUÉS: OPEN (failure_count=3)
--- run 6 (estado del breaker ANTES: OPEN) ---
    intento 1/3...
  OK -> {'booking_id': 1, 'confirmed': True, 'price_cents': 6000}
  estado del breaker DESPUÉS: CLOSED (failure_count=0)
--- run 7 (estado del breaker ANTES: CLOSED) ---
    intento 1/3...
  OK -> {'booking_id': 1, 'confirmed': True, 'price_cents': 6000}
  estado del breaker DESPUÉS: CLOSED (failure_count=0)

llamadas reales totales a book_room: 11

Recórrelo con el interruptor térmico en mente. Runs 1 y 2: fallan, cada uno agotando sus tres reintentos con backoff — el breaker sigue CLOSED, pero failure_count sube a 1, después a 2. Run 3: falla otra vez — el tercer fallo seguido — y failure_count llega a failure_threshold (3): el breaker salta a OPEN. Runs 4 y 5: ni siquiera tocan book_roomRECHAZADO SIN LLAMAR A LA TOOL, en el mismo instante, sin ningún backoff, sin ningún timeout — el ahorro completo que un circuit breaker existe para dar. Run 6: el cooldown de dos rechazos ya se cumplió (_calls_while_open llegó a 2), así que el breaker deja pasar esta llamada como sonda HALF_OPEN — y para este momento, la tool ya se recuperó (la llamada real #10, dentro del tope de intentos de este run, ya está fuera de la ventana de nueve caídas): la sonda tiene éxito, y el breaker vuelve, en el mismo instante, a CLOSED. Run 7: normal, CLOSED de principio a fin, sin ningún fallo.

Fíjate en el total: 11 llamadas reales para siete runs — no 21 (que es lo que costarían siete runs de hasta tres intentos cada uno sin ningún breaker). Los runs 4 y 5, los que el breaker rechazó de plano, no gastaron ni una sola llamada real.


Errores comunes

  1. Contar fallos totales en vez de consecutivos — olvidar el reset en on_success. Si self.failure_count = 0 no estuviera en on_success, el breaker abriría sobre una tool sana que simplemente tuvo dos hipos aislados, semanas aparte, sin relación entre sí. Contar consecutivos —y resetear con cada éxito— es lo que distingue "mala suerte puntual" de "está muerta de verdad".

  2. Poner failure_threshold demasiado alto "para estar seguros". Un umbral de 50, por ejemplo, deja fugar cincuenta llamadas reales al tope de reintentos completo antes de abrir — casi tanto costo como no tener breaker. El umbral correcto es lo bastante bajo para que el peaje de detección sea pequeño; un puñado de fallos consecutivos (tres, cinco) ya es una señal clara de caída sostenida.

  3. Pensar que el peaje de detección (los runs 1, 2 y 3 del ejemplo, que sí fallaron de verdad) es un defecto del breaker. Es inevitable y correcto: el breaker no puede saber que book_room está caído sin dejar que algunos runs lo intenten primero — esos runs son cómo el breaker descubre la caída. Lo que el breaker garantiza es que, después de ese descubrimiento, deja de pagar ese costo — no que el costo desaparezca por completo desde el primer fallo.

  4. Compartir un solo CircuitBreaker entre tools distintas. El breaker de esta lección protege a una tool —book_room—. Si get_quote también necesitara uno, hace falta una instancia separada (CircuitBreaker("get_quote", ...)) con su propio failure_count y su propio estado — un fallo de get_quote nunca debería abrir el circuito de book_room, y viceversa.


Ejercicios

Ejercicio 1: Calcula el peaje de detección (Fácil)

Con failure_threshold=3 y max_retries=3 por run (como en el ejemplo de esta lección), ¿cuántas llamadas reales se gastan, como mínimo, antes de que el breaker abra por primera vez? (Pista: cada run que falla agota su propio tope de reintentos antes de que el breaker cuente ese run como un solo fallo).

Ver solución

failure_threshold=3 significa que hacen falta tres runs fallidos seguidos para abrir el circuito. Cada uno de esos runs, al fallar, agota sus max_retries=3 intentos reales antes de rendirse. El peaje total es 3 runs × 3 intentos = 9 llamadas reales — exactamente lo que confirmó el ejemplo trabajado: el breaker abrió justo después de la novena llamada real (run 3, llamada real #9).

Ejercicio 2: Un breaker más estricto (Medio)

Repite el ejemplo trabajado de esta lección, pero con CircuitBreaker("book_room", failure_threshold=1, cooldown_calls=1) — abre con el primer fallo, prueba de nuevo con un solo rechazo de cooldown. Ejecuta cuatro runs contra la misma flaky_book_room (OUTAGE_CALLS=9, reiniciando _state["count"] = 0 primero) y confirma en qué run el breaker abre, y en cuál rechaza sin tocar la tool.

Ver solución
_state["count"] = 0
breaker_strict = CircuitBreaker("book_room", failure_threshold=1, cooldown_calls=1)

for run_n in range(1, 5):
    print(f"--- run {run_n} (ANTES: {breaker_strict.state}) ---")
    try:
        call_with_breaker(breaker_strict, flaky_book_room, room="Focus", tier="pro",
                           hours=3, member=f"user{run_n}", max_retries=3, base_delay_ms=100)
        print("  OK")
    except CircuitOpenError:
        print("  RECHAZADO SIN LLAMAR A LA TOOL")
    except ConnectionError:
        print("  FALLO (tope de reintentos agotado)")
    print(f"  DESPUÉS: {breaker_strict.state}")

Salida esperada (recortada a los estados, sin las líneas de intento/backoff):

--- run 1 (ANTES: CLOSED) ---
  FALLO (tope de reintentos agotado)
  DESPUÉS: OPEN
--- run 2 (ANTES: OPEN) ---
  RECHAZADO SIN LLAMAR A LA TOOL
  DESPUÉS: OPEN
--- run 3 (ANTES: OPEN) ---
  FALLO (tope de reintentos agotado)
  DESPUÉS: OPEN
--- run 4 (ANTES: OPEN) ---
  RECHAZADO SIN LLAMAR A LA TOOL
  DESPUÉS: OPEN

Explicación: con failure_threshold=1, el breaker abre en cuanto el primer run se rinde (después de sus tres intentos reales, 1, 2, 3, todos dentro de la ventana de nueve caídas). El run 2 se rechaza de plano (cooldown_calls=1 ya se cumplió con ese único rechazo, así que en teoría el run 3 debería ser la sonda) — pero el run 3, al entrar en HALF_OPEN, usa sus propios tres intentos reales (4, 5, 6), todavía dentro del apagón de nueve, así que la sonda entera falla y el breaker vuelve a OPEN con un cooldown nuevo. El run 4 se rechaza otra vez. Con un umbral y un cooldown tan bajos, el breaker abre rapidísimo —protege desde el primer fallo—, pero también tarda más en confirmar la recuperación, porque cada sonda tiene solo una oportunidad de coincidir con el momento exacto en que la tool ya volvió.

Ejercicio 3: ¿Por qué el breaker no debería resetear failure_count al recibir un CircuitOpenError? (Difícil)

Alguien propone modificar call_with_breaker para que, si breaker.before_call() lanza CircuitOpenError, el except Exception de más abajo lo capture igual que cualquier otro fallo y llame a breaker.on_failure(). Explica, en un párrafo, por qué eso rompería la máquina de estados —piensa en qué le pasaría a failure_count y a _calls_while_open mientras el breaker está OPEN rechazando llamadas una tras otra—.

Ver solución

Si cada CircuitOpenError también llamara a on_failure(), cada rechazo mientras el breaker está OPEN sumaría otro fallo — pero on_failure(), en su rama normal (no HALF_OPEN), solo incrementa failure_count y vuelve a evaluar el umbral; no toca _calls_while_open. El resultado sería, como mínimo, un conteo de fallos que sigue creciendo sin límite mientras el breaker ya está abierto —diez, cien, mil "fallos" que en realidad son solo rechazos que el breaker mismo generó, no fallos reales de la tool—, contaminando cualquier métrica que dependa de failure_count para decidir algo. Peor: en el código real, breaker.before_call() se llama antes del bloque try, precisamente para que un CircuitOpenError se propague directo hacia afuera sin pasar por el manejo de fallos de la llamada real — mezclar los dos caminos confundiría "el breaker decidió no intentarlo" con "se intentó y falló", que son, con precisión, las dos cosas distintas que esta máquina de estados existe para separar.


Resumen y siguiente paso

  • Construimos CircuitBreaker: tres estados (CLOSED/OPEN/HALF_OPEN), el mismo vocabulario de resilience-and-reliability-patterns-guide, con before_call (decide si la llamada pasa), on_success y on_failure (registran el resultado), y CircuitOpenError (falla rápido, sin tocar la tool real).
  • call_with_breaker compone el breaker con el retry_with_backoff de la Lección 3: el breaker decide, una vez, si un run entero entra a intentarlo; si entra, el backoff sigue operando dentro de ese run exactamente igual que antes.
  • Ejecutamos el ciclo completo contra book_room caído: tres runs fallidos abren el circuito (CLOSED → OPEN), dos runs se rechazan sin tocar la tool, y el sexto —ya con la tool recuperada— cierra el círculo (OPEN → HALF_OPEN → CLOSED). Total: 11 llamadas reales, no 21.
  • Honestidad: el cooldown de este breaker se mide en llamadas rechazadas, no en segundos reales — una simplificación deliberada para mantener la reproducibilidad de esta guía; la versión con time.monotonic() real está en resilience-and-reliability-patterns-guide.

Siguiente lección: 05 — CLOSED, OPEN, HALF_OPEN. Miramos con más detalle las transiciones — qué pasa cuando la sonda de HALF_OPEN también falla, cuánto se ahorra en llamadas reales, y por qué el "sondeo" de este breaker es distinto al de una dependencia HTTP genérica.


Recursos adicionales

  1. resilience-and-reliability-patterns-guide (Módulo 5, "Circuit breakers") — La fuente canónica completa de este patrón: la misma máquina de tres estados, medida a fondo contra un apagón real de microservicios, con cooldown_s real y time.monotonic().
  2. Anthropic — Building effective agents — Sobre por qué un agente en producción necesita mecanismos que protejan al sistema completo, no solo a una llamada individual.
  3. Python — Clases — El mecanismo de objetos con estado mutable que hace posible que CircuitBreaker recuerde entre llamadas, dentro del mismo proceso.
  4. Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de esta lección.