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

Reintentos con backoff acotado

Descripción

agent-fundamentals Módulo 7 (Lección 6) construyó call_with_retries: un reintento simple, con un tope duro, que distingue un fallo transitorio de un error de validación y nunca reintenta el segundo. Esa misma lección dejó, a propósito, una pieza sin resolver — cito textualmente su cierre: "reintentar de inmediato, sin ningún espacio entre intentos, puede empeorar la congestión que causó el fallo en primer lugar [...] esta lección no la implementa en detalle [...] vale la pena tenerla presente para cualquier sistema que crezca por encima de un ejercicio de aprendizaje". Este módulo es exactamente ese sistema que creció. Esta lección cierra esa pieza pendiente: construye retry_with_backoff, la escalada directa de call_with_retries, que agrega la espera creciente entre un intento y el siguiente — calculada, mostrada, nunca dormida de verdad.

Conexión con el módulo

Esta lección reusa el criterio completo de call_with_retries —reintentar solo lo transitorio, con un tope duro, relanzar el error real si se agota el tope— y le agrega una sola pieza nueva: cuánto esperar entre un intento y el siguiente. El resultado, retry_with_backoff, es la función que vas a reusar, sin cambios, en la Lección 6 de este módulo para el 429 de Claude, y que la Lección 4 va a envolver con el circuit breaker.


Analogía: tocar el timbre cada vez más espaciado, no a repetición

Imagina que tocas el timbre de una puerta y nadie contesta. Volver a tocar un segundo después, y otro segundo después, y otro — es exactamente lo que hace un reintento sin backoff: insistente, pero sin ningún criterio sobre cuánto tiempo es razonable darle a la persona del otro lado para llegar a la puerta. Alguien más paciente esperaría un poco más después del primer intento fallido, un poco más todavía después del segundo, cada vez un poco más — dándole al otro lado más tiempo para reaccionar en cada vuelta, en vez de ametrallar el timbre. Esa progresión —esperar más, cada vez, después de cada fallo— es exactamente lo que el backoff exponencial modela: delay = base * 2**intento. Duplicar la espera en cada vuelta no es arbitrario — es la forma más simple de darle a un servicio saturado cada vez más margen para recuperarse, en vez de sumarle más presión justo cuando menos la necesita.


Ejemplo trabajado: retry_with_backoff, construido y ejecutado

El backoff, modelado — nunca dormido de verdad

# resilience/tool_circuit_breaker.py

def compute_backoff_ms(attempt, base_delay_ms=100):
    """delay = base * 2**attempt -- MODELADO: se calcula y se muestra, nunca
    se duerme de verdad (nunca time.sleep())."""
    return base_delay_ms * (2 ** attempt)


def retry_with_backoff(fn, *args, max_retries=3, base_delay_ms=100,
                        retry_on=(ConnectionError,), **kwargs):
    """Reintenta SOLO las excepciones en retry_on, con un tope duro, y
    calcula (sin dormir) el backoff exponencial de cada intento."""
    last_exc = None
    for attempt in range(1, max_retries + 1):
        try:
            print(f"    intento {attempt}/{max_retries}...")
            return fn(*args, **kwargs)
        except retry_on as exc:
            last_exc = exc
            delay_ms = compute_backoff_ms(attempt - 1, base_delay_ms)
            print(f"      fallo transitorio ({type(exc).__name__}): {exc}"
                  f" -- backoff modelado: {delay_ms}ms (no se duerme de verdad)")
    raise last_exc

Compáralo con call_with_retries de agent-fundamentals M7: el for attempt in range(1, max_retries + 1), el except acotado a un tipo de excepción específico (nunca Exception a secas — un error de validación no cambia por reintentarlo), y el raise last_exc final que nunca finge un éxito que no ocurrió, son exactamente los mismos. Lo único genuinamente nuevo es compute_backoff_ms, y la línea que la usa: en vez de reintentar de inmediato, cada fallo calcula —y muestra— cuánto debería esperar antes del siguiente intento.

Honestidad, antes de seguir: en un sistema real conectado a una red de verdad, ese delay_ms se usaría con time.sleep(delay_ms / 1000) (o su equivalente asíncrono) justo antes del siguiente intento. Esta guía nunca ejecuta esa espera — dormir de verdad en cada bloque "Qué esperar" volvería cada ejemplo lento y, peor, no reproducible byte a byte entre una corrida y la siguiente. Lo que importa para aprender el patrón —cómo crece la espera, y por qué eso protege a una tool saturada— se ve igual de bien en el número calculado que en el reloj real. Y una segunda honestidad, la misma que ya viste en la Lección 1: un sistema real casi siempre le suma jitter —una variación aleatoria— a ese número, para que miles de clientes reintentando la misma dependencia no lo hagan todos en el mismo instante exacto. Esta guía nunca implementa jitter con random, por la misma razón de reproducibilidad; la versión completa, con jitter aleatorio real y medida con miles de clientes simulados, está en resilience-and-reliability-patterns-guide (Módulo 3, "Reintentos, backoff y jitter") — se cita, no se repite.

Ejecutado: un bache breve, retry_with_backoff alcanza a recuperarse

import reservo_tools as rt

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


def flaky_book_room(room, tier, hours, member):
    """Falla las primeras DOS llamadas (un bache breve, no un apagón largo)
    y después funciona con normalidad."""
    _state["count"] += 1
    if _state["count"] <= 2:
        raise ConnectionError(f"timeout de red simulado (intento {_state['count']})")
    return _book_room_real(room, tier, hours, member)


print("=== max_retries=3, base_delay_ms=100: alcanza a recuperarse ===")
result = retry_with_backoff(
    flaky_book_room, room="Focus", tier="pro", hours=3, member="Ana",
    max_retries=3, base_delay_ms=100,
)
print("resultado:", result)

Qué esperar:

=== max_retries=3, base_delay_ms=100: alcanza a recuperarse ===
    intento 1/3...
      fallo transitorio (ConnectionError): timeout de red simulado (intento 1) -- backoff modelado: 100ms (no se duerme de verdad)
    intento 2/3...
      fallo transitorio (ConnectionError): timeout de red simulado (intento 2) -- backoff modelado: 200ms (no se duerme de verdad)
    intento 3/3...
resultado: {'booking_id': 1, 'confirmed': True, 'price_cents': 6000}

Los dos primeros intentos fallan —tal como flaky_book_room está diseñada para hacer—, cada uno con su backoff calculado y mostrado (100ms, después 200ms — el doble). El tercer intento, sin ningún mensaje de fallo antes de resultado:, tuvo éxito: _state["count"] ya llegó a 3, y flaky_book_room ejecuta la rama real. La tabla completa de cuánto crece cada backoff, con base_delay_ms=100:

print("=== la tabla de backoff exponencial (compute_backoff_ms), base=100ms ===")
for attempt in range(4):
    print(f"  intento {attempt}: delay = 100 * 2**{attempt} = {compute_backoff_ms(attempt, 100)}ms")

Qué esperar:

=== la tabla de backoff exponencial (compute_backoff_ms), base=100ms ===
  intento 0: delay = 100 * 2**0 = 100ms
  intento 1: delay = 100 * 2**1 = 200ms
  intento 2: delay = 100 * 2**2 = 400ms
  intento 3: delay = 100 * 2**3 = 800ms

Cada vuelta duplica la espera anterior — la misma progresión, sin importar cuál sea el base_delay_ms que elijas.

Ejecutado: max_retries=1 se agota antes de recuperarse

print("=== max_retries=1: se agota antes de recuperarse (el mismo bache) ===")
_state["count"] = 0
try:
    retry_with_backoff(
        flaky_book_room, room="Focus", tier="pro", hours=3, member="Ana",
        max_retries=1, base_delay_ms=100,
    )
except ConnectionError as exc:
    print(f"ConnectionError final, tras agotar el tope: {exc}")

Qué esperar:

=== max_retries=1: se agota antes de recuperarse (el mismo bache) ===
    intento 1/1...
      fallo transitorio (ConnectionError): timeout de red simulado (intento 1) -- backoff modelado: 100ms (no se duerme de verdad)
ConnectionError final, tras agotar el tope: timeout de red simulado (intento 1)

Con solo una oportunidad, retry_with_backoff nunca llega al tercer intento —el que sí habría funcionado—, y relanza el ConnectionError real, exactamente como call_with_retries de agent-fundamentals M7 hacía: nunca oculta un fallo real detrás de un tope demasiado ajustado.

Ejecutado: un apagón largo — ni el backoff alcanza

_state["count"] = 0
OUTAGE_CALLS = 6

def flaky_book_room_long(room, tier, hours, member):
    _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)

print("=== apagón largo (6 llamadas caídas): max_retries=3 con backoff NO alcanza ===")
try:
    retry_with_backoff(
        flaky_book_room_long, room="Focus", tier="pro", hours=3, member="Ana",
        max_retries=3, base_delay_ms=100,
    )
except ConnectionError as exc:
    print(f"ConnectionError final: {exc}")
print("llamadas reales gastadas en ESTE run: 3 de 3 -- las 3 tocaron la tool caída")

Qué esperar:

=== apagón largo (6 llamadas caídas): max_retries=3 con backoff NO alcanza ===
    intento 1/3...
      fallo transitorio (ConnectionError): timeout de red simulado (intento 1) -- backoff modelado: 100ms (no se duerme de verdad)
    intento 2/3...
      fallo transitorio (ConnectionError): timeout de red simulado (intento 2) -- backoff modelado: 200ms (no se duerme de verdad)
    intento 3/3...
      fallo transitorio (ConnectionError): timeout de red simulado (intento 3) -- backoff modelado: 400ms (no se duerme de verdad)
ConnectionError final: timeout de red simulado (intento 3)
llamadas reales gastadas en ESTE run: 3 de 3 -- las 3 tocaron la tool caída

Este es el momento clave de toda la lección: retry_with_backoff, con backoff exponencial y todo, sigue sin poder recuperarse de una caída que dura más que su propio tope. Y aquí está el problema que la Lección 2 ya mostró, ahora con backoff de por medio pero sin resolverlo: si el siguiente usuario llega un segundo después, con su propio run, va a repetir exactamente esta misma secuencia de tres intentos fallidos — porque retry_with_backoff, igual que call_with_retries, no tiene memoria fuera de esta única llamada. El backoff mejora cuánto se tarda en rendirse dentro de un run; no evita que el siguiente run vuelva a intentarlo desde cero.


Errores comunes

  1. Usar time.sleep(delay_ms / 1000) en un bloque "Qué esperar" de esta guía. Rompería la reproducibilidad byte a byte que exige cada ejemplo, y agregaría segundos reales de espera a algo que se entiende igual de bien viendo el número calculado. En un sistema real conectado a una red de verdad, ese sleep sí va — aquí, nunca.

  2. Implementar el jitter con random.uniform(...). Es la técnica correcta en producción —evita que miles de clientes reintenten todos en el mismo instante—, pero introduce no-determinismo en un ejemplo que tiene que producir la misma salida cada vez. Si necesitas ilustrar jitter en un ejercicio propio, usa un valor fijo o una secuencia determinista (por ejemplo, una lista de offsets predefinida), y sé explícito de que en producción ese valor sería aleatorio de verdad.

  3. Capturar Exception en vez de retry_on en retry_with_backoff. Si el except fuera genérico, un error de validación —tier="premium", que nunca va a cambiar de resultado por reintentarlo— se reintentaría igual que un fallo de red real, desperdiciando intentos (y, en un sistema conectado, dinero) en algo que ya sabemos que no va a cambiar. Esta es exactamente la misma advertencia de agent-fundamentals M7, Lección 6.

  4. Pensar que un max_retries más alto "resuelve" el apagón largo del último ejemplo. Subir el tope pospone el problema, no lo resuelve: un apagón real puede durar minutos, y ningún max_retries razonable —sin convertirse en una espera absurda dentro de un solo run— cubre eso. La respuesta correcta no es "más reintentos dentro del run" — es la memoria entre runs que la Lección 4 construye.


Ejercicios

Ejercicio 1: Calcula el backoff acumulado (Fácil)

Sin ejecutar Python: con base_delay_ms=50 y tres reintentos fallidos antes de un cuarto intento exitoso, ¿cuánto backoff se calculó en total, sumando los tres? (Recuerda: el backoff del intento N usa compute_backoff_ms(N - 1, base_delay_ms), porque el primer intento, índice 0, no espera nada antes de sí mismo — el backoff se calcula después de que ese intento falla, antes del siguiente).

Ver solución

Los tres backoffs calculados son: compute_backoff_ms(0, 50) = 50, compute_backoff_ms(1, 50) = 100, compute_backoff_ms(2, 50) = 200. Suma: 50 + 100 + 200 = 350 milisegundos de backoff acumulado (modelado, nunca dormido) antes del cuarto intento, el que finalmente tiene éxito.

Ejercicio 2: Una tool que nunca se recupera, con backoff (Medio)

Escribe always_down_book_room(**kwargs), que siempre lanza ConnectionError("servicio permanentemente caído"), sin importar cuántas veces se llame. Ejecuta retry_with_backoff(always_down_book_room, max_retries=3, base_delay_ms=100) dentro de un try/except, y confirma que el ConnectionError final se relanza tras exactamente tres intentos, con el backoff creciendo en cada uno.

Ver solución
def always_down_book_room(**kwargs):
    raise ConnectionError("servicio permanentemente caído")

try:
    retry_with_backoff(always_down_book_room, max_retries=3, base_delay_ms=100)
except ConnectionError as exc:
    print(f"ConnectionError final: {exc}")

Salida esperada:

    intento 1/3...
      fallo transitorio (ConnectionError): servicio permanentemente caído -- backoff modelado: 100ms (no se duerme de verdad)
    intento 2/3...
      fallo transitorio (ConnectionError): servicio permanentemente caído -- backoff modelado: 200ms (no se duerme de verdad)
    intento 3/3...
      fallo transitorio (ConnectionError): servicio permanentemente caído -- backoff modelado: 400ms (no se duerme de verdad)
ConnectionError final: servicio permanentemente caído

Explicación: los tres intentos fallan con el mismo mensaje —always_down_book_room no tiene ningún estado interno que cambie entre llamadas—, y el backoff sigue duplicándose en cada vuelta (100, 200, 400) aunque, en este caso, ningún backoff del mundo iba a ayudar: el servicio está muerto de verdad, no saturado temporalmente. Esta es exactamente la distinción que la Lección 4 convierte en una decisión explícita: cuándo dejar de intentarlo del todo.

Ejercicio 3: ¿Por qué retry_with_backoff no debería tener un max_retries de 50? (Difícil)

agent-fundamentals M7 (Lección 6) ya explicó, para call_with_retries, por qué un tope enorme "para nunca perderme una recuperación" no es gratis. Aplica ese mismo razonamiento aquí, pero ahora con backoff exponencial de por medio: calcula cuánto backoff acumulado (la suma de todos los compute_backoff_ms) se generaría con max_retries=10 y base_delay_ms=100, sin llegar nunca a tener éxito. Explica, en una o dos frases, por qué ese número —aunque nunca se duerma de verdad en esta guía— es exactamente la razón por la que un sistema real jamás debería poner un max_retries tan alto sin un circuit breaker de por medio.

Ver solución
total_ms = sum(compute_backoff_ms(attempt, 100) for attempt in range(10))
print(f"backoff acumulado con max_retries=10: {total_ms}ms")

Salida esperada:

backoff acumulado con max_retries=10: 102300ms

102300 milisegundos son poco más de 100 segundos — más de un minuto y medio que, en un sistema real (donde ese backoff sí se duerme de verdad), un usuario tendría que esperar antes de que el run finalmente se rinda, si la tool nunca se recupera. Y eso es solo el costo de un usuario: si diez usuarios llegan durante ese mismo apagón, cada uno paga esos mismos ~100 segundos de espera acumulada, por separado, porque —tal como confirmó el ejemplo del apagón largo de esta lección— retry_with_backoff no tiene memoria entre runs. Esta es, con números concretos, la razón exacta por la que ningún max_retries —por más generoso que sea— resuelve un apagón sostenido: el backoff acotado protege dentro de un run; lo que hace falta para protegerse entre runs es la memoria que la Lección 4 construye a continuación.


Resumen y siguiente paso

  • retry_with_backoff(fn, *args, max_retries=3, base_delay_ms=100, retry_on=(ConnectionError,), **kwargs) escala call_with_retries de agent-fundamentals M7: mismo criterio de qué se reintenta y qué no, mismo tope duro, con la pieza que esa lección dejó pendiente — el backoff exponencial (delay = base * 2**attempt), calculado y mostrado, nunca dormido de verdad.
  • Lo ejecutamos con flaky_book_room: un bache breve se recupera dentro del tope, con el backoff creciendo en cada intento (100ms, 200ms, 400ms...); un max_retries=1 se agota antes de tiempo; y un apagón largo (6 llamadas caídas) demuestra que ni el backoff alcanza cuando la caída dura más que el tope del run.
  • Confirmamos, con números, por qué un max_retries enorme no es la solución: el backoff acumulado crece exponencialmente, y aun así ningún tope dentro de un run resuelve el problema de fondo — la falta de memoria entre runs.
  • Honestidad explícita: esta guía nunca duerme el backoff de verdad (time.sleep()) ni usa random para el jitter — el patrón se entiende igual de bien calculado; la versión con jitter aleatorio real está en resilience-and-reliability-patterns-guide.

Siguiente lección: 04 — El patrón circuit breaker. Construimos la memoria que le falta a todo lo visto hasta ahora: un objeto que recuerda, entre runs, que una tool lleva fallando, y deja de llamarla hasta que un chequeo periódico confirma que revivió.


Recursos adicionales

  1. Python — Excepciones incorporadas (ConnectionError) — La excepción estándar reusada de agent-fundamentals M7 para representar un fallo transitorio.
  2. Anthropic — Errors — Referencia de códigos de error de la API de Claude; una guía real de qué tipos de fallo suelen ser transitorios (por ejemplo, 529 overloaded_error) frente a los que no lo son — la misma distinción que retry_on codifica aquí.
  3. resilience-and-reliability-patterns-guide (Módulo 3, "Reintentos, backoff y jitter") — La versión completa del backoff exponencial, con jitter aleatorio real, medida a fondo contra un retry storm de miles de clientes simulados — la profundidad que este módulo cita en vez de repetir.
  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.