Módulo 3: Reintentos, backoff y jitter
8. Proyecto: reintentos correctos para las llamadas de Mercado
Descripción
Este es el cierre práctico del módulo. Las siete lecciones anteriores te dieron las piezas una por una —por qué reintentar, el retry storm medido, el backoff exponencial, el jitter, el presupuesto, y cuándo no reintentar—. Ahora te toca tejerlas en un solo entregable que demuestra la tesis central del módulo con tus propias manos: tomar la llamada cruda de orders a payments, ponerle el retry completo —transitorio + backoff + jitter + presupuesto—, y probar con la simulación medida el antes y el después: la tormenta contra el control. No es una herramienta nueva; es el módulo entero condensado en un helper de reintentos construido y medido.
Lo que de verdad se evalúa aquí no es que consigas el 100% de éxito —eso es el resultado— sino que puedas demostrar el antes y el después con números. Cualquiera puede escribir un retry con backoff. El entregable que importa es la evidencia: la llamada sin control enterrando a payments (289.6x de carga, 3% de éxito), la misma llamada con el retry completo recuperándose (1.5x, 100%), y la justificación de qué pieza resuelve qué parte del problema. Un alumno que entrega el helper sin la medición del antes no demostró que entendió qué problema resolvió; uno que entrega la tormenta, el control y la explicación de cada decisión demostró que ya no dejará que una llamada de red se convierta en un incidente.
Conexión con el módulo: esta lección es el examen práctico del módulo 3 y su cierre. Recoge el retry que fuiste construyendo pieza por pieza (el TransientError de la lección 2, el backoff de la 4, el jitter de la 5, el max_attempts y retry_on de las lecciones 6 y 7) y la simulación de payments (lecciones 3 a 6), y los presenta como un proyecto con entregables y solución de referencia. Después del enunciado, cierra el módulo y te apunta al módulo 4: el retry que entregas aquí tiene un peligro escondido —reintentar el charge puede duplicar el cobro—, y taparlo con idempotency_key es exactamente lo que el módulo 4 construye sobre lo que dejas aquí.
Analogía: el reporte del ingeniero que apagó el incendio
Piensa en un ingeniero de bomberos al que llaman para explicar por qué un edificio se incendió y cómo lo evitó en el edificio de al lado. Su trabajo no termina cuando el segundo edificio queda a salvo; termina cuando entrega el reporte que demuestra qué falló y qué lo arregló: la foto del primer edificio en llamas (el problema), la foto del segundo intacto (la solución), y la lista de qué medida —los rociadores, las puertas cortafuego, las salidas— detuvo qué parte del fuego. Un ingeniero que solo dice "el segundo no se quemó" no prueba nada; uno que entrega el antes, el después y la explicación causal demuestra que entendió el incendio y sabe prevenirlo.
Tu proyecto es ese reporte. El primer edificio en llamas es la llamada sin control desatando el retry storm; el segundo intacto es la misma llamada con el retry completo; y la lista de medidas es la justificación de qué pieza —backoff, jitter, presupuesto, retry_on— resuelve qué parte del problema. Entregar el reporte completo, no solo "quedó en 100%", es lo que separa "cambié el código" de "entendí la tormenta y sé prevenirla". Y como todo buen reporte de incendio, el tuyo termina señalando el riesgo que todavía queda —el cobro duplicado— y a quién le toca resolverlo (el módulo 4).
El proyecto: enunciado formal
Tu tarea es tomar la llamada de orders a payments sin protección y dejarla con reintentos correctos, medidos y justificados. Partes de este punto de partida —la llamada cruda, sin nada—:
# orders_naive.py — orders cobra sin ninguna proteccion.
def checkout(order_id, amount_cents):
# Si payments falla, esto lanza. Si el llamador reintenta de inmediato,
# y muchos llamadores lo hacen a la vez, nace el retry storm.
return payments.charge(order_id, amount_cents)
Debes:
- Construir el helper
retrycompleto con las cuatro piezas del módulo: reintenta solo errores transitorios (retry_on), con backoff exponencial (base * 2^intento), full jitter (uniform(0, techo)), y presupuesto local (max_attempts). Firma sugerida:retry(fn, *, max_attempts, base, cap, retry_on). - Envolver la llamada de
orderscon eseretry, eligiendo parámetros justificados para el caso de un cobro (piensa: ¿max_attemptsalto o bajo? ¿qué errores van enretry_on? ¿reintentar unchargees seguro?). - Medir el antes y el después con la simulación de
paymentsrecuperándose de un apagón: la política sin control ("none") contra la política con backoff + jitter, mostrando pico de req/s, amplificación de carga y success_rate lado a lado. - Escribir la justificación: una tabla que asocie cada pieza del
retrycon la parte del problema que resuelve, y una nota honesta sobre el riesgo que queda (el cobro duplicado) y por qué su solución es el módulo 4.
Entregables
- El helper
retry(retry.py) con las cuatro piezas y una firma limpia. ordersenvuelto (orders_resilient.py) con los parámetros elegidos y comentados.- La tabla antes/después con la salida real de la simulación (semilla fija).
- La justificación: qué pieza resuelve qué, y la nota sobre el peligro del cobro duplicado.
Analiza tu resultado antes de verlo resuelto
Antes de mirar la solución de referencia, intenta el proyecto y hazte estas preguntas sobre tu resultado. ¿Tu retry reintenta solo los errores transitorios, o se traga también los 400 y los 401? Si un charge da timeout de lectura, ¿tu diseño puede duplicar el cobro —y lo reconociste en la justificación—? ¿Elegiste un max_attempts bajo (2-3) y sabes argumentar por qué, con los rendimientos decrecientes de la lección 2 y la amplificación de la lección 6? ¿Tu jitter es full (uniform(0, techo)) o dejaste un backoff determinista que se sincroniza? Y la más importante: ¿tu medición muestra las dos filas —la tormenta y el control— con la misma semilla, para que la comparación sea legítima? Si alguna respuesta te incomoda, esa es justo la parte que la solución de referencia te va a aclarar.
Solución de referencia
1. El helper retry completo
# retry.py — el retry del modulo 3: transitorio + backoff + jitter + presupuesto.
import random
import time
class TransientError(Exception):
"""Error momentaneo que vale la pena reintentar: timeout, 503, 429, red."""
def retry(fn, *, max_attempts=3, base=0.5, cap=10.0, retry_on=(TransientError,)):
"""Reintenta fn() con backoff exponencial + full jitter, solo ante retry_on.
- max_attempts: intentos totales (incluye el primero). BAJO a proposito.
- base, cap: techo de espera = min(cap, base * 2^(intento-1)).
- jitter: la espera real es uniform(0, techo) (full jitter).
- retry_on: SOLO estos errores se reintentan; el resto se propaga ya."""
attempt = 0
while True:
attempt += 1
try:
return fn() # exito: devuelve y termina
except retry_on:
if attempt >= max_attempts: # presupuesto agotado: rendirse
raise
ceiling = min(cap, base * (2 ** (attempt - 1)))
time.sleep(random.uniform(0, ceiling)) # full jitter, no el valor exacto
# nota: cualquier excepcion FUERA de retry_on (400, 401, 422) no se
# captura aqui y se propaga de inmediato -> no reintentamos lo permanente.
Las cuatro piezas están en cuatro lugares precisos: retry_on en el except (solo lo transitorio); base * 2 ** (attempt - 1) (backoff exponencial); random.uniform(0, ceiling) (full jitter); y if attempt >= max_attempts (presupuesto local). Todo lo que no es un TransientError —un 400, un ValidationError— ni siquiera entra al except: se propaga en el acto, cumpliendo la primera regla de la lección 7.
2. orders envuelto, con parámetros justificados
# orders_resilient.py — orders cobra con reintentos correctos para un COBRO.
from retry import retry, TransientError
def checkout(order_id, amount_cents):
# max_attempts BAJO (2): cobrar es peligroso de reintentar (lecc. 7) y los
# rendimientos decrecen rapido (lecc. 2). Un reintento, y nos rendimos.
# retry_on solo transitorios: un 400 de validacion NO se reintenta.
# base pequena (0.3s) para no castigar la latencia del usuario en el caso bueno.
return retry(
lambda: payments.charge(order_id, amount_cents),
max_attempts=2,
base=0.3,
cap=5.0,
retry_on=(TransientError,), # timeout / 503 / 429 / error de red
)
# PELIGRO CONOCIDO: si el charge dio timeout de LECTURA (payments si cobro,
# pero la respuesta se perdio), este reintento DUPLICA el cobro. La solucion
# es una idempotency_key que payments deduplique -> Modulo 4.
Las decisiones, justificadas: max_attempts=2 (bajo) porque cobrar es la operación más peligrosa de reintentar y los reintentos rinden poco tras el primero; retry_on solo transitorios para no reintentar un 400; base=0.3 pequeña para no sumar latencia perceptible al usuario en el caso normal. Y el comentario PELIGRO CONOCIDO es parte del entregable, no un adorno: nombra el riesgo que el retry no puede resolver solo.
3. La medición: antes y después
Corremos la simulación de payments recuperándose de un apagón de 5 s (capacidad 50/s, 40 checkouts/s llegando), con la política sin control y con backoff + jitter, misma semilla:
# measure.py — el antes (storm) y el despues (control), lado a lado.
for label, policy in [("SIN control (storm)", "none"),
("CON retry completo", "jitter")]:
m = run_storm(policy=policy) # misma simulacion, misma semilla 42
print(label, m["peak_req_s"], m["amplification"], m["success_rate"])
Qué esperar. En mi máquina (Python 3.14, semilla 42), el antes y el después:
escenario | pico req/s | req/s @30s | total reqs | amplif. | success
---------------------------------------------------------------------------------
sin backoff (storm) | 23,120 | 11,708 | 695,043 | 289.6x | 3%
backoff + jitter | 179 | 41 | 3,695 | 1.5x | 100%
El reporte del incendio, en dos filas. Antes (la llamada cruda del punto de partida, reintentada sin control): payments recibe 289.6 veces su carga, con un pico de 23.120 req/s, y solo el 3% de los checkouts termina bien —el apagón de 5 s se convirtió en un colapso de un minuto—. Después (la misma llamada con el retry completo): la carga baja a 1.5x, el pico a 179 req/s, y el éxito sube a 100%. La única diferencia entre las dos filas es cómo reintenta el cliente. Ese contraste —289.6x contra 1.5x, 3% contra 100%— es tu evidencia de que entendiste el módulo.
4. La justificación: qué pieza resuelve qué
Pieza del retry | Parte del problema que resuelve | Evidencia en el módulo |
|---|---|---|
retry_on (solo transitorio) | Evita reintentar errores permanentes (400, 401) que nunca cambian | Lecc. 7: reintentar un 400 = 5.000 llamadas, 0 éxitos |
| Backoff exponencial | Espacia los reintentos, baja la frecuencia de golpeo bajo el umbral de meltdown | Lecc. 4: amplificación de 289.6x → 1.4x |
| Full jitter | Des-sincroniza a los clientes que fallan a la vez (thundering herd) | Lecc. 5: 500 reintentos en 1 tick → repartidos en 11 |
max_attempts (presupuesto) | Acota el daño contra una dependencia que no vuelve | Lecc. 6: sin límite = 6.4x de carga inútil sobre un muerto |
Y la nota honesta que cierra el reporte: el retry completo resolvió la tormenta, pero dejó vivo un peligro. Cada reintento del charge es un cobro duplicado en potencia —si el intento original en realidad cobró (timeout de lectura) y el reintento cobra otra vez, el comprador paga el doble—. El retry no puede resolver esto solo, porque no tiene forma de saber que su reintento es el "mismo" cobro que el original. La solución es una idempotency_key que payments reconozca y deduplique, y construirla es el módulo 4. Reintentar bien (este módulo) y reintentar seguro (módulo 4) son las dos mitades de blindar un cobro.
Errores comunes en el proyecto
Entregar el "después" sin el "antes". Qué pasa: se muestra la fila de 100% de éxito y se declara terminado. Por qué pasa: el resultado bueno se siente como la prueba. Cómo detectarlo: si tu reporte no tiene la fila de la tormenta (289.6x, 3%), no demostraste qué problema resolviste —solo que tu código funciona—. Cómo corregirlo: mide las dos políticas con la misma semilla. El valor pedagógico está en el contraste, no en el número final. Sin el antes, el después no significa nada.
max_attempts alto en el cobro. Qué pasa: se pone max_attempts=5 "para maximizar el éxito del checkout". Por qué pasa: más intentos = más éxito, en la intuición de la lección 2. Cómo detectarlo: para un charge, cada reintento extra es un cargo duplicado extra en potencia, y los rendimientos ya decrecieron. Cómo corregirlo: en la operación más peligrosa del sistema, max_attempts va lo más bajo posible (1 o 2). La prudencia aquí vale más que el último punto de éxito.
Reintentar el charge sin nombrar el peligro del cobro duplicado. Qué pasa: se entrega el retry sobre charge como si fuera seguro, sin mención del riesgo. Por qué pasa: en la simulación, charge no duplica visiblemente (no modelamos el efecto monetario en la tormenta). Cómo detectarlo: pregúntate qué pasa si el intento original cobró y el reintento también. Cómo corregirlo: la justificación debe incluir la nota del cobro duplicado y remitir al módulo 4. Reintentar un cobro sin reconocer ese riesgo es exactamente el error que la lección 7 mide en dinero.
Ejercicios de extensión
Ejercicio 1 — El retry_on completo. Amplía retry_on a la lista realista de errores transitorios que reintentarías contra payments, y nombra dos errores que deliberadamente dejarías fuera. Justifica cada exclusión.
Ver solución
Una lista retry_on realista para payments:
retry_on = (
TimeoutError, # timeout de conexion o lectura
ConnectionError, # la red se cayo, la conexion se cerro
ServiceUnavailable, # 503: payments se protegio bajo carga
TooManyRequests, # 429: payments pide bajar el ritmo (respetar Retry-After)
)
Dos exclusiones deliberadas:
ValidationError/400— la petición es inválida (monto negativo, campo faltante). Reintentarla da el mismo error; hay que corregir la petición, no repetirla. Excluida por la primera regla de la lección 7.InsufficientFunds/422— la tarjeta no tiene fondos. Es una decisión de negocio, no un hipo del sistema; reintentar en 2 segundos no le pone dinero a la tarjeta. Excluida porque es permanente en el horizonte del reintento.
El matiz del 429: se reintenta, pero es el único 4xx en la lista, y solo porque el servidor pide explícitamente que se reintente más tarde. El criterio no es el código HTTP por sí mismo, sino "¿la causa es un estado momentáneo del sistema (reintentar) o un problema con la petición (no reintentar)?".
Ejercicio 2 — Sensibilidad a los parámetros. Corre la simulación con base=0.1 (backoff muy corto) y con base=2.0 (backoff largo), ambos con jitter, contra el apagón de 5 s. Predice y luego verifica: ¿cómo cambian la amplificación y el success_rate? ¿Hay un punto en que un backoff demasiado corto se acerca a la tormenta?
Ver solución
Predicción y verificación (los números exactos dependen de correrlo, pero la tendencia es robusta):
base=0.1(backoff corto): las esperas son 0.1, 0.2, 0.4… s —muy cortas—. Los reintentos vuelven casi tan rápido como en la política"none", así que la frecuencia de golpeo se mantiene alta y la carga se acerca (aunque no llega) al régimen de tormenta: más amplificación que conbase=1.0, y si el backoff es lo bastante corto, puede empujar la carga por encima del umbral de meltdown y bajar el success_rate. Un backoff demasiado corto es apenas mejor que no tener backoff.base=2.0(backoff largo): las esperas son 2, 4, 8… s. La carga baja muchísimo (amplificación mínima) ypaymentsse recupera cómodo, pero el costo es la latencia: los checkouts que necesitan reintentar esperan mucho más antes de completarse. El success_rate se mantiene alto, pero el usuario percibe más lentitud.
La lección: base es un dial entre "carga sobre la dependencia" y "latencia para el usuario". Muy corto reintroduce la tormenta; muy largo castiga la experiencia. El valor sensato (0.3-1.0 s para muchos servicios) vive en el medio, y la simulación es exactamente la herramienta para encontrarlo antes de ir a producción.
Ejercicio 3 — Agrega el presupuesto agregado. El proyecto usa presupuesto local (max_attempts). Esboza cómo agregarías un presupuesto agregado (token bucket, lección 6) a la simulación, y predice cómo cambiaría la fila de la tormenta si el token bucket limitara los reintentos al 20% del tráfico.
Ver solución
Esbozo: agregar un RetryBudget compartido por todos los clientes de la simulación. Cada checkout que se completa con éxito llama a budget.on_success() (repone ratio=0.2 fichas); antes de programar un reintento, el cliente llama a budget.can_retry() —si devuelve False (cubo vacío), el checkout se rinde en vez de reintentar—. El token bucket vive en el nivel de la flota, no del cliente.
Predicción para la tormenta con presupuesto agregado al 20%: durante el apagón, no hay éxitos, así que el cubo no se repone; los primeros reintentos vacían las fichas acumuladas y, a partir de ahí, can_retry() devuelve False para casi todos. El resultado: la amplificación de la tormenta se desploma de 289.6x a algo cercano a 1.2x (el intento original más el ~20% de reintentos que el presupuesto autoriza), incluso con la política "none". Es decir: el token bucket agregado desactiva la tormenta aunque los clientes reintenten sin backoff, porque corta los reintentos en seco cuando el sistema deja de tener éxitos. Es la red de seguridad de último recurso: el backoff y el jitter dan la forma correcta a la carga, y el presupuesto agregado le pone un techo que ninguna política de cliente puede superar. Combinar los tres es lo que hace a un sistema robusto ante lo que no anticipaste.
Resumen y cierre del módulo
Con este proyecto cerraste el módulo 3. Construiste el helper retry completo —transitorio + backoff exponencial + full jitter + presupuesto local— con cada pieza en su lugar preciso, y lo aplicaste a la llamada de orders a payments con parámetros justificados para un cobro (max_attempts bajo, retry_on solo transitorios). Y demostraste el antes y el después con la simulación medida: la llamada sin control desatando la tormenta (289.6x de carga, 3% de éxito), y la misma llamada con el retry completo recuperándose (1.5x, 100%). Entregaste el reporte del incendio: el problema, la solución, la lista de qué pieza resuelve qué, y la nota honesta sobre el peligro que queda.
Mirando el módulo entero: empezaste con una decisión que el timeout del módulo 2 te dejó en la mano —reintentar o no—. Aprendiste que reintentar funciona porque las fallas suelen ser transitorias (lección 2), pero que reintentar sin control desata un retry storm que entierra a la dependencia (lección 3). Desactivaste la tormenta con tres piezas: backoff para espaciar (lección 4), jitter para des-sincronizar (lección 5), presupuesto para no martillar a un muerto (lección 6). Y afilaste la disciplina de cuándo no reintentar: lo permanente (puro desperdicio) y lo no idempotente (cobro duplicado) (lección 7). Todo medido, nada de memoria.
El módulo termina señalando su propia deuda. El retry que construiste es poderoso y, en la misma medida, peligroso: cada reintento de un charge puede cobrar dos veces. La única forma de reintentar un cobro con tranquilidad es hacerlo idempotente —una idempotency_key que garantice que dos intentos del mismo cobro cobren una sola vez—, y eso es el módulo 4. Reintentar bien (lo que acabas de aprender) y reintentar seguro (lo que sigue) son las dos mitades de blindar la operación más delicada de Mercado. Nos vemos ahí.
Recursos
- "Timeouts, retries, and backoff with jitter" — Marc Brooker, Amazon Builders' Library — el texto de referencia que reúne todas las piezas de este proyecto en un solo lugar, desde la experiencia de operar servicios a escala.
- "Exponential Backoff And Jitter" — Marc Brooker, AWS Architecture Blog — la medición original que justifica el full jitter que usaste en el helper.
- Release It! (2ª edición) — Michael Nygard — los patrones de estabilidad (reintentos, circuit breaker, bulkhead) que este módulo empieza y los siguientes continúan; la fuente canónica del ecosistema.
- Documentación de Tenacity y resilience4j — Retry — las librerías de producción (Python y JVM) que implementan el
retryque aquí construiste a mano; úsalas en un sistema real en vez de reinventarlas, ahora que entiendes qué hacen por dentro. - Stripe — Idempotent requests — el anticipo del módulo 4: cómo una pasarela real hace seguro reintentar un cobro con una
idempotency_key.