Módulo 6: Fallos a escala — backoff, circuit breakers y rate limits
Degradación elegante
Descripción
Las Lecciones 4 y 5 construyeron un CircuitBreaker que sabe, con certeza, cuándo book_room lleva fallando de forma sostenida — y en cuanto lo sabe, rechaza cualquier llamada nueva en microsegundos, sin tocar la tool muerta. Esa certeza es valiosa, pero solo si el agente hace algo útil con ella. Un breaker que se limita a lanzar CircuitOpenError hacia afuera, sin que nadie lo atrape con criterio, deja al agente exactamente donde estaba antes: un error técnico, sin traducir, en el peor momento posible. Esta lección cierra ese círculo — no con un mecanismo nuevo, sino reusando el protocolo is_error que agent-fundamentals ya construyó, para que el agente le diga al usuario, con claridad y en el mismo instante, por qué su reserva no se pudo confirmar.
Conexión con el módulo
Esta lección no agrega ningún componente nuevo a resilience/tool_circuit_breaker.py — todo lo que necesita ya existe desde la Lección 4: CircuitOpenError, call_with_breaker, y el mecanismo de dispatch_robust de agent-fundamentals M7 que atrapa cualquier excepción real de una tool y la convierte en un tool_result con is_error: true. Lo único que cambia en esta lección es de dónde viene esa excepción — antes era un ConnectionError real de la tool; ahora es, además, un CircuitOpenError del breaker — y qué hace el modelo (concepto) con la información que ese is_error le da.
🛑 Frontera — lo que esta lección hace y lo que no
resilience-and-reliability-patterns-guide (Módulo 7, "Degradación elegante y load shedding") desarrolla este tema a fondo, con un sistema completo: fallback (una respuesta alternativa cuando la dependencia principal no está), degrade (bajar la calidad del servicio en vez de negarlo por completo — por ejemplo, un envío diferido en vez de uno inmediato), y load shedding (rechazar tráfico de forma deliberada cuando el sistema entero está sobrecargado, no solo una dependencia puntual), todo medido con success_rate real contra el caso Mercado. Esa es la referencia completa, y esta lección la cita en vez de repetirla.
Lo que esta lección construye es mucho más angosto: qué le dice el agente al usuario, en una sola respuesta, cuando el circuit breaker de una tool específica está OPEN. No hay ningún fallback a un servicio alternativo, no hay ninguna cola de reintentos diferidos, no hay ningún mecanismo de load shedding sobre el tráfico general del sistema — eso queda íntegro en la guía hermana. Lo que hay es una traducción: de CircuitOpenError (una excepción técnica) a una respuesta clara (una frase que el usuario entiende), usando el mismo protocolo is_error que ya conoces.
Analogía: seguir con la vida cuando el interruptor saltó
Vuelve al interruptor térmico. Cuando salta y corta la luz de un circuito, tienes dos formas de reaccionar. La mala: seguir enchufando el mismo aparato roto, una y otra vez, cada vez que se te olvida que ya saltó — sin ganar nada, sin aprender nada. La buena: darte cuenta, en el momento, de que ese circuito está cortado, y actuar en consecuencia — usar una linterna si hace falta, avisarle a quien tenga que arreglarlo, y seguir con tu noche sin fingir que la luz sigue prendida. Degradar con elegancia es exactamente esa segunda reacción, aplicada al agente: en cuanto sabe que book_room está OPEN, no vuelve a intentarlo, y le explica al usuario, con claridad, qué pasó y qué puede esperar — en vez de colgarse, de reintentar a ciegas, o de responder con un mensaje técnico que no le sirve a nadie.
Ejemplo trabajado: el mismo is_error, ahora con el breaker adentro
El breaker ya está abierto, por fallos de runs anteriores
Simulamos el momento en que varios usuarios ya dispararon el breaker —como en las Lecciones 4 y 5— y ahora llega un usuario nuevo, Ana, con el circuito ya OPEN.
import reservo_tools as rt
import reservo_agent as ra
def always_down_book_room(room, tier, hours, member):
raise ConnectionError("timeout de red simulado -- book_room sigue caído")
def make_resilient_book_room(breaker, real_fn, max_retries=3, base_delay_ms=100):
def resilient_book_room(**kwargs):
return call_with_breaker(breaker, real_fn, max_retries=max_retries, base_delay_ms=base_delay_ms, **kwargs)
return resilient_book_room
# El breaker YA está OPEN, por fallos acumulados en runs anteriores
breaker = CircuitBreaker("book_room", failure_threshold=3, cooldown_calls=5)
breaker.state = "OPEN"
ra.TOOL_FUNCS["book_room"] = make_resilient_book_room(breaker, always_down_book_room)
La técnica es, otra vez, la misma de siempre: reemplazamos TOOL_FUNCS["book_room"] desde afuera, sin tocar dispatch_robust ni run_reservo_agent. Lo único nuevo es que la función que registramos ahora envuelve la tool real con call_with_breaker — así que cualquier excepción que salga de ahí, sea un ConnectionError real o un CircuitOpenError del breaker, va a llegar a dispatch_robust exactamente por el mismo camino, y dispatch_robust la va a atrapar exactamente de la misma forma que atrapa cualquier otra excepción real: sin cambiar una línea de su propio código.
El guion: el modelo ve el is_error, y responde con claridad
script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Las reservas de Focus están temporalmente pausadas por mantenimiento del sistema. Guardé tu pedido y te aviso apenas se restablezca -- no hace falta que lo repitas."}]},
]
final, history = ra.run_reservo_agent("Reserva Focus pro 3h para Ana", script)
ra.print_trace(history)
print()
print("RESPUESTA:", final["content"][0]["text"])
print()
tool_result = history[2]["content"][0]
print(f"tool_result real: is_error={tool_result['is_error']} content={tool_result['content']!r}")
Qué esperar:
[0] user pregunta: 'Reserva Focus pro 3h para Ana'
[1] assistant tool_use(book_room): {'room': 'Focus', 'tier': 'pro', 'hours': 3, 'member': 'Ana'}
[2] user tool_result [is_error]: CircuitOpenError: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool
[3] assistant texto final: 'Las reservas de Focus están temporalmente pausadas por mantenimiento del sistema. Guardé tu pedido y te aviso apenas se restablezca -- no hace falta que lo repitas.'
RESPUESTA: Las reservas de Focus están temporalmente pausadas por mantenimiento del sistema. Guardé tu pedido y te aviso apenas se restablezca -- no hace falta que lo repitas.
tool_result real: is_error=True content='CircuitOpenError: book_room: circuito abierto (OPEN), llamada rechazada sin tocar la tool'
Fíjate en el paso [2]: el tool_result con is_error: true tiene, adentro, el mensaje exacto que CircuitOpenError produjo — "circuito abierto (OPEN), llamada rechazada sin tocar la tool". Eso es información real y específica, no un timeout genérico — el agente (concepto, claude-sonnet-5) sabe, con esa información, que no vale la pena reintentar dentro de este mismo run, y que la causa no es un dato inválido del usuario sino una caída del propio sistema. El paso [3] es la respuesta guionada de este ejemplo — en un sistema real, el modelo produciría un texto así a partir de ver exactamente ese is_error, de la misma forma en que agent-fundamentals M7 ya mostró que el modelo se auto-corrige o responde con criterio frente a un tool_result con error. Ningún mecanismo nuevo — el mismo protocolo, con una fuente de error nueva.
Comparación: sin el breaker, el mismo apagón cuesta tres intentos reales por usuario
def unprotected_book_room(**kwargs):
return retry_with_backoff(always_down_book_room, max_retries=3, base_delay_ms=100, **kwargs)
ra.TOOL_FUNCS["book_room"] = unprotected_book_room
script_sin_breaker = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "No pude confirmar la reserva de Focus -- el sistema no respondió tras varios intentos."}]},
]
print("=== SIN circuit breaker: cada run gasta max_retries llamadas reales antes de rendirse ===")
final, history = ra.run_reservo_agent("Reserva Focus pro 3h para Ana", script_sin_breaker)
print("RESPUESTA:", final["content"][0]["text"])
Qué esperar:
=== SIN circuit breaker: cada run gasta max_retries llamadas reales antes de rendirse ===
intento 1/3...
fallo transitorio (ConnectionError): timeout de red simulado -- book_room sigue caído -- backoff modelado: 100ms (no se duerme de verdad)
intento 2/3...
fallo transitorio (ConnectionError): timeout de red simulado -- book_room sigue caído -- backoff modelado: 200ms (no se duerme de verdad)
intento 3/3...
fallo transitorio (ConnectionError): timeout de red simulado -- book_room sigue caído -- backoff modelado: 400ms (no se duerme de verdad)
RESPUESTA: No pude confirmar la reserva de Focus -- el sistema no respondió tras varios intentos.
Las dos respuestas finales dicen, en el fondo, lo mismo — "no se pudo reservar ahora mismo" —, pero llegaron ahí de formas completamente distintas. Sin el breaker: tres llamadas reales, cada una esperando su propio backoff (100ms, 200ms, 400ms de espera modelada — en un sistema real, tiempo de verdad, con el usuario esperando una respuesta), antes de rendirse. Con el breaker: cero llamadas reales, una respuesta en el mismo instante, y —el detalle que más importa— una respuesta que además le dice al usuario que no hace falta reintentar ("guardé tu pedido, te aviso"), en vez de dejarlo con la duda de si vale la pena volver a preguntar. Esa es, con números, la ganancia completa de las Lecciones 4 y 5, puesta al servicio del usuario final en esta lección.
Errores comunes
-
Dejar que
CircuitOpenErrorse propague sin que el modelo lo vea. Si el código que llama arun_reservo_agentatraparaCircuitOpenErrorpor fuera del loop del agente —en vez de dejar quedispatch_robustlo convierta en untool_resultconis_error—, el modelo nunca se enteraría de por qué falló, y no podría producir una respuesta informada. El mensaje del breaker es información valiosa; perderla en un manejo de excepciones genérico es desperdiciarla. -
Confundir "degradar con elegancia" con "ocultar el fallo". Una respuesta que dijera, sin más, "reserva confirmada" cuando en realidad no se confirmó nada, no es degradación elegante — es una mentira. Degradar con elegancia significa ser honesto sobre lo que pasó, rápido, y con una salida clara — nunca fingir un éxito que no ocurrió.
-
Construir un mensaje de degradación distinto para cada tool, a mano, en cada lugar del código. El mensaje real que ve el usuario (paso
[3]del ejemplo) es responsabilidad del modelo, no de este módulo — este módulo solo garantiza que la información correcta (CircuitOpenErrorcon un mensaje claro) llegue hasta ahí. Intentar codificar a mano, en Python, todas las variantes posibles de "qué decirle al usuario" para cada tool y cada estado del breaker es reconstruir, a mano, algo que el protocolois_error+ el modelo ya resuelven mejor.
Ejercicios
Ejercicio 1: Confirma el ahorro en pasos del historial (Fácil)
Sin ejecutar Python: compara len(history) entre el run con breaker OPEN (el primer ejemplo de esta lección) y el run sin breaker (el segundo ejemplo). Ambos tienen la misma estructura de guion (una tool call, un texto final) — ¿por qué el número de pasos en history es igual en los dos, aunque el camino interno haya sido tan distinto?
Ver solución
len(history) es 4 en ambos casos: [0] la pregunta, [1] el tool_use, [2] el tool_result, [3] el texto final. El número de pasos en history refleja la estructura del protocolo —cuántos turnos hubo entre el modelo y las tools—, no cuánto trabajo interno hizo dispatch_robust para producir el tool_result del paso [2]. Si dentro de ese único tool_result hubo cero llamadas reales (breaker OPEN) o tres llamadas reales con backoff (sin breaker), el historial no lo distingue — para verlo, hace falta mirar la salida real de la ejecución (las líneas de intento), no la forma de history. Es la misma distinción que la Lección 5 subrayó entre el estado "antes/después" del breaker y lo que en verdad pasó adentro de un run.
Ejercicio 2: Un mensaje de degradación para cancel_booking (Medio)
cancel_booking no tiene un circuit breaker en esta guía —el módulo se enfocó en book_room—, pero el mismo patrón aplicaría igual si lo tuviera. Escribe el guion completo (model_script) de un run donde el breaker de cancel_booking está OPEN, y el modelo responde con un mensaje de degradación adecuado —piensa qué información es distinta entre cancelar y reservar: ¿qué le importa más al usuario que le digan cuando lo que falló es una cancelación, no una reserva nueva?
Ver solución
breaker_cancel = CircuitBreaker("cancel_booking", failure_threshold=3, cooldown_calls=5)
breaker_cancel.state = "OPEN"
script_cancel = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "cancel_booking", "input": {"id": 1}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "No pude procesar la cancelación de tu reserva #1 -- el sistema está temporalmente fuera de servicio. Tu reserva SIGUE ACTIVA por ahora; guardé tu pedido de cancelación y lo proceso apenas se restablezca el servicio."}]},
]
Explicación: la diferencia clave frente al mensaje de book_room es que, en una reserva nueva, el estado por defecto si algo falla es "no pasó nada" —no hay ninguna ambigüedad—; en una cancelación, el estado por defecto si algo falla es que la reserva original sigue activa, y eso es exactamente lo que el usuario necesita saber con la mayor claridad posible, para no asumir por error que ya se canceló. Un buen mensaje de degradación no es un molde genérico ("el sistema no responde, intenta más tarde") — tiene que reflejar qué es lo verdaderamente importante para el usuario en ESE tipo de operación específica.
Ejercicio 3: ¿Por qué esta lección nunca construye una cola de reintentos diferidos? (Difícil)
El mensaje del ejemplo trabajado dice "guardé tu pedido y te aviso apenas se restablezca" — pero el código de esta lección no implementa ninguna cola real que guarde ese pedido y lo reintente después. Explica, en un párrafo, por qué esta lección se detiene ahí —en el mensaje— sin construir el mecanismo que lo haría cierto, y qué guía se encargaría de esa pieza si hiciera falta implementarla de verdad.
Ver solución
Una cola de reintentos diferidos —guardar el pedido en algún almacenamiento persistente, con un proceso separado que lo reintenta más tarde y notifica al usuario cuando por fin se confirma— es infraestructura real: necesita una base de datos o una cola de mensajes, un worker que corra fuera del ciclo de vida de un solo run del agente, y un mecanismo de notificación de vuelta al usuario. Nada de eso es "resiliencia de la capa de tool-calls de un agente" —el alcance completo de este módulo, declarado desde la Lección 1—; es infraestructura de aplicación, del mismo orden que un sistema de colas o un scheduler. La frase del mensaje es honesta sobre la intención —comunicarle al usuario que no hace falta que insista— pero el módulo, a propósito, no construye el mecanismo detrás de esa promesa, exactamente por la misma razón que nunca construye bulkheads ni load shedding: esa capa de infraestructura, si hiciera falta implementarla de verdad, es del tamaño y la naturaleza de lo que trabaja resilience-and-reliability-patterns-guide — degradación elegante con un fallback real y medido —, no de esta guía, que opera la capa de un agente con Python puro y costo cero.
Resumen y siguiente paso
- Degradar con elegancia, en esta guía, significa una cosa concreta y angosta: traducir un
CircuitOpenErroren una respuesta clara y honesta para el usuario, en el mismo instante, sin reintentar a ciegas y sin fingir un éxito que no ocurrió. - No hay ningún mecanismo nuevo — reusamos el protocolo
is_errorcompleto deagent-fundamentals(M4/M7): el breaker envuelve la tool real, y cualquier excepción que produzca —ConnectionErroroCircuitOpenError— llega adispatch_robustexactamente por el mismo camino de siempre. - Comparamos, con números reales, el costo de responder sin breaker (tres llamadas reales con backoff, tiempo de espera real para el usuario) contra responder con el breaker ya
OPEN(cero llamadas reales, respuesta inmediata) — la misma frase final, con un costo radicalmente distinto por detrás. - Frontera: fallback a un servicio alternativo, colas de reintentos diferidos, bulkheads y load shedding a fondo quedan íntegros en
resilience-and-reliability-patterns-guide(Módulo 7) — esta lección nunca los construye.
Siguiente lección: 08 — Mini-proyecto: un agente de Reservo resiliente. Juntamos backoff, circuit breaker y degradación elegante en un solo lote de usuarios reales, con book_room cayéndose y recuperándose — y medimos, con números, el trade-off real de ajustar el cooldown del breaker.
Recursos adicionales
resilience-and-reliability-patterns-guide(Módulo 7, "Degradación elegante y load shedding") — El desarrollo completo de fallback, degradación de calidad y load shedding, medido consuccess_ratereal contra el caso Mercado — la profundidad que esta lección cita en vez de repetir.- Anthropic — Tool use (function calling) overview — El protocolo
tool_use/tool_resultconis_error, que esta lección reusa sin ninguna modificación. - Anthropic — Building effective agents — Sobre por qué las respuestas de un agente en producción tienen que ser honestas sobre sus propias limitaciones, no solo técnicamente correctas.
- Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de esta lección.