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

Una tool que falla repetidamente

Descripción

Antes de construir ninguna solución, vale la pena ver el problema con tus propios ojos, ejecutado. Esta lección no agrega ningún mecanismo de resiliencia todavía —eso empieza en la Lección 3—; se detiene, a propósito, en el momento exacto en que algo empieza a salir mal, y deja que el código te muestre lo que le falta al agente de Reservo tal como está hoy: memoria entre runs. Vas a simular una caída real de book_room y vas a ver, ejecutado, cómo tres usuarios distintos —Ana, Luis y Sofía— llegan uno detrás del otro mientras la tool sigue caída, y cómo el agente descubre esa misma caída tres veces, desde cero, sin que la segunda ni la tercera vez aprendan nada de la primera.

Conexión con el módulo

Esta lección usa, sin tocar, el runner run_reservo_agent de agent-fundamentals Módulo 8 y su manejo de errores de Módulo 7 (dispatch_robust, que atrapa cualquier excepción real de una tool y la convierte en un tool_result con is_error: true, sin dejar que el run se caiga). Eso ya funciona, y sigue funcionando exactamente igual aquí. Lo que esta lección expone es lo que ese mecanismo no resuelve: cada run es una isla. dispatch_robust no tiene ninguna forma de saber que la tool que está a punto de llamar ya falló dos veces en los últimos cinco minutos, para otros dos usuarios.


Analogía: la misma llamada al restaurante, tres noches distintas

Imagina que llamas a un restaurante para reservar una mesa, y nadie contesta — la línea está caída. Al día siguiente, un amigo tuyo, sin saber nada de tu llamada fallida, intenta lo mismo — y tampoco contesta nadie. Al tercer día, otra persona más, completamente ajena a las dos llamadas anteriores, marca el mismo número — y se encuentra con el mismo silencio. Tres personas distintas descubrieron, cada una por su cuenta, exactamente el mismo hecho: ese restaurante no está contestando el teléfono. Ninguna de las tres se enteró de que las otras dos ya lo habían intentado y habían fallado. Si existiera una libreta compartida en la puerta del restaurante —"no contestan desde el lunes"— la segunda y la tercera persona se habrían ahorrado la llamada completa.

Eso es exactamente lo que le falta al agente de Reservo en esta lección: no hay ninguna libreta compartida. Cada run de run_reservo_agent es como cada una de esas tres personas — descubre la caída de book_room por su cuenta, paga el costo completo de descubrirla, y no deja ningún rastro que ayude al siguiente run a saltarse ese descubrimiento. La Lección 4 construye exactamente esa libreta — el circuit breaker.


Ejemplo trabajado: tres runs, la misma tool caída, sin memoria

Una tool que falla de forma sostenida

Reusamos el patrón exacto que agent-fundamentals M7 (Lección 6) usó con flaky_list_rooms: una tool de prueba, declarada solo para esta lección, que envuelve la tool real y falla un número determinado de veces antes de recuperarse. Esta vez la tool es book_room —la de escritura, la que de verdad le importa al usuario— y la caída dura seis llamadas reales, más de lo que cualquier usuario individual va a intentar por su cuenta.

import reservo_tools as rt
import reservo_agent as ra

_book_room_real = rt.book_room
_state = {"count": 0}
OUTAGE_CALLS = 6


def flaky_book_room(room, tier, hours, member):
    """Simula una tool con una dependencia inestable: falla las primeras
    seis llamadas con un error TRANSITORIO. No es parte del contrato
    canónico de Reservo -- se declara para esta lección, igual que
    agent-fundamentals M7 hizo con flaky_list_rooms."""
    _state["count"] += 1
    if _state["count"] <= OUTAGE_CALLS:
        raise ConnectionError(f"timeout de red simulado (intento {_state['count']})")
    return _book_room_real(room, tier, hours, member)


ra.TOOL_FUNCS["book_room"] = flaky_book_room

La última línea es la misma técnica de "envolver sin tocar" que ya conoces del Módulo 2: reemplazamos, desde afuera, la entrada "book_room" del diccionario TOOL_FUNCS que usa dispatch_robust — sin editar una sola línea de reservo_agent.py. dispatch_robust sigue haciendo exactamente lo que siempre hizo: llama a la función registrada bajo ese nombre, y si lanza una excepción, la atrapa y arma un tool_result con is_error: true. No sabe, y no necesita saber, que la función que está llamando ahora es una versión inestable.

Tres usuarios, tres runs, la misma caída descubierta tres veces

def make_script(room, tier, hours, member):
    return [
        {"stop_reason": "tool_use", "content": [
            {"type": "tool_use", "id": "toolu_01", "name": "book_room",
             "input": {"room": room, "tier": tier, "hours": hours, "member": member}}]},
        {"stop_reason": "end_turn", "content": [
            {"type": "text", "text": f"No pude confirmar la reserva de {room} para {member} -- el sistema de reservas no respondió. Intenta de nuevo en unos minutos."}]},
    ]


print("=== tres runs independientes, la misma tool caída, SIN memoria entre ellos ===")
users = [("Focus", "pro", 3, "Ana"), ("Studio", "pro", 2, "Luis"), ("Boardroom", "pro", 1, "Sofía")]
for i, (room, tier, hours, member) in enumerate(users, start=1):
    final, history = ra.run_reservo_agent(f"Reserva {room} para {member}", make_script(room, tier, hours, member))
    tool_result = history[2]["content"][0]
    print(f"--- run {i} ({member}) ---")
    print(f"  tool_result: is_error={tool_result['is_error']} content={tool_result['content']!r}")
    print(f"  RESPUESTA: {final['content'][0]['text']}")
print()
print(f"llamadas reales a book_room hasta ahora: {_state['count']} (las 3 fallaron -- el apagón sigue)")

Qué esperar:

=== tres runs independientes, la misma tool caída, SIN memoria entre ellos ===
--- run 1 (Ana) ---
  tool_result: is_error=True content='ConnectionError: timeout de red simulado (intento 1)'
  RESPUESTA: No pude confirmar la reserva de Focus para Ana -- el sistema de reservas no respondió. Intenta de nuevo en unos minutos.
--- run 2 (Luis) ---
  tool_result: is_error=True content='ConnectionError: timeout de red simulado (intento 2)'
  RESPUESTA: No pude confirmar la reserva de Studio para Luis -- el sistema de reservas no respondió. Intenta de nuevo en unos minutos.
--- run 3 (Sofía) ---
  tool_result: is_error=True content='ConnectionError: timeout de red simulado (intento 3)'
  RESPUESTA: No pude confirmar la reserva de Boardroom para Sofía -- el sistema de reservas no respondió. Intenta de nuevo en unos minutos.

llamadas reales a book_room hasta ahora: 3 (las 3 fallaron -- el apagón sigue)

Cada uno de los tres pidió una sala distinta —Focus, Studio, Boardroom—, y a cada uno le tocó exactamente el mismo destino: una llamada real a book_room, un ConnectionError, y una respuesta final honesta pero sin ninguna esperanza real de que "intenta de nuevo en unos minutos" ayude, porque nada en el sistema sabe que hace falta esperar más que eso, o que ya son tres los usuarios que se toparon con lo mismo. dispatch_robust hizo su trabajo perfectamente en las tres ocasiones — atrapó el error, no dejó que el run se cayera, produjo una respuesta clara. Lo que no hizo, porque no está diseñado para hacerlo, fue avisarle al siguiente run que ya sabe la respuesta.


El costo de no recordar

Con solo tres usuarios el problema ya es visible; a escala real —cientos de usuarios llegando durante un apagón que dura minutos, no milisegundos— el costo se multiplica exactamente en proporción al número de personas que tienen la mala suerte de pedir algo durante la ventana caída. Cada una de esas llamadas reales a book_room cuesta lo mismo que costaría si la tool estuviera sana: el tiempo de espera hasta el timeout (que el Módulo 4 mediría en milisegundos), y si book_room fuera, en un sistema real, una llamada a un servicio de pago o a una API externa con costo por invocación, cada intento fallido también sería dinero gastado sin ningún resultado — exactamente lo que el Módulo 3 mediría en centavos.

Fíjate en algo más sutil: en esta lección, ninguno de los tres reintenta dentro de su propio run —el guion de cada usuario tiene una sola llamada a book_room—. Eso es a propósito: el problema que esta lección plantea no es "¿cuántas veces reintento dentro de un run?" —esa pregunta ya la resolvió agent-fundamentals M7, y la va a escalar la Lección 3 de este módulo con backoff—. El problema es uno completamente distinto: run tras run, nadie recuerda nada. Ese es, con precisión, el hueco que el circuit breaker de las Lecciones 4 y 5 viene a cerrar.


Errores comunes

  1. Pensar que la solución es "agregar más reintentos dentro del run". No resuelve nada aquí — el problema no es que cada usuario tenga pocas oportunidades dentro de su propio run, es que el sistema entero no recuerda entre un run y el siguiente. Diez reintentos por run seguirían descubriendo la misma caída, diez veces cada uno, para cada usuario nuevo.

  2. Confundir "la tool devolvió is_error" con "el sistema falló". dispatch_robust funcionó exactamente como debía en los tres casos — atrapó el error real, nunca dejó que el run se cayera sin control, y produjo una respuesta clara para el usuario. El sistema no "falló" en el sentido de un bug — lo que falta es una capa completamente distinta, que esta lección todavía no construye.

  3. Suponer que basta con loguear el error para "recordarlo". El Módulo 2 ya deja un registro completo de cada tool_result con is_error: true en RUN_LOG.jsonl — pero un log es una historia que alguien tiene que leer después. Lo que hace falta aquí es una decisión en el momento, antes de la próxima llamada: ¿vale la pena intentarlo, o ya sabemos que no?


Ejercicios

Ejercicio 1: Calcula cuántas llamadas reales se desperdician (Fácil)

Sin ejecutar Python: si la caída de book_room dura OUTAGE_CALLS = 6 llamadas reales, y llegan cinco usuarios distintos —cada uno con un solo intento a book_room en su guion, como en el ejemplo de esta lección—, ¿cuántos de los cinco reciben un ConnectionError? ¿Cuántas llamadas reales se gastaron en total, sin ningún resultado útil?

Ver solución

Los cinco reciben ConnectionError, porque 5 <= OUTAGE_CALLS (6) — las cinco llamadas reales caen dentro de la ventana caída. Se gastaron 5 llamadas reales, las cinco sin ningún resultado útil — ninguna reserva se confirmó, y el sistema sigue sin saber, después de la quinta, que ya lleva cinco fallos seguidos.

Ejercicio 2: Un sexto usuario, justo en el borde (Medio)

Usando el mismo flaky_book_room de esta lección (OUTAGE_CALLS = 6), ejecuta un sexto y un séptimo usuario después de los tres del ejemplo trabajado (Ana, Luis, Sofía ya gastaron las llamadas reales 1, 2 y 3). Sin ejecutar Python primero: ¿el sexto usuario (llamada real #4) va a fallar o va a tener éxito? ¿Y el séptimo (llamada real #5)? Después, ejecuta y confirma.

Ver solución

Ambos van a fallar: la llamada real #4 y la #5 siguen dentro de la ventana <= OUTAGE_CALLS (6). Recién la llamada real #7 —un octavo usuario— tendría éxito.

users_extra = [("Focus", "basic", 1, "Marco"), ("Studio", "basic", 1, "Julia")]
for i, (room, tier, hours, member) in enumerate(users_extra, start=4):
    final, history = ra.run_reservo_agent(f"Reserva {room} para {member}", make_script(room, tier, hours, member))
    tool_result = history[2]["content"][0]
    print(f"run {i} ({member}): is_error={tool_result['is_error']}")

Salida esperada:

run 4 (Marco): is_error=True
run 5 (Julia): is_error=True

Explicación: _state["count"] es un contador compartido por TODA la sesión de Python, no por usuario — sigue subiendo con cada llamada real, sin importar quién la originó. Eso es, de hecho, exactamente lo que hace que el problema de esta lección sea real: el estado de "¿está caída la tool?" existe (el contador lo sabe), pero nada en el camino entre dispatch_robust y el usuario lo consulta antes de intentar de nuevo.

Ejercicio 3: ¿Por qué no alcanza con revisar RUN_LOG.jsonl antes de cada run? (Difícil)

El Módulo 2 dejó un RUN_LOG.jsonl con un evento tool_result por cada llamada, incluidos los que tienen is_error: true. Alguien propone: "antes de cada run nuevo, leamos el log completo y contemos cuántos tool_result de book_room con is_error: true hubo en los últimos N eventos — si son muchos, no llamamos a la tool". Explica, en un párrafo, qué problema práctico tiene esa propuesta comparada con un circuit breaker que vive en memoria dentro del proceso, y por qué esta guía elige la segunda opción para las Lecciones 4 y 5.

Ver solución

La propuesta no está mal en su intención —de hecho, es la misma señal que un circuit breaker usa—, pero tiene un costo que un objeto en memoria no tiene: leer y parsear el archivo completo, o al menos su cola, antes de cada llamada a la tool. Eso agrega una operación de I/O (abrir el archivo, leer líneas, parsear JSON) al camino crítico de cada tool call, exactamente donde menos conviene agregar latencia — es el mismo tipo de costo que el Módulo 4 enseñó a medir con cuidado. Un CircuitBreaker en memoria, en cambio, es un objeto Python normal —un if self.state == OPEN cuesta nanosegundos, no una lectura de disco—, y vive exactamente tanto como el proceso que sirve las peticiones, que es, en la práctica, el tiempo que le importa a esta decisión. El log de RUN_LOG.jsonl sigue siendo valioso —para auditoría, para reconstruir qué pasó horas o días después—, pero no es la estructura de datos correcta para una decisión que tiene que tomarse en microsegundos, antes de cada llamada. Esa es, con precisión, la razón por la que las Lecciones 4 y 5 construyen el breaker como un objeto en memoria, no como un lector de logs.


Resumen y siguiente paso

  • Ejecutamos, con flaky_book_room, el problema central de este módulo: tres usuarios distintos —Ana, Luis, Sofía— cada uno con su propio run, cada uno descubriendo desde cero, con una llamada real fallida, que book_room está caído.
  • Confirmamos que dispatch_robust (agent-fundamentals M7) funciona exactamente como debe — atrapa el error real, nunca deja caer el run, produce una respuesta clara — y que eso, por diseño, no incluye ninguna memoria entre un run y el siguiente.
  • El costo de no recordar crece en proporción directa al número de usuarios que llegan durante la ventana caída: cada uno paga el costo completo de descubrir, por su cuenta, lo que ya se sabía.
  • El siguiente paso no es "más reintentos dentro del run" — eso ya lo resolvió agent-fundamentals M7. Es una capa de memoria que persiste entre runs, y antes de eso, una mejora concreta al reintento mismo: backoff.

Siguiente lección: 03 — Reintentos con backoff acotado. Antes de construir la memoria entre runs, cerramos una pieza que agent-fundamentals M7 dejó pendiente a propósito: cuánto esperar entre un intento y el siguiente, dentro de un mismo run.


Recursos adicionales

  1. Python — Excepciones incorporadas (ConnectionError) — La excepción estándar que esta lección, igual que agent-fundamentals M7, usa para representar un fallo transitorio.
  2. Anthropic — Building effective agents — Sobre por qué un sistema agentic en producción necesita anticipar fallos de sus dependencias, no solo manejarlos uno por uno cuando ya ocurrieron.
  3. resilience-and-reliability-patterns-guide — El mismo problema —una dependencia caída, descubierta una y otra vez sin memoria— es el punto de partida de esa guía hermana para sistemas distribuidos genéricos, con su propio caso (Mercado).