Módulo 5: Reliability at Scale
Retry strategies e idempotency
Descripción
Circuit breakers protegen del downstream completamente roto. Pero la mayoría de fallos son transitorios: un blip de red de 200ms, un 429 que se resuelve en segundos, un 503 que pasa con el siguiente request. Para esos casos, retry es la herramienta correcta — solo si lo haces bien.
Retry mal hecho es peor que no retry: agresivo y sin límites te quema dinero (cada retry al LLM cuesta tokens), sin idempotency duplica side-effects (manda el email dos veces, cobra dos veces), sin backoff sincronizado entre instancias amplifica picos de carga.
En esta cápsula vas a aprender el set completo: cuándo reintentar, cuándo no, cómo hacerlo con backoff que no martillee, idempotency keys que evitan duplicados, y dead letter queues que sirven como diagnóstico, no como cementerio.
Al terminar vas a poder:
- Decidir cuándo reintentar (errors transitorios) vs cuándo no (errors permanentes)
- Implementar exponential backoff con jitter correcto
- Diseñar idempotency keys que sobreviven a reintentos
- Configurar dead letter queues con análisis automatizado
Cuándo reintentar (cuándo no)
Regla simple: retry solo si el error es transitorio.
| Error | Transitorio | Acción |
|---|---|---|
Connection reset, connection refused | ✅ | Retry con backoff |
Timeout | ✅ | Retry con backoff |
HTTP 502/503/504 | ✅ | Retry con backoff |
HTTP 429 (rate limit) | ✅ | Retry siguiendo Retry-After |
HTTP 500 | ⚠️ Depende | Retry 1 vez; si sigue, no insistir |
HTTP 4xx (except 429) | ❌ | NO retry (request inválido) |
Auth error (401/403) | ❌ | NO retry (config mal) |
Validation error | ❌ | NO retry (input malo) |
Content filter (OpenAI) | ❌ | NO retry (prompt inaceptable) |
Para AI: si el LLM responde con content == "" o finish_reason == "content_filter", NO retry. Es la respuesta correcta del modelo.
Exponential backoff: por qué exponencial
Si tu servicio reintenta cada 100ms, 10 instancias × 10 retries/s = 100 requests/s hacia el provider que ya está caído. Amplificas el problema.
Exponential backoff: cada retry espera doble del anterior.
intento 1: espera 1s
intento 2: espera 2s
intento 3: espera 4s
intento 4: espera 8s
Esto da tiempo al downstream a recuperarse. Para LLM providers, hasta 60s de espera total es razonable.
Implementación básica
import time
import random
def with_exponential_backoff(func, max_retries=3, base_delay=1.0, max_delay=60.0):
for attempt in range(max_retries + 1):
try:
return func()
except RetriableError as e:
if attempt == max_retries:
raise
delay = min(max_delay, base_delay * (2 ** attempt))
time.sleep(delay)
Jitter: el detalle crucial
Sin jitter, todas tus instancias retryean al mismo tiempo:
t=0 : 10 instancias intentan, todas fallan
t=1s : 10 instancias retryean al mismo tiempo → segundo pico
t=3s : 10 instancias retryean al mismo tiempo → tercer pico
Esto se llama "thundering herd". Lo evitas con jitter: agregar aleatoriedad a cada delay.
def with_jittered_backoff(func, max_retries=3, base_delay=1.0, max_delay=60.0):
for attempt in range(max_retries + 1):
try:
return func()
except RetriableError:
if attempt == max_retries:
raise
# Full jitter: delay ∈ [0, base * 2^attempt]
delay = random.uniform(0, min(max_delay, base_delay * (2 ** attempt)))
time.sleep(delay)
Existen variantes (decorrelated jitter, equal jitter) — para 95% de casos, full jitter es lo que quieres.
Respetar Retry-After
Cuando un provider devuelve 429 con Retry-After: 30, te está diciendo cuánto esperar. Respétalo. Si reintentas antes, vuelves a recibir 429 y peor: el provider podría extender tu cool-down.
import httpx
def call_with_retry_after(url: str, max_retries=3):
for attempt in range(max_retries + 1):
r = httpx.get(url)
if r.status_code == 429 and attempt < max_retries:
retry_after = float(r.headers.get("Retry-After", "1"))
time.sleep(retry_after + random.uniform(0, 1)) # + jitter
continue
return r
Cuándo combinar retry con circuit breaker
Retry y circuit breaker no son alternativos — son complementarios.
Patrón recomendado:
def call_protected(prompt: str):
# Capa exterior: circuit breaker
return circuit.call(
lambda: with_retry( # Capa interior: retry
lambda: openai_client.chat.completions.create(...)
)
)
- Circuit breaker decide "¿debo intentar el provider en general?"
- Retry decide "este request específico falló, ¿reintento o me rindo?"
Cuando circuit está OPEN, retry no se ejecuta (rechazo inmediato → fallback). Cuando circuit está CLOSED, retry maneja los blips transitorios sin abrir circuit innecesariamente.
Idempotency: el problema y la solución
Problema: si reintentas un request que ya se ejecutó parcialmente, puedes duplicar side-effects.
Ejemplos de side-effects en AI:
- Mandar email al usuario con la respuesta del LLM (un retry = dos emails)
- Cobrar al cliente por tokens usados (un retry = doble cobro)
- Logging del request para auditoría (un retry = dos logs, métricas infladas)
- Almacenar el resultado en DB (un retry = dos registros, posiblemente con conflictos)
Solución: idempotency keys. Cada request lleva un ID único. Tu sistema verifica si ya procesó ese ID antes de hacer side-effects.
Patrón básico
import redis
r = redis.Redis()
def process_with_idempotency(idempotency_key: str, work):
# 1. Intenta marcar el key como "in progress"
acquired = r.set(
f"idem:{idempotency_key}",
"in_progress",
nx=True, # solo set si no existe
ex=300, # expiry 5min
)
if not acquired:
# Ya existe — devuelve resultado previo o espera
existing = r.get(f"idem:{idempotency_key}:result")
if existing:
return existing
raise Exception("Request en progreso, intenta en unos segundos")
# 2. Procesa
try:
result = work()
r.set(f"idem:{idempotency_key}:result", result, ex=3600)
r.delete(f"idem:{idempotency_key}") # quita el "in progress"
return result
except Exception:
r.delete(f"idem:{idempotency_key}") # libera para retry
raise
En tu API
@app.post("/api/chat")
def chat(req: ChatRequest, idempotency_key: str | None = Header(None)):
key = idempotency_key or str(uuid4())
result = process_with_idempotency(key, lambda: call_llm(req.prompt))
return {"text": result, "idempotency_key": key}
El cliente puede pasar Idempotency-Key: <uuid> en el header. Si el request se queda sin respuesta (network blip), reintenta con el mismo key y tu sistema devuelve el resultado original sin reprocesar.
Idempotency para queue-based processing
En el flujo de M5-03 (queue + worker), tienes dos lugares donde puede haber duplicación:
- API encola dos veces (cliente reintenta antes de recibir el 202)
- Worker procesa dos veces (visibility timeout, restart)
Solución para (1): idempotency en encolar
@app.post("/api/chat", status_code=202)
def enqueue_chat(req: ChatRequest, idempotency_key: str | None = Header(None)):
key = idempotency_key or str(uuid4())
# Verifica si ya encolamos este key
existing_job = r.hget("idem_to_job", key)
if existing_job:
return {"job_id": existing_job.decode(), "status": "queued"}
job_id = str(uuid4())
r.hset("idem_to_job", key, job_id)
r.expire("idem_to_job", 3600)
r.lpush("llm-jobs", json.dumps({...}))
return {"job_id": job_id, "status": "queued"}
Solución para (2): worker chequea antes de procesar
def worker_loop():
while True:
payload = dequeue()
if not payload:
continue
# Check si ya procesamos
existing = r.hget("job_results", payload["job_id"])
if existing:
existing_data = json.loads(existing)
if existing_data.get("status") in ("completed", "failed"):
continue # ya procesado, ignorar
# Marca como "processing" atomicamente
was_set = r.hsetnx("job_results", payload["job_id"],
json.dumps({"status": "processing", "worker_id": WORKER_ID}))
if not was_set:
continue # otro worker tomó este job
try:
process(payload)
except Exception as e:
# ...
Dead letter queues: diagnóstico, no cementerio
Cuando un job falla N veces (e.g., 5), no quieres reintentarlo infinitamente. Lo mandas a una dead letter queue (DLQ). Pero la DLQ no debe ser un agujero negro.
Patrón correcto
MAX_RETRIES = 5
def worker_with_dlq():
while True:
payload = dequeue()
attempts = payload.get("attempts", 0)
try:
result = process(payload)
store_result(payload["job_id"], result)
except Exception as e:
attempts += 1
if attempts >= MAX_RETRIES:
# → DLQ
r.lpush("dlq:llm-jobs", json.dumps({
**payload,
"final_error": str(e),
"final_traceback": traceback.format_exc(),
"moved_to_dlq_at": time.time(),
"attempts": attempts,
}))
# ALERTA
alert_team(f"Job {payload['job_id']} fue a DLQ tras {attempts} intentos")
else:
# Re-enqueue con backoff
delay = min(60, 2 ** attempts)
time.sleep(delay)
payload["attempts"] = attempts
r.lpush("llm-jobs", json.dumps(payload))
Procesar la DLQ
DLQ con 1000 jobs sin tocar = problema. DLQ debe ser operada:
- Alerta cuando crece (>10 items, o crecimiento >X/hr)
- Dashboard mostrando jobs en DLQ con categorización (por tipo de error)
- Replay manual después de fix: tomar jobs de DLQ y re-encolar a la queue principal
- Análisis automatizado: agrupar por similar root cause, expone patterns
# Replay simple
def replay_dlq():
while True:
item = r.brpop("dlq:llm-jobs", timeout=5)
if not item:
break
payload = json.loads(item[1])
payload["attempts"] = 0 # reset counter
r.lpush("llm-jobs", json.dumps(payload))
print(f"Replayed {payload['job_id']}")
Trampas comunes
Trampa 1 — Retry de un 400. "Bad Request" significa que el cliente mandó algo inválido. Retry no lo arregla. Solo retry para 5xx, 429, network errors, timeouts.
Trampa 2 — Backoff sin jitter. 10 instancias retryean simultáneamente, tu provider se cae más fuerte. Siempre jitter.
Trampa 3 — Max retries infinito.
Sin límite, un job poisoned puede correr para siempre. Siempre max_retries finito (3-5 típicamente).
Trampa 4 — Idempotency key generado en cliente sin garantías. Si el cliente genera un UUID nuevo en cada retry, no es idempotente. El idempotency key debe ser estable a través de retries del mismo cliente.
Trampa 5 — DLQ sin alerta ni monitoring. Jobs van a DLQ silenciosamente. Llegas el lunes a 5000 jobs muertos. Alertas obligatorias.
Trampa 6 — Retry en círculo entre servicios. Servicio A llama B con retry. B llama C con retry. C falla. Tu retry total: 3 × 3 × 3 = 27 requests por cada request original. No tener retry en cadena no coordinado. Retry en una sola capa, idealmente la más cercana al downstream.
Ejercicio
Tu sistema procesa pedidos de análisis con LLM. Cada pedido implica:
- Llamar al LLM con el contenido del usuario (puede fallar transitoriamente)
- Guardar resultado en DB
- Mandar email al usuario con el resultado
Diseña retry + idempotency:
- ¿Para cuáles de los 3 pasos aplicas retry? ¿Con qué parámetros?
- ¿Cómo evitas que un retry mande el email dos veces?
- ¿Qué pasa si el LLM call funciona pero el email falla?
- ¿Cuándo va a DLQ?
Ver solución
- Retry por paso:
- LLM call: retry 3 veces con exponential backoff + full jitter, base 2s, max 60s. NO retry en 4xx, content_filter, content vacío.
- DB save: retry 3 veces con backoff corto (100ms-1s). Casi siempre transitorios (timeouts breves).
- Email: retry 3 veces con backoff (1s-10s). 4xx del email provider NO retry; 5xx y timeouts sí.
- Idempotency:
- El job tiene un
job_idúnico - Antes de mandar email, marca en DB:
UPDATE jobs SET email_sent_at = NOW() WHERE id = ? AND email_sent_at IS NULL— solo procede sirowcount == 1 - Si retry intenta mandar de nuevo, el rowcount es 0 y skip
- El job tiene un
- LLM OK pero email falla:
- DB tiene el resultado (paso 2 OK)
- Email tiene status "failed",
email_attempts = 3 - Job total marca
partial_success: usuario puede ver resultado en la app aunque no recibió email - Alerta a soporte para investigar el email
- DLQ:
- LLM call falla 3 veces consecutivas (con backoff entre cada uno)
- Después de 3 intentos, job va a DLQ con el último error
- Equipo de oncall ve alerta, investiga (¿prompt malicioso? ¿outage de OpenAI? ¿bug?), decide replay o descartar
Resumen
Aprendiste:
- ✅ Cuándo retry (transitorios) vs cuándo no (4xx, content_filter, validation)
- ✅ Exponential backoff con full jitter (evita thundering herd)
- ✅ Respetar
Retry-Aftercuando el provider lo manda - ✅ Combinar retry (request individual) con circuit breaker (downstream completo)
- ✅ Idempotency keys: stable across retries, almacenados en Redis con expiry
- ✅ Idempotency en queue + worker: dedup en encolar y en procesar
- ✅ Dead letter queues con alertas, dashboards, replay manual
Checkpoint: si puedes diseñar retry para un pipeline que tiene LLM call + DB + email, sin duplicar side-effects, estás listo.
Siguiente cápsula
07 — Graceful degradation por niveles. Tu sistema ya maneja errors, latencia, rate limits, retry, circuit breakers. Pero cuando todo se vuelve a la vez, ¿cómo respondes? Vamos a diseñar niveles de degradation que mantienen al sistema parcialmente funcional incluso en condiciones extremas.
Recursos
- Exponential Backoff and Jitter — AWS Blog — el write-up canónico de jitter.
- Tenacity (Python) — librería retry production-ready.
- Stripe API — Idempotency — referencia de cómo lo hace Stripe.
- Designing for Failure (Adrian Cockcroft) — patterns de Netflix.
- AWS SQS — Visibility timeout — para idempotency en colas.