Módulo 12 · Nivel empresarial (integración)

Lección 54 — Resiliencia

Timeouts, reintentos con backoff y circuit breakers entre servicios.

Publicada
En esta lección
  1. Ejercicio 1 — Los timeouts
  2. Ejercicio 2 — Los reintentos
  3. Ejercicio 3 — El breaker
  4. Ejercicio 4 — La degradación
  5. Ejercicio 5 — El game day
  6. Resumen del profesor

Ejercicio 1 — Los timeouts

  1. El colapso documentado: 20 requests sin timeout contra el lento → 20 hilos/worker bloqueados 60 s (el gunicorn con 4 workers: el servicio ENTERO responde solo 4 requests más en el minuto), el pool de PG sin conexiones libres (los hilos no devuelven), el browse (que no toca la pasarela) con p95 4 s por falta de workers. El fallo de UNA dependencia derrumba TODO: la definición del colapso en cadena. Con timeout 8 s: el daño queda en el checkout (§3: el breaker lo limita más).
  1. La tabla:
LlamadaconnectreadCriterio
Pasarela: cobro2 s8 sp99 400 ms ×20; < edge 60 s
Pasarela: consulta intent2 s3 soperación ligera
SMTP: envío2 s5 sasync (29): tolera más
Redis: caché0.2 s0.5 ssi tarda >0.5 s el caché pierde su propósito (38)
Postgres—statement_timeout 10 sel linter de la 09 mata la query infinita

El Redis con timeout CORTO es la regla del caché: cachear debe ser más rápido que no cachear — el caché que tarda 2 s es peor que el miss.

  1. El test con el timeout real: el fake del gateway lanza ReadTimeout (no GatewayTimeout simulado) → la saga entra UNKNOWN → la reconciliación del 32 (GET con intent) → CONFIRMED. La cadena completa: timeout → no-sé → protocolo: la resiliencia del dinero es el flujo entero, no el parámetro.

Ejercicio 2 — Los reintentos

  1. La evidencia del flaky: intento 1 falla, 2 falla, 3 pasa:
attempt 1: fail (ConnectError) → sleep 1.0 + 0.31 (jitter) = 1.31 s
attempt 2: fail (TimeoutError) → sleep 2.0 + 0.07           = 2.07 s
attempt 3: OK
  1. La distinción: el 402 (declinación) → 1 llamada (sin retry: el rechazo de negocio no mejora repitiendo: es la compensación del 32); el ConnectError → 3 con backoff. La regla en el cliente: retryable=(ConnectError, ReadTimeout) — el 4xx jamás.
  1. El herd: 50 clientes sin jitter al volver el servicio: pico de 50 requests en <100 ms (el servicio que vuelve recibe el doble de golpe: la segunda caída); con jitter (±0.5 s): el pico se reparte en ~500 ms (el histograma de delays muestra la dispersión). El jitter es gratis y es la diferencia entre "el servicio vuelve" y "el servicio vuelve y cae otra vez".

Ejercicio 3 — El breaker

  1. Los tests de los 3 estados:
python
def test_abre_tras_umbral():
    for _ in range(5):                       # 5 fallos consecutivos
        with pytest.raises(RetryableError): breaker.call(fallar)
    with pytest.raises(BreakerOpen):         # OPEN: fail-fast, sin tocar la red
        breaker.call(fallar)
    assert gw.llamadas == 5                  # la 6ª JAMÁS salió

def test_half_open_sana_o_reabre():
    ... abiertos los 30 s ...
    breaker.call(exito)   # sonda half-open: CLOSED de nuevo, fallos a 0
    # la sonda fallida: abierto_hasta += ventana (re-OPEN)
  1. Los números: SIN breaker (pasarela caída, 50 VUs): p95 checkout 8.1 s (todos esperan el timeout), pool del 39 agotado, browse p95 2.4 s (colateral), errores del browse +12%. CON breaker: los requests del pago fallan en ~2 ms con 503+Retry-After, browse p95 280 ms (intacto), pool respirando. El breaker LOCALIZA: el pago cae, el resto no lo nota.
  1. El breaker observable: breaker_state{service="gateway"} → gauge 0/1/2 (closed/open/half) → la alerta breaker_open > 2 min (page: la pasarela lleva 2 min en cuarentena — o el breaker está mal ajustado y martilla en falso). El breaker sin métrica es un fusible en el sótano: funde y nadie lo sabe.

Ejercicio 4 — La degradación

  1. El flujo completo del modo degradado:
Pasarela caída → breaker OPEN → POST /reservations/{ref}/pay → 503
  {"type": ".../payment-unavailable", "Retry-After": 30,
   "detail": "Estamos teniendo problemas con los pagos. Tu asiento queda retenido;
              reintenta en unos minutos o te avisaremos."}
→ la saga PENDING retiene el asiento (el TTL de la reserva se extiende: la política del negocio)
→ la pasarela vuelve → el retry del 29 re-encola el cobro → reconciliación → CONFIRMED → email

El usuario NO perdió el asiento: la retención con extensión es la decisión de producto (51: la conversión del pico vale la retención) — el fallback diseñado, no improvisado.

  1. El degradado de las sugerencias: FEATURE_SUGGESTIONS=false (el flag del 27 al revés) → el front muestra el selector manual (el mismo seatmap del 00b) → el E2E del 35 en modo degradado: la compra completa SIN sugerencias pasa (el test del modo alternativo: la feature "bonita" muere, el negocio vive).
  1. El doc (extracto): "Se degrada: caché→origen (lento-correcto), sugerencias→manual, email→cola acumulada (converge), pasarela→503+retención+reintento. JAMÁS se degrada: la unicidad del asiento (10: duplicar el inventario no es degradar, es morir), la validez del pago (el dinero), la autenticación (18: jamás 'auth degradada')". 15 líneas que el on-call lee antes de improvisar.

Ejercicio 5 — El game day

  1. La cronología del juego 1:
T+0    sandbox caído (docker stop)
T+2s   3 requests del pago esperan timeout
T+8s   3 timeouts → 3 fallos del breaker
T+15s  5º fallo → breaker OPEN (métrica breaker_state=1)
T+16s  checkout 503+Retry-After en ~2 ms (el resto del sistema: p95 intacto)
T+2m   alerta page: breaker_open > 2 min ✓
T+5m   sandbox vuelve → sonda half-open OK → CLOSED → el retry del 29 drena el backlog
T+7m   las sagas retenidas se confirman; cero asientos perdidos
  1. El juego 2 (latencia 5 s): el timeout del gateway (8 s) NO corta (5 s < 8 s: la operación "sana pero lenta" pasa con p95 5.1 s) — PERO el edge del 42 (60 s) tampoco, y el usuario del spinner (~10 s) sí ve el dolor: el finding: el timeout de la OPERACIÓN no protege del lento-funcional (el SLO del 46 sí: la alerta del burn dispara). La lección: el timeout protege de la caída; el SLO del lento; el breaker del martilleo — tres herramientas, tres dolores distintos.
  1. El informe (extracto): gaps: (1) la retención extendida no avisaba al usuario del límite (el detalle del 503 ahora lo dice); (2) la alerta del breaker no existía antes del juego (creada); (3) el runbook de degradación (el doc del ejercicio 4) se escribió DESPUÉS de necesitarlo (la acción P1). Acciones con issue/dueño/fecha. La resiliencia final: lo que sobrevivió el caos: el browse, el pool, el inventario; lo que no: la UX del aviso (corregida).

Resumen del profesor

  • Todo remoto lleva timeout por operación (p99 ×20, menor que el edge): el connect y el read son dolores distintos con reintentos distintos.
  • Reintento: solo lo idempotente, backoff+jitter, 3-5 intentos, el 4xx jamás — el breaker deja de martillar y LOCALIZA la caída (el resto respira).
  • La degradación se diseña (¿qué cae primero? ¿qué jamás cae?) y se prueba con caos controlado: la resiliencia es lo que sobrevive el juego, no lo que dice el código.