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.

ErrorTransitorioAcción
Connection reset, connection refusedRetry con backoff
TimeoutRetry con backoff
HTTP 502/503/504Retry con backoff
HTTP 429 (rate limit)Retry siguiendo Retry-After
HTTP 500⚠️ DependeRetry 1 vez; si sigue, no insistir
HTTP 4xx (except 429)NO retry (request inválido)
Auth error (401/403)NO retry (config mal)
Validation errorNO 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:

  1. API encola dos veces (cliente reintenta antes de recibir el 202)
  2. 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:

  1. Alerta cuando crece (>10 items, o crecimiento >X/hr)
  2. Dashboard mostrando jobs en DLQ con categorización (por tipo de error)
  3. Replay manual después de fix: tomar jobs de DLQ y re-encolar a la queue principal
  4. 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:

  1. Llamar al LLM con el contenido del usuario (puede fallar transitoriamente)
  2. Guardar resultado en DB
  3. Mandar email al usuario con el resultado

Diseña retry + idempotency:

  1. ¿Para cuáles de los 3 pasos aplicas retry? ¿Con qué parámetros?
  2. ¿Cómo evitas que un retry mande el email dos veces?
  3. ¿Qué pasa si el LLM call funciona pero el email falla?
  4. ¿Cuándo va a DLQ?
Ver solución
  1. 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í.
  2. 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 si rowcount == 1
    • Si retry intenta mandar de nuevo, el rowcount es 0 y skip
  3. 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
  4. 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-After cuando 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

  1. Exponential Backoff and Jitter — AWS Blog — el write-up canónico de jitter.
  2. Tenacity (Python) — librería retry production-ready.
  3. Stripe API — Idempotency — referencia de cómo lo hace Stripe.
  4. Designing for Failure (Adrian Cockcroft) — patterns de Netflix.
  5. AWS SQS — Visibility timeout — para idempotency en colas.