Stack: Django/DRF · Proyecto: TicketFlow Estado: Publicada — timeouts, reintentos y breakers entre servicios Prerrequisito: Lección 53 — Monolito modular frente a microservicios
Objetivos
- Blindar cada frontera remota de TicketFlow con timeouts por operación (el default sin timeout es el colapso en cadena).
- Aplicar reintentos con backoff + jitter SOLO en lo idempotente (la 14/29), y el circuit breaker para dejar de martillar al caído.
- 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):
# 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.
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 + jitterLa 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:
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
raiseEl 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
- ¿Qué dos colapsos evita el timeout y cómo se dimensiona (¿p99 ×20? ¿el edge?)? ¿Por qué conexión y lectura son timeouts distintos?
- ¿Cuáles son las 4 reglas del reintento y por qué el read-timeout exige idempotencia mientras el connect no?
- ¿Qué hace el breaker en OPEN y qué colapso concreto evita (¿el pool del 39?)? ¿Por qué su estado es métrica?
- Enumera la jerarquía de degradación de TicketFlow: ¿qué SE degrada y qué JAMÁS (¿la invariante de la 10)?
- ¿Qué tres juegos de caos prueban la resiliencia y qué produce cada uno?
Continúa con los ejercicios. Las solutions.md solo tras intentarlo.