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. Objetivos
  2. 1. El timeout: la pieza sin la que todo lo demás es teoría
  3. 2. El reintento: backoff, jitter e idempotencia
  4. 3. El circuit breaker: dejar de martillar al caído
  5. 4. La degradación elegante: vender "menos" en vez de "nada"
  6. 5. El caos controlado: la prueba de la resiliencia
  7. Autoevaluación

Stack: Django/DRF · Proyecto: TicketFlow Estado: Publicada — timeouts, reintentos y breakers entre servicios Prerrequisito: Lección 53 — Monolito modular frente a microservicios


Objetivos

  1. Blindar cada frontera remota de TicketFlow con timeouts por operación (el default sin timeout es el colapso en cadena).
  2. Aplicar reintentos con backoff + jitter SOLO en lo idempotente (la 14/29), y el circuit breaker para dejar de martillar al caído.
  3. Degradar con gracia: el fallback por feature (el listado sin caché, el checkout sin sugerencias) que vende "menos" en vez de "nada".

1. El timeout: la pieza sin la que todo lo demás es teoría

La regla: toda llamada remota lleva timeout — sin él, el hilo/conexión espera infinito, el pool se agota (39), y tu sistema muere por el tercero lento (el colapso en cadena: el fallo en cascada donde TÚ eres el que se cae por la caída ajena). Los timeouts de TicketFlow, por operación (no globales — el cobro tolera más que la disponibilidad):

python
# core/http.py — el cliente con los timeouts del proyecto
import httpx

class GatewayClient:
    TIMEOUTS = httpx.Timeout(connect=2.0, read=8.0, write=2.0, pool=1.0)   # el cobro (32)

    def cobrar(self, intent_id, amount):
        return self._client.post("/charges", json=..., timeout=self.TIMEOUTS)

El análisis del timeout: debe exceder el p99 de la operación SANA (el cobro: p99 400 ms → read 8 s = ×20: margen para el 2% lento) y ser MENOR que la paciencia del sistema (el edge del 42: 60 s; el request del usuario: el spinner del front ~10 s). Y el timeout de conexión (2 s) separado del de lectura: el "no conecta" (caída) es distinto del "no responde" (sobrecarga) — el reintento difiere (§2). El resultado del timeout es el UNKNOWN del 32: la saga lo maneja (reconciliación) — el timeout no es el final, es la entrada del protocolo del no-sé.

2. El reintento: backoff, jitter e idempotencia

El reintento sin condiciones es el DDoS propio: martillar al servicio caído lo tumba del todo. Las reglas del proyecto: (1) solo lo idempotente (la 14: el GET, el cobro con Idempotency-Key, el envío con dedup del 29 — el reintento del POST no-idempotente DUPLICA el efecto); (2) backoff exponencial + jitter (la 29: 1s/2s/4s ± aleatorio — el jitter evita que 50 clientes reinten a la vez al volver el servicio: el thundering herd del reintento); (3) máximo 3-5 intentos (después: DLQ/compensación — el reintento infinito es el "espera y reza" del 32); (4) el reintento del connect vs del read: el connect fallido (no llegó) es seguro de repetir; el read-timeout (llegó, no sabemos) exige idempotencia — la regla que separa el seguro del peligroso.

python
def with_retry(fn, *, retries=3, base=1.0, retryable=(ConnectError, TimeoutError)):
    for attempt in range(retries):
        try:
            return fn()
        except retryable as exc:
            if attempt == retries - 1:
                raise
            sleep(base * 2 ** attempt + random.uniform(0, 0.5))   # backoff + jitter

La pieza fina: retryable — el 5xx y el timeout se reintentan; el 4xx (el rechazo de negocio del 26) NO (la declinación de la tarjeta no mejora con el intento: es compensación, no retry — la 32).

3. El circuit breaker: dejar de martillar al caído

El breaker (el fusible): tras N fallos consecutivos, las llamadas se ABORTAN de inmediato (fail-fast, sin esperar el timeout ×N) durante la ventana abierta; luego half-open (UNA sonda prueba) y se cierra si sana:

python
class CircuitBreaker:
    def __init__(self, umbral=5, ventana=30):
        self.fallos, self.abierto_hasta = 0, 0.0

    def call(self, fn, *args, **kw):
        if self.abierto:                                   # OPEN: fail-fast sin tocar la red
            raise BreakerOpen("pasarela en cuarentena 30 s")
        try:
            out = fn(*args, **kw)
            self.fallos = 0                                 # HALF-OPEN→CLOSED: sana
            return out
        except RetryableError:
            self.fallos += 1
            if self.fallos >= self.umbral:
                self.abierto_hasta = time.monotonic() + self.ventana
            raise

El beneficio medible: sin breaker, cada request del pico espera el timeout de 8 s contra la pasarela caída (el pool del 39 se agota: EL CHECKOUT ENTERO muere por el pago); con breaker, los requests fallan en 1 ms con 503 claro y el pool respira — el breaker LOCALIZA la caída (solo el pago falla; el browse sigue: el fallo por compartimento). Y el estado del breaker es MÉTRICA (46): breaker_state{servicio} con alerta en OPEN — el breaker que abre y nadie lo sabe es el fallo silencioso.

4. La degradación elegante: vender "menos" en vez de "nada"

El fallback por feature: cada dependencia remota lleva su modo degradado definido ANTES: (1) la caché del 38 cae → el origen sirve (lento pero correcto: el test del FLUSHALL ya lo probó); (2) la pasarela cae → el checkout se pone en cola "te avisamos" (la saga del 32: el usuario NO pierde el asiento — el UNKNOWN del cobro espera); (3) el servicio de sugerencias (52) cae → el selector manual de asientos sigue (el feature flag del 27 al revés: apagar lo bonito, no lo esencial); (4) el email (29) cae → la cola acumula (la convergencia del 30). La jerarquía de la degradación: primero lo que el usuario no nota (caché), luego lo que nota pero puede vivir (sugerencias), jamás el inventario ni el dinero (las invariantes de la 10 no se degradan: el asiento duplicado no es "degradación", es el negocio muerto).

La regla de diseño: cada endpoint del 13 lleva su respuesta de degradación en el contrato (el 503 con Retry-After del 26, el modo manual del checkout documentado en el runbook del 47) — la degradación se diseña, no se improvisa el día de la caída.

5. El caos controlado: la prueba de la resiliencia

La resiliencia sin prueba es una hipótesis: el game day (46/47) sistematizado: (1) apaga la pasarela sandbox → ¿el breaker abre en <30 s? ¿el pool respira? ¿el checkout muestra el modo degradado? ¿la saga retoma al volver? (2) añade latencia (el proxy con delay de 5 s) → ¿el timeout corta en 8 s? ¿el edge del 42 no mata antes? (3) apaga Redis → el FLUSHALL del 38 a escala. Cada ejercicio produce: la cronología (el 47), los gaps (la alerta que faltó, el fallback que no existía) y el fix con su test. El juego del caos en un entorno de 1: staging + calendario (31: el game day trimestral) — la resiliencia se mide por lo que SOBREVIVE el caos, no por lo que dice el código.


Autoevaluación

  1. ¿Qué dos colapsos evita el timeout y cómo se dimensiona (¿p99 ×20? ¿el edge?)? ¿Por qué conexión y lectura son timeouts distintos?
  2. ¿Cuáles son las 4 reglas del reintento y por qué el read-timeout exige idempotencia mientras el connect no?
  3. ¿Qué hace el breaker en OPEN y qué colapso concreto evita (¿el pool del 39?)? ¿Por qué su estado es métrica?
  4. Enumera la jerarquía de degradación de TicketFlow: ¿qué SE degrada y qué JAMÁS (¿la invariante de la 10)?
  5. ¿Qué tres juegos de caos prueban la resiliencia y qué produce cada uno?

Continúa con los ejercicios. Las solutions.md solo tras intentarlo.