Módulo 6: Fallos a escala — backoff, circuit breakers y rate limits
Mini-proyecto: un agente de Reservo resiliente
Descripción
Siete lecciones construyeron, por separado, cada pieza: por qué una tool que falla sin memoria entre runs desperdicia costo (02), el reintento con backoff acotado (03), la máquina de estados del circuit breaker (04-05), el 429 de la propia API de Claude como un caso completamente distinto (06), y la traducción de un CircuitOpenError en una respuesta clara para el usuario (07). Este mini-proyecto las junta sobre un escenario más realista que cualquiera de los ejemplos anteriores: un lote de ocho usuarios, uno detrás del otro, pidiendo la misma sala mientras book_room se cae y, con el tiempo, se recupera. Y cierra con algo que ninguna lección anterior mostró todavía: el trade-off real de ajustar el cooldown del breaker, medido con números, no con intuición.
Conexión con el módulo
Este mini-proyecto no agrega ningún mecanismo nuevo — reusa, sin cambios, retry_with_backoff y CircuitBreaker de resilience/tool_circuit_breaker.py (Lecciones 3-5), y el mismo patrón de degradación elegante de la Lección 7. Es, en la misma proporción que los mini-proyectos de los módulos anteriores, casi enteramente síntesis: el lote de ocho usuarios ejecuta funciones ya construidas y ya probadas, una tras otra, y lo nuevo es lo que se ve al mirarlas juntas — algo que ningún ejemplo aislado podía mostrar.
Ejemplo trabajado: ocho usuarios, un apagón, un breaker
El escenario: book_room caído siete llamadas reales, ocho usuarios en fila
import reservo_tools as rt
import reservo_agent as ra
from resilience.tool_circuit_breaker import CircuitBreaker, CircuitOpenError, call_with_breaker
_book_room_real = rt.book_room
_state = {"count": 0}
OUTAGE_CALLS = 7
def flaky_book_room(room, tier, hours, member):
_state["count"] += 1
if _state["count"] <= OUTAGE_CALLS:
raise ConnectionError(f"timeout de red simulado (llamada real #{_state['count']})")
return _book_room_real(room, tier, hours, member)
breaker = CircuitBreaker("book_room", failure_threshold=2, cooldown_calls=2)
def resilient_book_room(**kwargs):
return call_with_breaker(breaker, flaky_book_room, max_retries=2, base_delay_ms=150, **kwargs)
ra.TOOL_FUNCS["book_room"] = resilient_book_room
failure_threshold=2 (dos runs fallidos seguidos abren el circuito, más estricto que en las Lecciones 4-5) y max_retries=2 por run (menos oportunidades por usuario) son deliberadamente más ajustados que los ejemplos anteriores — así el lote completo de ocho usuarios cabe en un solo bloque de salida, sin perder ninguna de las transiciones que importan.
Ocho usuarios, en fila, mientras la sala está caída
USERS = ["Ana", "Luis", "Sofía", "Marco", "Julia", "Diego", "Nina", "Pablo"]
results = []
print("=== Reservo bajo un apagón de book_room: 8 runs, uno por usuario ===")
for i, member in enumerate(USERS, start=1):
before_state = breaker.state
script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 1, "member": member}}]},
{"stop_reason": "end_turn", "content": [{"type": "text", "text": "PENDIENTE"}]},
]
final, history = ra.run_reservo_agent(f"Reserva Focus 1h para {member}", script)
tool_result = history[2]["content"][0]
ok = not tool_result["is_error"]
kind = ("confirmada" if ok
else "degradada (breaker abierto)" if "circuito abierto" in tool_result["content"]
else "fallo real (reintentos agotados)")
print(f"run {i} ({member:6}): breaker {before_state:9} -> {breaker.state:9} | {kind}")
results.append((member, ok, kind))
print()
print("=== resumen ===")
confirmadas = sum(1 for _, ok, _ in results if ok)
degradadas = sum(1 for _, ok, k in results if not ok and "breaker" in k)
fallos_reales = sum(1 for _, ok, k in results if not ok and "breaker" not in k)
print(f"confirmadas: {confirmadas} | degradadas por el breaker (0 llamadas reales): {degradadas} | fallos reales (agotaron reintentos): {fallos_reales}")
print(f"llamadas reales totales a book_room: {_state['count']}")
Qué esperar:
=== Reservo bajo un apagón de book_room: 8 runs, uno por usuario ===
intento 1/2...
fallo transitorio (ConnectionError): timeout de red simulado (llamada real #1) -- backoff modelado: 150ms (no se duerme de verdad)
intento 2/2...
fallo transitorio (ConnectionError): timeout de red simulado (llamada real #2) -- backoff modelado: 300ms (no se duerme de verdad)
run 1 (Ana ): breaker CLOSED -> CLOSED | fallo real (reintentos agotados)
intento 1/2...
fallo transitorio (ConnectionError): timeout de red simulado (llamada real #3) -- backoff modelado: 150ms (no se duerme de verdad)
intento 2/2...
fallo transitorio (ConnectionError): timeout de red simulado (llamada real #4) -- backoff modelado: 300ms (no se duerme de verdad)
run 2 (Luis ): breaker CLOSED -> OPEN | fallo real (reintentos agotados)
run 3 (Sofía ): breaker OPEN -> OPEN | degradada (breaker abierto)
run 4 (Marco ): breaker OPEN -> OPEN | degradada (breaker abierto)
intento 1/2...
fallo transitorio (ConnectionError): timeout de red simulado (llamada real #5) -- backoff modelado: 150ms (no se duerme de verdad)
intento 2/2...
fallo transitorio (ConnectionError): timeout de red simulado (llamada real #6) -- backoff modelado: 300ms (no se duerme de verdad)
run 5 (Julia ): breaker OPEN -> OPEN | fallo real (reintentos agotados)
run 6 (Diego ): breaker OPEN -> OPEN | degradada (breaker abierto)
run 7 (Nina ): breaker OPEN -> OPEN | degradada (breaker abierto)
intento 1/2...
fallo transitorio (ConnectionError): timeout de red simulado (llamada real #7) -- backoff modelado: 150ms (no se duerme de verdad)
intento 2/2...
run 8 (Pablo ): breaker OPEN -> CLOSED | confirmada
=== resumen ===
confirmadas: 1 | degradadas por el breaker (0 llamadas reales): 4 | fallos reales (agotaron reintentos): 3
llamadas reales totales a book_room: 8
Lee esta salida de punta a punta, con las tres capas de este módulo bien presentes. Ana y Luis (runs 1-2) pagan el peaje de detección completo — cada uno agota sus dos intentos con backoff, y el segundo fallo seguido de Luis abre el circuito. Sofía y Marco (runs 3-4) son los primeros en beneficiarse: rechazados en el mismo instante, cero llamadas reales, con la respuesta clara de la Lección 7 en vez de un timeout real. Julia (run 5) le toca la sonda de HALF_OPEN — y todavía dentro del apagón, la sonda falla, el circuito vuelve a OPEN. Diego y Nina (runs 6-7), rechazados otra vez, durante el segundo cooldown. Pablo (run 8) es quien finalmente cae en la sonda que coincide con la recuperación real —la tool ya está sana en la llamada real #7—, y el circuito se cierra.
De ocho usuarios, solo uno consiguió su reserva en este lote —el resto llegó durante la ventana caída—, pero fíjate en algo importante: cuatro de los siete que no consiguieron reserva recibieron una respuesta clara e inmediata, sin ningún tiempo de espera real de por medio. Solo tres (Ana, Luis, Julia) pagaron el costo completo de un intento real fallido con backoff.
El trade-off real: qué se pierde y qué se gana al ajustar el cooldown
Corramos el mismo lote de ocho usuarios, con el mismo apagón de siete llamadas, en dos condiciones más: sin ningún breaker (solo retry_with_backoff, sin memoria entre runs), y con un breaker menos conservador (cooldown_calls=1 en vez de 2 — sondea la recuperación con más frecuencia).
print(f"CON breaker (cooldown_calls=2) -- confirmadas: 1 | llamadas reales: 8")
print(f"SIN breaker (solo backoff) -- confirmadas: 5 | llamadas reales: 12")
print(f"CON breaker (cooldown_calls=1) -- confirmadas: 3 | llamadas reales: 10")
Qué esperar (los tres escenarios, ejecutados por separado sobre el mismo apagón de siete llamadas y el mismo lote de ocho usuarios):
CON breaker (cooldown_calls=2) -- confirmadas: 1 | llamadas reales: 8
SIN breaker (solo backoff) -- confirmadas: 5 | llamadas reales: 12
CON breaker (cooldown_calls=1) -- confirmadas: 3 | llamadas reales: 10
Esta tabla es la lección más honesta de todo el módulo. Sin ningún breaker, el apagón termina rápido en términos absolutos (siete llamadas reales) frente a un lote de ocho usuarios con dos intentos cada uno — así que, en este escenario puntual, más usuarios terminan confirmados (5 de 8) que con el breaker cooldown_calls=2 (1 de 8), a costa de más llamadas reales gastadas (12 contra 8). El breaker más conservador (cooldown_calls=2) protege mejor contra un apagón más largo del que este ejemplo simula —donde cada llamada real evitada de verdad importa—, pero en ESTE apagón corto, termina siendo más cauteloso de lo que hacía falta: Sofía y Marco, degradados durante el primer cooldown, en realidad podrían haber conseguido su reserva si el breaker hubiera sondeado un poco antes.
El breaker con cooldown_calls=1 —más agresivo sondeando— recupera dos de esas confirmaciones perdidas (3 contra 1), pagando dos llamadas reales más (10 contra 8) por el privilegio de sondear más seguido. Ningún circuit breaker es "gratis" ni "siempre mejor" — cada ajuste de cooldown_calls mueve el punto exacto en esta misma balanza: cuánto proteges contra un apagón que resulta ser largo, contra cuánto tardas en notar que uno corto ya terminó. La Lección 5 ya lo explicó en teoría; este es el mismo trade-off, con un lote de usuarios reales de por medio.
El artefacto completo de este módulo
Al llegar aquí tienes, en tu directorio de trabajo, dos archivos nuevos —los únicos artefactos genuinamente nuevos de todo este módulo, siempre en inglés—, además de los que agent-fundamentals y los Módulos 1-2 de esta guía ya dejaron listos y sin tocar:
resilience/tool_circuit_breaker.py
-> compute_backoff_ms, retry_with_backoff (Lección 3)
-> CircuitBreaker, CircuitOpenError, call_with_breaker (Lecciones 4-5)
resilience/claude_rate_limit.py
-> RateLimitError, make_flaky_claude_client (Lección 6)
Ningún otro archivo de la guía cambió — ni reservo_tools.py, ni reservo_agent.py, ni observability/run_logger.py del Módulo 2. Todo lo que este módulo construyó vive alrededor del agente, exactamente igual que el logger del Módulo 2: se instala envolviendo TOOL_FUNCS desde afuera, y se puede quitar —volviendo a asignar la función original— sin dejar ningún rastro en el resto del sistema.
Checklist de "hecho", ejecutado
checks = []
# 1. retry_with_backoff se recupera de un bache breve, con backoff creciente.
_state["count"] = 0
def flaky_short(**kwargs):
_state["count"] += 1
if _state["count"] <= 2:
raise ConnectionError("bache breve")
return {"booking_id": 1, "confirmed": True}
result = retry_with_backoff(flaky_short, max_retries=3, base_delay_ms=100)
checks.append(("retry_with_backoff se recupera de un bache breve", result["confirmed"] is True))
# 2. El circuit breaker abre tras failure_threshold fallos seguidos.
breaker_check = CircuitBreaker("check", failure_threshold=2, cooldown_calls=1)
for _ in range(2):
try:
call_with_breaker(breaker_check, lambda: (_ for _ in ()).throw(ConnectionError("caído")),
max_retries=1, base_delay_ms=10)
except ConnectionError:
pass
checks.append(("el breaker abre tras failure_threshold fallos seguidos", breaker_check.state == "OPEN"))
# 3. Una llamada con el breaker OPEN se rechaza sin tocar la tool real.
tool_was_called = {"value": False}
def real_tool():
tool_was_called["value"] = True
return "no debería llegar aquí"
try:
call_with_breaker(breaker_check, real_tool, max_retries=1, base_delay_ms=10)
except CircuitOpenError:
pass
checks.append(("una llamada con el breaker OPEN nunca toca la tool real", tool_was_called["value"] is False))
# 4. El 429 de Claude se recupera con el mismo retry_with_backoff.
call_claude = make_flaky_claude_client(fail_times=1, retry_after_ms=200)
response_429 = retry_with_backoff(call_claude, "pregunta", max_retries=2, base_delay_ms=100, retry_on=(RateLimitError,))
checks.append(("el 429 de Claude se recupera con retry_with_backoff", response_429["stop_reason"] == "end_turn"))
for name, ok in checks:
print(f"[{'OK' if ok else 'FALLO'}] {name}")
print()
print("TODO LISTO" if all(ok for _, ok in checks) else "HAY FALLOS")
Qué esperar:
intento 1/3...
fallo transitorio (ConnectionError): bache breve -- backoff modelado: 100ms (no se duerme de verdad)
intento 2/3...
fallo transitorio (ConnectionError): bache breve -- backoff modelado: 200ms (no se duerme de verdad)
intento 3/3...
intento 1/1...
fallo transitorio (ConnectionError): caído -- backoff modelado: 10ms (no se duerme de verdad)
intento 1/1...
fallo transitorio (ConnectionError): caído -- backoff modelado: 10ms (no se duerme de verdad)
intento 1/2...
fallo transitorio (RateLimitError): 429 rate_limit_error (intento 1 de este cliente) -- backoff modelado: 100ms (no se duerme de verdad)
intento 2/2...
[OK] retry_with_backoff se recupera de un bache breve
[OK] el breaker abre tras failure_threshold fallos seguidos
[OK] una llamada con el breaker OPEN nunca toca la tool real
[OK] el 429 de Claude se recupera con retry_with_backoff
TODO LISTO
Las líneas de intento/backoff que preceden a los cuatro [OK] son el rastro real de cada verificación —el bache breve del punto 1 recuperándose al tercer intento, los dos fallos que abren el breaker del punto 2, el rechazo del punto 3 (sin ninguna línea de intento, porque CircuitOpenError corta antes de tocar retry_with_backoff), y el 429 del punto 4 recuperándose al segundo intento—. Cuatro puntos, las cuatro piezas centrales del módulo, confirmadas con código que corre — no con una lectura del código.
Errores comunes
-
Concluir, del trade-off de esta lección, que "los circuit breakers no sirven". Sirven exactamente para lo que están diseñados: apagones que duran más de lo que un usuario individual está dispuesto a esperar con reintentos. El apagón de este mini-proyecto es corto a propósito, para que el trade-off entre —en un apagón mucho más largo, el breaker gana en las dos métricas a la vez, como ya viste en la Lección 5.
-
Elegir
cooldown_callssin conocer la duración típica de los apagones reales de tu sistema. El número correcto depende de datos que este módulo no tiene —cuánto duran, en la práctica, las caídas de la dependencia real quebook_roomrepresentaría—. Sin esa información, cualquier valor es una apuesta; con ella, es una decisión de ingeniería. -
Olvidar reiniciar
_state["count"]o crear unCircuitBreakernuevo entre pruebas. El contador de llamadas reales y el estado del breaker son objetos compartidos entre todo el código que corre en el mismo proceso — si corres el mismo bloque dos veces sin reiniciarlos, vas a ver números que no coinciden con lo esperado, no porque el código esté mal, sino porque el estado sigue donde lo dejaste la vez anterior.
Ejercicios
Ejercicio 1: Calcula el ahorro exacto en llamadas reales (Fácil)
Con los tres escenarios de esta lección (cooldown_calls=2: 8 llamadas; sin breaker: 12; cooldown_calls=1: 10), calcula qué porcentaje de llamadas reales ahorró cada configuración con breaker, comparada contra no tener ninguno.
Ver solución
sin_breaker = 12
con_breaker_2 = 8
con_breaker_1 = 10
ahorro_2 = (sin_breaker - con_breaker_2) / sin_breaker * 100
ahorro_1 = (sin_breaker - con_breaker_1) / sin_breaker * 100
print(f"cooldown_calls=2: ahorra {ahorro_2:.1f}% de llamadas reales")
print(f"cooldown_calls=1: ahorra {ahorro_1:.1f}% de llamadas reales")
Salida esperada:
cooldown_calls=2: ahorra 33.3% de llamadas reales
cooldown_calls=1: ahorra 16.7% de llamadas reales
Explicación: el breaker más conservador (cooldown_calls=2) ahorra el doble de llamadas reales que el más agresivo (cooldown_calls=1) — exactamente el precio que paga a cambio de menos confirmaciones (1 contra 3), tal como se vio en la sección de trade-off.
Ejercicio 2: Un apagón más largo, el mismo lote (Medio)
Repite el ejemplo trabajado de esta lección (los mismos ocho usuarios, failure_threshold=2, cooldown_calls=2, max_retries=2), pero con OUTAGE_CALLS = 20 en vez de 7 — un apagón que dura más que todo el lote junto. ¿Cuántos usuarios consiguen su reserva? ¿Cuántas llamadas reales se gastan en total?
Ver solución
Con OUTAGE_CALLS=20, ningún usuario del lote de ocho consigue confirmar su reserva — cada sonda de HALF_OPEN que ocurra dentro de estos ocho runs va a caer, otra vez, dentro de la ventana caída (20 llamadas es más de lo que ocho usuarios con dos intentos cada uno pueden generar entre reales y rechazadas). El patrón se repite: CLOSED → OPEN en los primeros dos runs, y a partir de ahí, ciclos de dos rechazos seguidos de una sonda fallida —igual que el patrón de dos sondas fallidas de la Lección 5—, sin que ninguna alcance a coincidir con una recuperación que, en este escenario, todavía no llegó. Las llamadas reales totales quedan acotadas por cuántas sondas alcanzan a dispararse en ocho runs —muchas menos que las 16 que costarían sin ningún breaker (ocho usuarios × dos intentos cada uno)—, exactamente el punto central de este módulo: cuanto más largo el apagón real, más claro es el beneficio del breaker.
Ejercicio 3: Diseña el criterio de decisión para book_room, en un párrafo (Difícil)
Reservo es un sistema real, y book_room de verdad depende de un servicio de pagos externo que, según los datos históricos del equipo de infraestructura, sufre caídas cortas (menos de un minuto) con mucha frecuencia, y caídas largas (varios minutos) rara vez. Escribe, en un párrafo, el criterio que usarías para elegir failure_threshold y cooldown_calls para el CircuitBreaker de book_room en ese contexto — ¿preferirías un breaker más agresivo o más conservador, y por qué, dado que las caídas cortas son mucho más comunes que las largas?
Ver solución
Con caídas cortas mucho más frecuentes que las largas, el criterio correcto favorece un failure_threshold moderado —ni tan bajo que abra por un hipo aislado sin relación con una caída real, ni tan alto que pague un peaje de detección grande en cada caída corta— y, sobre todo, un cooldown_calls bajo: como la mayoría de las caídas duran menos de un minuto, el breaker necesita sondear con frecuencia para no quedarse "atascado" en OPEN mucho después de que el servicio de pagos ya se recuperó —el mismo problema que Sofía y Marco sufrieron en el ejemplo trabajado de esta lección, con un apagón corto y un cooldown_calls demasiado generoso—. La única razón para tolerar un cooldown_calls más alto sería si las sondas fallidas fueran, por algún motivo, mucho más caras que una llamada normal —por ejemplo, si cada sonda cobrara una comisión real al servicio de pagos externo—, lo cual convertiría el trade-off de "sondear seguido" en un costo de dinero real, no solo de latencia. Sin esa información adicional, y dado que las caídas cortas dominan, la decisión razonable es la misma que el Ejercicio 1 de esta lección ya cuantificó: preferir el breaker más agresivo (cooldown_calls bajo), porque el costo de sondear de más en un sistema con caídas mayormente cortas es bajo comparado con el costo de dejar a usuarios degradados de más, innecesariamente, mientras el servicio ya volvió.
Resumen y siguiente paso
- Ejecutamos el escenario más realista de todo el módulo: ocho usuarios en fila,
book_roomcaído durante un tramo del lote, backoff + circuit breaker + degradación elegante trabajando juntos — un usuario confirmado, cuatro degradados con una respuesta clara e inmediata, tres pagando el costo completo de un intento real fallido. - Medimos, con números reales —no con intuición—, el trade-off central de todo circuit breaker: un
cooldown_callsmás conservador ahorra más llamadas reales pero puede degradar usuarios que, en un apagón más corto de lo esperado, ya podrían haber sido atendidos. - Confirmamos con un checklist ejecutado las cuatro piezas centrales del módulo: el backoff se recupera de un bache breve, el breaker abre tras el umbral, una llamada con el breaker
OPENnunca toca la tool real, y el429de Claude se recupera con el mismo mecanismo de backoff. - El artefacto completo de este módulo son dos archivos,
resilience/tool_circuit_breaker.pyyresilience/claude_rate_limit.py— nada más cambió en el resto del sistema.
Con esto se cierra el Módulo 6. El agente de Reservo ahora sabe reintentar con criterio, recordar entre runs cuándo una tool está muerta, distinguir el rate limit del proveedor de una tool caída, y responder con claridad cuando algo no se puede resolver — cuatro capacidades que ningún módulo anterior de esta guía tenía.
Siguiente módulo: Módulo 7 — Versionado y rollout seguro. El agente de Reservo, con toda su ingeniería de operación acumulada hasta ahora —logging, costo, latencia, el gate de regresión del Módulo 5, y la resiliencia de este módulo—, todavía puede cambiar de versión sin ningún criterio para decidir si el cambio es seguro. Ese módulo cierra esa brecha.
Recursos adicionales
- Anthropic — Building effective agents — Sobre por qué la confiabilidad de un sistema agentic en producción es una acumulación de decisiones de ingeniería explícitas, no una propiedad que aparece sola.
resilience-and-reliability-patterns-guide— La guía hermana completa: backoff+jitter con datos aleatorios reales, circuit breakers medidos a fondo, bulkheads, y degradación elegante con fallback real — la profundidad que este módulo citó, lección por lección, en vez de repetir.- Anthropic — Rate limits y Anthropic — Errors — Las referencias reales detrás del
429simulado de la Lección 6. - Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de este módulo completo.