Módulo 2: Timeouts
8. Proyecto: pon timeouts al checkout de Mercado y mide
Descripción
Este es el capstone del módulo. Hasta ahora aprendiste el timeout pieza por pieza —qué es, por qué es la primera defensa, dónde ponerlo, qué valor darle, qué promete, cómo encadenarlo—. Ahora lo aplicas entero, con tus manos, al checkout de Mercado. La tarea tiene una forma que se repetirá en cada módulo de la guía y que quiero que interiorices como método: tomas un sistema sin protección, mides su fragilidad con números, aplicas el patrón, y vuelves a medir para probar que sirvió. No "pongo timeouts porque el libro dice"; "pongo timeouts, y aquí está la tabla que muestra que el tráfico sano pasó de 62/167 a 167/167". La medición antes/después es la entrega, porque es la que convierte una creencia en una decisión de ingeniería defendible.
Vas a trabajar con el checkout de orders que llama a payments, shipping y catalog, sobre un pool compartido —el mismo escenario que mediste en la lección 3, ahora como tu proyecto—. Primero lo instrumentas y provocas el incidente (shipping colgado) para ver el pool agotado y el tráfico sano caer. Después eliges los timeouts con el método del p99, los aplicas separando conexión y lectura, añades el presupuesto en la cadena, y mides de nuevo. La entrega es el código resiliente, la tabla de métricas antes/después, y —lo más importante— la justificación de por qué cada número es el que es.
Conexión con el módulo: esta lección no introduce concepto nuevo; integra los siete anteriores en un flujo de trabajo. La lección 3 te dio el simulador del pool; la 4, la separación conexión/lectura; la 5, el método del p99; la 6, el contrato de tiempo; la 7, el presupuesto. Aquí los juntas. Al terminar, tendrás hecho de principio a fin el ciclo "medir la fragilidad → aplicar el patrón → medir la mejora" que es la columna vertebral de toda la guía, y estarás listo para el módulo 3, donde el patrón que se apoya sobre estos timeouts es el reintento.
El entregable, de un vistazo
Al final del proyecto habrás producido tres cosas:
- El checkout instrumentado y blindado (código): la versión con timeouts de conexión y lectura, valores derivados del
p99, y presupuesto de tiempo en la cadena. - La tabla de métricas antes/después:
success_ratedel tráfico sano, requests rechazadas,p99de latencia deorders, y ocupación del pool, medidas SIN y CON timeouts bajo el mismo incidente y la misma semilla. - La justificación: por qué elegiste cada timeout (qué
p99mediste, qué margen aplicaste), por qué separaste conexión y lectura, y qué falla resuelve el timeout y qué falla no resuelve (dejando claro qué queda para los módulos siguientes).
Paso 1 — Toma el checkout frágil y mide su fragilidad
Partimos del simulador del pool de orders de la lección 3, que ya modela el escenario completo: pool de 8 hilos, cola de 8, tráfico browse (a catalog, sano) y checkout (a shipping, hoy colgado). Tu primer trabajo es correrlo sin timeout y registrar la fragilidad. Esta es la línea del código que representa "el checkout frágil": la llamada sin límite.
# El checkout FRÁGIL: sin timeout. hold = la latencia completa de shipping.
if timeout_ms is not None and latency > timeout_ms:
hold = timeout_ms
req["outcome"] = "timeout"
else:
hold = latency # sin timeout, hold = 3000 ms colgados
req["outcome"] = "served"
Corre la simulación con timeout_ms=None y anota los números del "antes". Estos son los que ya viste en la lección 3, ahora como tu medición base:
=== SIN timeout (el checkout frágil) ===
browse (catalog, healthy): served= 62/167 rejected=105 latency p50=123ms p99=2373ms
checkout(shipping, hung) : served= 17 timeout= 0 rejected= 33
pool: avg busy=7.3/8 time at 100% full=86%
Tradúcelo a las métricas del entregable. El success_rate del tráfico sano (browse) es 62/167 = 37% —el 63% de las lecturas de catálogo, que no tocan shipping, fallan—. El p99 de latencia de orders para ese tráfico es 2373 ms. El pool está al 100% el 86% del tiempo. Esta es la foto de la fragilidad: un servicio que se cae con su dependencia, arrastrando tráfico que no tenía nada roto. Guarda estos números; son la mitad izquierda de tu tabla.
Paso 2 — Elige los timeouts con el método del p99
Antes de aplicar timeouts, decide sus valores con el método de la lección 5, no a ojo. Necesitas el p99 de la latencia sana de cada dependencia. Mídelo (o, en el proyecto, tómalo de la medición de la lección 5 para shipping):
shipping:p99≈ 143 ms (medido sobre 50 000 llamadas sanas). Read timeout =p99× ~2 ≈ 300 ms.payments: supón que midesp99≈ 320 ms. Read timeout ≈ 650 ms.catalog:p99≈ 40 ms. Read timeout ≈ 100 ms.
Y el connect timeout de cada uno sale aparte, del handshake sano de tu red (lección 4): en el mismo datacenter, ~1 s es holgado y seguro para las tres. Así que la configuración es:
TIMEOUTS = {
# (connect, read) en segundos
"catalog": (1.0, 0.100), # read = p99(40ms) x ~2
"payments": (1.0, 0.650), # read = p99(320ms) x ~2
"shipping": (1.0, 0.300), # read = p99(143ms) x ~2
}
Anota la justificación ahora, mientras la tienes fresca: cada read timeout es el p99 de la dependencia sana por un margen de ~2× —lo bastante para no cortar tráfico sano (falsos timeouts ~0%, lección 5) y lo bastante corto para liberar el hilo rápido ante un cuelgue—. El connect es corto y uniforme porque un handshake sano en el datacenter son milisegundos; 1 s detecta un host caído sin castigar la variación normal. Esta justificación es parte de la entrega: un timeout sin justificación es un número mágico.
Paso 3 — Aplica los timeouts y mide de nuevo
Ahora corre la misma simulación con el read timeout de shipping (300 ms) activo. En el simulador, eso es simplemente timeout_ms=300. En el código real de orders, es pasar la tupla a la llamada:
import requests
def create_shipment(order_id, deadline_ms):
# timeout de fase (connect, read) + acotado por el presupuesto (paso 4)
connect_to, read_to = TIMEOUTS["shipping"]
effective_read = min(read_to, deadline_ms / 1000) # presupuesto, lección 7
resp = requests.post(
"http://shipping/shipments",
json={"order_id": order_id},
timeout=(connect_to, effective_read),
)
return resp.json()
Corre la simulación con timeout_ms=300 y anota los números del "después":
=== CON timeout = 300 ms (el checkout blindado) ===
browse (catalog, healthy): served=167/167 rejected= 0 latency p50=21ms p99=31ms
checkout(shipping, hung) : served= 0 timeout= 50 rejected= 0
pool: avg busy=3.2/8 time at 100% full=0%
El success_rate del tráfico sano subió a 167/167 = 100%, las requests rechazadas cayeron a 0, el p99 de orders bajó de 2373 ms a 31 ms, y el pool nunca llega al 100%. Esta es la mitad derecha de tu tabla.
Paso 4 — Añade el presupuesto en la cadena
El checkout real de orders no hace una sola llamada; encadena catalog → payments → shipping. Aplica el presupuesto de la lección 7: la app da a orders un deadline (digamos 1200 ms), y cada llamada se acota por el tiempo restante. El esqueleto del checkout presupuestado:
import time
CLIENT_DEADLINE_MS = 1200
class DeadlineExceeded(Exception):
pass
def checkout(order):
start = time.monotonic()
def remaining_ms():
return CLIENT_DEADLINE_MS - (time.monotonic() - start) * 1000
def call(dep, fn, *args):
rem = remaining_ms()
if rem <= 0:
raise DeadlineExceeded(f"sin presupuesto antes de llamar a {dep}")
connect_to, read_to = TIMEOUTS[dep]
effective_read = min(read_to, rem / 1000) # min(local, remaining)
return fn(*args, timeout=(connect_to, effective_read))
product = call("catalog", read_catalog, order.product_id)
payment = call("payments", charge, order, product)
shipment = call("shipping", create_shipment, order) # acotado por lo que quede
return confirm(order, payment, shipment)
Verifica con la simulación del presupuesto (lección 7) que, en el peor caso, orders responde dentro de los 1200 ms en vez de pasarse a 1340 ms. Documenta el caso en que el presupuesto recorta el timeout de shipping por debajo de su p99: es la señal de que la cadena está al límite del deadline, y una nota honesta para tu entrega ("si esto pasa seguido, hay que paralelizar o diferir shipping").
Paso 5 — Arma la tabla y la justificación
Junta todo en la tabla antes/después, que es el corazón de la entrega:
Métrica (bajo shipping colgado) | SIN timeout | CON timeout |
|---|---|---|
success_rate tráfico sano (browse) | 37% (62/167) | 100% (167/167) |
| Requests sanas rechazadas | 105 | 0 |
p99 latencia de orders (tráfico sano) | 2373 ms | 31 ms |
| Pool: % del tiempo al 100% lleno | 86% | 0% |
| Pool: hilos ocupados en promedio | 7.3 / 8 | 3.2 / 8 |
checkout (a shipping) | 17 servidos lentísimos, 33 rechazados | 50 timeout rápido |
Y la justificación en prosa, que acompaña la tabla:
- Qué resuelve el timeout: el contagio. El tráfico sano de
catalogpasó de 37% a 100% de éxito porque el timeout impide que loscheckoutcolgados monopolicen el pool. El timeout salvó al que no tenía nada roto. - Qué NO resuelve el timeout: el
checkouten sí sigue fallando (50 timeouts) —shippingestá roto y el timeout no lo cura—. Eso queda para los módulos siguientes: reintentar con backoff (M3) por si es transitorio, dejar de golpear ashippingcon un breaker (M5), aislar su pool con un bulkhead (M6), y completar el pedido con envío diferido en vez de fallar (M7, degradación). - Por qué cada número: los read timeouts salen del
p99× ~2 de cada dependencia sana; el connect timeout es corto y uniforme (handshake sano); el presupuesto acota la cadena al deadline de 1200 ms del cliente.
Rúbrica: cómo sabes que lo hiciste bien
Tu proyecto está completo cuando puedes responder afirmativamente a todo esto:
- ¿Mediste el "antes" con números concretos (success_rate, rechazos,
p99, ocupación del pool), no con una descripción cualitativa? - ¿Cada timeout tiene una justificación derivada del
p99de la dependencia, y no es un número redondo inventado? - ¿Separaste connect y read, con el connect corto y el read del
p99? - ¿Aplicaste el presupuesto de tiempo a la cadena y verificaste que el peor caso cabe en el deadline del cliente?
- ¿Tu tabla antes/después muestra la mejora en el tráfico sano (no solo en el
checkout), que es donde vive el contagio? - ¿Distinguiste explícitamente qué falla resuelve el timeout (el contagio) de cuál no (el
checkoutque falla porqueshippingestá roto), señalando qué patrón posterior ataca lo que queda?
Si a alguna respondes "no", ahí está tu siguiente iteración. La medición es la prueba; sin ella, no hiciste el proyecto, solo escribiste código.
Errores comunes en el proyecto
Medir solo el checkout y declarar "no sirvió". Qué pasa: el alumno mira que el checkout sigue fallando (50 timeouts) con timeout y concluye que el timeout no ayudó. Por qué pasa: se mide la métrica equivocada —el timeout no arregla el checkout, arregla el contagio—. Cómo detectarlo: tu conclusión ignora los 167/167 browse salvados. Cómo corregirlo: la mejora vive en el tráfico sano (browse de 37% a 100%); esa es la falla en cascada evitada. El checkout roto es problema de otros módulos.
Inventar los timeouts en vez de derivarlos. Qué pasa: se ponen "300 ms para todo" sin medir el p99 de cada dependencia. Por qué pasa: es más rápido y "funcionó en la lección 3". Cómo detectarlo: catalog (p99 ~40 ms) con timeout de 300 ms es innecesariamente holgado, y payments (p99 ~320 ms) con 300 ms cortaría tráfico sano. Cómo corregirlo: cada dependencia tiene su propio p99 y por tanto su propio read timeout; deriva uno por dependencia, no un número global.
Saltarse la medición "antes". Qué pasa: se aplica el timeout directo y se entrega solo el "después". Por qué pasa: parece redundante medir algo que "ya sabemos que está mal". Cómo detectarlo: no tienes tabla comparativa, solo una foto final. Cómo corregirlo: el valor del proyecto es el contraste; sin el "antes" medido, no puedes cuantificar la mejora ni defender la decisión. Mide los dos mundos con la misma semilla.
Ejercicios
Ejercicio 1 — Deriva la fila de payments. Añade payments como tercer tipo de tráfico al simulador, con p99 sano de 320 ms, y supón que durante el incidente payments no está colgado sino lento (responde en 1500 ms, no infinito). ¿Qué read timeout le pones, y esperarías que su comportamiento bajo timeout sea igual, mejor o peor que el de shipping colgado? Justifica.
Ver solución
Read timeout de payments: p99(320 ms) × ~2 ≈ 650 ms. Es más grande que el de shipping (300 ms) porque payments es legítimamente más lento —su cola sana llega más lejos—, y cortarlo en 300 ms generaría falsos timeouts sobre cobros sanos.
Comportamiento bajo timeout: payments lento (1500 ms) sería menos dañino sin timeout que shipping colgado (infinito/3000 ms), porque al menos responde en 1500 ms y suelta el hilo, mientras que el colgado lo retiene indefinidamente. Pero con timeout el resultado es el esperado y sano: como 1500 ms > 650 ms, cada llamada a payments lento da timeout a los 650 ms y suelta el hilo —igual de rápido que shipping a los 300 ms, solo que con un techo más alto acorde a su p99—. La diferencia clave: 650 ms de retención por llamada es más que 300 ms, así que bajo la misma carga, payments lento presiona más el pool que shipping (más tiempo de hilo retenido por request). Esto refuerza la lección 5: el timeout es tu latencia de detección, y una dependencia con p99 más alto necesariamente retiene el hilo más tiempo antes de cortar —una razón más para aislar los pools por dependencia (bulkhead, M6), para que el payments lento no comparta pool con catalog—.
Nota importante sobre payments: reintentar un cobro que dio timeout es peligroso (podría haber cobrado antes de cortar —el "estado desconocido" de la lección 2—), así que aquí el timeout corta pero no se debe reintentar sin idempotencia (M3 y M4).
Ejercicio 2 — El presupuesto que no cabe. Con deadline de 1200 ms y timeouts de catalog (100 ms), payments (650 ms) y shipping (300 ms), la suma es 1050 ms + overhead. Supón que payments un día se degrada y su p99 sano sube a 900 ms, así que su read timeout debería subir a ~1800 ms para no cortar tráfico sano. ¿Qué le pasa al presupuesto del checkout, y qué opciones de diseño tienes?
Ver solución
Qué le pasa al presupuesto: si payments necesita 1800 ms para no cortar tráfico sano, pero el deadline total del cliente es 1200 ms, entonces payments solo no cabe en el deadline —su read timeout sano (1800 ms) ya excede el presupuesto entero (1200 ms)—. El presupuesto recortaría payments a lo sumo a ~1100 ms (lo que quede tras catalog), muy por debajo de su p99 de 900 ms + margen, así que empezarías a cortar cobros sanos. Es el conflicto "presupuesto < p99" de la lección 7, en su forma aguda: la cadena ya no cabe honestamente en el deadline.
Opciones de diseño (ninguna es "apretar el timeout a ciegas"):
- Paralelizar: si
cataloges independiente depayments, lánzalos en paralelo para quecatalogno consuma presupuesto secuencial antes depayments. Ganas ~100 ms, pero no alcanza sipaymentssolo ya pide 1800 ms. - Diferir
shipping: saca la creación del envío del camino crítico del checkout —confirma el pedido traspaymentsy crea el envío de forma asíncrona (degradación, M7 y arch-styles)—. Eso libera los 300 ms deshippingdel presupuesto, pero sigue sin resolver quepaymentssolo ya excede 1200 ms. - Renegociar el deadline: si
paymentslegítimamente necesita 1800 ms cuando está degradado, quizás el checkout no puede prometer 1200 ms ese día; el negocio decide si sube el deadline a 2 s o si acepta cortarpayments. - Atacar la causa: el
p99depaymentsque sube a 900 ms es en sí un incidente que hay que investigar (¿sobrecarga? ¿despliegue lento?); el timeout es defensa, no cura.
Lo importante: el presupuesto reveló que la cadena no cabe, y te obliga a una decisión de diseño explícita en vez de esconder el problema. Esa transparencia es el valor del presupuesto.
Ejercicio 3 — Transfiere el patrón. Fuera de Mercado, describe otro sistema que llame a una dependencia externa poco confiable (por ejemplo, un servicio que consulta una API de terceros para tipos de cambio, o un backend que llama a un proveedor de correo). Aplica el ciclo del proyecto: ¿qué medirías "antes", qué p99 necesitarías, qué timeouts pondrías, y qué falla resolvería el timeout y cuál no?
Ver solución
Tomemos un backend que envía correos transaccionales llamando a un proveedor externo (SendGrid, SES, etc.) desde el flujo de registro de un usuario.
- Qué medir "antes": bajo un proveedor de correo lento/caído, ¿cuántos registros de usuario fallan o se cuelgan aunque el registro en sí (crear la cuenta en tu base de datos) esté sano? Si la llamada al proveedor de correo no tiene timeout y comparte pool con el resto del backend, un proveedor caído puede agotar el pool y tumbar todo el backend —igual que
shippingtumbaba acatalog—. Medirías: success_rate del tráfico que no envía correo, requests rechazadas, ocupación del pool. - Qué
p99necesitas: la latencia sana de la API del proveedor de correo (típicamente mides que responde en, digamos,p99≈ 400 ms). Read timeout ≈ 400 × 2 = ~800 ms. Connect timeout corto (~1 s), aunque al ser una API externa por internet, el connect podría necesitar más margen que en un datacenter propio. - Qué timeouts:
timeout=(1.0, 0.8)en la llamada al proveedor, con presupuesto si el registro tiene un deadline de UX. - Qué resuelve el timeout: el contagio —un proveedor de correo caído deja de agotar el pool y de tumbar el resto del backend; los registros que no dependen del correo siguen funcionando—.
- Qué NO resuelve: el correo en sí no se envía. Pero aquí la solución natural es degradación (M7): no bloquees el registro por el correo —confirma la cuenta y encola el correo para reintentarlo asíncronamente cuando el proveedor vuelva—. El correo de bienvenida no debería estar en el camino crítico del registro; el timeout evita el contagio, y diferir el correo (encolarlo) evita que el usuario ni siquiera note la falla del proveedor.
El ciclo es idéntico al de Mercado: mide la fragilidad, deriva timeouts del p99, aplica, mide la mejora, y distingue el contagio (que el timeout resuelve) del síntoma (que resuelven degradación/reintento). Ese ciclo es transferible a cualquier llamada a una dependencia poco confiable.
Cierre del módulo y hacia dónde sigue
Terminaste el módulo del timeout, y lo terminaste midiendo. Tomaste el checkout frágil de Mercado, cuantificaste su fragilidad (37% de éxito en el tráfico sano, pool al 100% el 86% del tiempo), aplicaste el patrón con criterio —timeouts de conexión y lectura, valores del p99, presupuesto en la cadena— y probaste la mejora con una tabla (100% de éxito, pool que respira). Sobre todo, practicaste el ciclo que gobierna toda la guía: medir → aplicar → medir, y aprendiste a distinguir lo que un patrón resuelve de lo que deja pendiente.
Lo que el timeout deja pendiente es la agenda de los módulos siguientes, y ahora tiene sentido. El checkout que da timeout todavía falla; el módulo 3 pregunta si conviene reintentarlo —y descubre el retry storm, el backoff y el jitter, todo apoyado en el hecho de que ahora las llamadas fallan rápido y de forma acotada gracias al timeout—. Reintentar un cobro que dio timeout exige que el cobro sea idempotente (módulo 4), por el "estado desconocido" que viste en la lección 2. Dejar de golpear del todo a un shipping muerto, ahorrándose incluso el costo del timeout, es el circuit breaker (módulo 5). Que el shipping lento no comparta pool con payments es el bulkhead (módulo 6). Y completar el checkout con envío diferido en lugar de fallar es la degradación elegante (módulo 7). Cada uno de esos patrones se para sobre el timeout que acabas de dominar: sin él, ninguno alcanza a actuar antes de que el pool se agote.
Resumen y siguiente paso
En este proyecto integraste los siete conceptos del módulo en el ciclo "medir → aplicar → medir" sobre el checkout de Mercado. Mediste el "antes" (success_rate del tráfico sano 37%, 105 rechazos, p99 de orders 2373 ms, pool al 100% el 86% del tiempo), elegiste los timeouts con el método del p99 (read = p99 × ~2 por dependencia, connect corto y uniforme), los aplicaste separando conexión y lectura, añadiste el presupuesto en la cadena para caber en el deadline del cliente, y mediste el "después" (success_rate 100%, 0 rechazos, p99 31 ms, pool que nunca se llena). La entrega es el código, la tabla antes/después y la justificación de cada número.
La lección más profunda del proyecto es metodológica: un patrón se justifica midiendo, no citando. La tabla antes/después es lo que convierte "puse timeouts" en una decisión de ingeniería defendible, y el hábito de distinguir "qué resuelve el patrón" (el contagio) de "qué deja pendiente" (el checkout roto) es lo que te deja combinar patrones con criterio en lugar de amontonarlos.
Con esto dominas el timeout de principio a fin: qué es, por qué es la primera defensa, dónde ponerlo, qué valor darle, qué promete y cómo encadenarlo. Lo que sigue es el módulo 3: reintentos, backoff y jitter —qué hacer después de que el timeout dispara—. Verás que reintentar ingenuamente crea un retry storm que mata al servicio que se estaba recuperando, y que la cura es el backoff exponencial con jitter. Y todo ese módulo se apoya en lo que acabas de construir: las llamadas ahora fallan rápido y de forma acotada, que es la precondición para poder reintentarlas con cabeza.
Recursos
- Michael T. Nygard, Release It!, 2ª ed. (Pragmatic Bookshelf, 2018) — el capítulo de stability patterns muestra cómo se combinan timeout, circuit breaker y bulkhead en un sistema real; el marco mental para lo que este proyecto empieza y los módulos siguientes completan. En inglés.
- Marc Brooker, "Timeouts, retries, and backoff with jitter", Amazon Builders' Library — aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter. El artículo que integra timeouts con lo que viene en el módulo 3 (reintentos y backoff); léelo como puente. Gratis y en inglés.
- Google SRE Book, "Addressing Cascading Failures" — sre.google/sre-book/addressing-cascading-failures. El tratamiento de extremo a extremo de cómo una falla parcial se vuelve total y cómo los timeouts, deadlines y el resto de las defensas lo evitan. La visión de conjunto de toda la guía. Gratis y en inglés.
- Documentación de
requests— requests.readthedocs.io/en/latest/user/advanced/#timeouts — y dehttpx— www.python-httpx.org/advanced/timeouts. Las referencias que usarás al implementar los timeouts de este proyecto en código real. En inglés.