Módulo 2: Timeouts
1. Presentación del módulo: el asesino silencioso
Descripción
En el módulo 1 aprendiste la ley que gobierna todo lo que sigue: en un sistema distribuido, algo siempre está caído o lento. No es mala suerte ni mal código; es la condición normal de operar sobre una red con muchas máquinas. Viste el flujo de checkout de Mercado —el marketplace que acompaña la guía— donde orders orquesta la compra llamando a payments para cobrar, a shipping para crear el envío, y consultando a catalog para leer el producto. Y viste el fenómeno que da sentido a toda la guía: la falla en cascada, esa forma en que la lentitud de una dependencia puede tumbar todo el sistema, incluso las partes que estaban perfectamente sanas.
Este módulo abre la caja de herramientas con el patrón más barato, más simple y —esto sorprende a mucha gente— más importante de todos: el timeout. Un timeout es un límite de tiempo a la espera de una respuesta: "si shipping no me contesta en 300 milisegundos, dejo de esperar y sigo". Escrito así parece un detalle de configuración, una línea aburrida en un archivo yaml. Pero la ausencia de timeouts es, medida en incidentes reales de producción, la causa número uno de caídas en cascada. La lección de hoy es entender por qué algo tan simple carga tanto peso.
La respuesta está en una sola frase mecánica, y quiero que la memorices porque es el corazón del módulo: cada request en vuelo retiene un recurso del llamador hasta que la dependencia responde. Ese recurso es un hilo del pool, o una conexión, o un slot —depende de la tecnología, pero siempre hay uno—. Mientras orders espera a shipping, un hilo de orders está bloqueado, sin hacer nada, sin poder atender a nadie más, esperando. Si shipping responde en 50 ms, el hilo se libera en 50 ms y no pasa nada. Pero si shipping se cuelga y no responde nunca, ese hilo queda retenido para siempre. Y aquí está el veneno: no hay un hilo esperando, hay muchos. Cada request nueva a shipping retiene otro hilo más. El pool es finito. Cuando se agota, orders ya no tiene hilos para nadie —ni para el cobro, ni para leer el catálogo— y cae, aunque orders en sí mismo esté perfectamente sano. Lo mató la espera, no el error.
Conexión con el módulo: esta es la lección-mapa. No ponemos timeouts todavía; hoy construimos la intuición del mecanismo (por qué esperar sin límite agota el pool), el vocabulario (timeout de conexión, timeout de lectura, p99, presupuesto de tiempo) y el escenario medido que recorreremos: el pool de orders bajo un shipping colgado. La lección 2 define el timeout con precisión y construye el primitivo call_with_timeout. La 3 es el corazón medido: ejecutamos el pool exhaustion con números lado a lado. La 4 separa el timeout de conexión del de lectura. La 5 te enseña a elegir el valor desde el p99 de la dependencia. La 6 convierte el timeout en un contrato de tiempo. La 7 lo lleva a cadenas de llamadas con un presupuesto. Y la 8 te pone a blindar el checkout de Mercado con tus manos y a medir el antes y el después.
La fila del banco: por qué esperar sin límite mata al que espera
Imagina un banco con ocho ventanillas —ni una más—. Es un banco eficiente: cuando un cliente llega, si hay una ventanilla libre, lo atienden de inmediato; si están todas ocupadas, hace fila. La mayoría de los trámites toman dos o tres minutos, así que las ocho ventanillas alcanzan de sobra para un flujo normal de gente. El banco funciona.
Un día llega un cliente con un trámite endemoniado: un papeleo con una oficina externa que, por un problema de esa oficina, se queda esperando una confirmación que no llega. El cajero no puede cerrar el trámite ni mandar al cliente a su casa; se queda ahí, bloqueado, esperando una llamada de vuelta que nunca suena. Una ventanilla menos. Enseguida entra otro cliente con el mismo trámite hacia la misma oficina externa colapsada: otra ventanilla bloqueada. Y otro. En pocos minutos las ocho ventanillas están congeladas esperando a la oficina externa, ninguna avanza, y la fila de gente que solo venía a sacar dinero —un trámite de treinta segundos que no toca a esa oficina para nada— se extiende hasta la puerta y sale a la calle. El banco no tiene ningún problema: sus cajeros están sanos, su dinero está ahí, su sistema funciona. Pero está caído, porque cada cajero está rehén de una espera sin límite.
Ahora dale al banco una regla nueva: "ningún trámite con la oficina externa puede tomar más de cinco minutos; pasados los cinco, el cajero cuelga, le dice al cliente 'no pude, vuelve más tarde' y atiende al siguiente". Con esa sola regla, ninguna ventanilla queda congelada. Las requests hacia la oficina rota siguen fallando —eso no lo arregla el timeout—, pero fallan rápido, el cajero se libera rápido, y la fila de gente que solo quería sacar dinero avanza normal. El banco sigue en pie. Eso es un timeout: no cura a la oficina externa; salva al banco de morir esperándola. Las ventanillas son el pool de hilos de orders; la oficina externa colapsada es shipping colgado; la gente que solo quería sacar dinero es el tráfico de catalog que nunca tuvo nada que ver con el envío y aun así se cae si no hay timeout.
El mecanismo, con nombres y números
Pongámosle los nombres exactos de Mercado a la analogía, porque los usaremos todo el módulo. orders es un servicio con un pool de hilos finito —digamos ocho— que atienden las requests entrantes. Dos tipos de tráfico caen sobre ese mismo pool: los browse, que solo consultan a catalog (una lectura sana, ~20 ms), y los checkout, que llaman a shipping para crear el envío. Hoy shipping está teniendo un mal día: se colgó, y cada llamada a shipping se queda esperando ~3000 ms (o para el caso, para siempre).
Sin timeout, la secuencia es la de la fila del banco. Los primeros checkout toman hilos y se quedan pegados esperando a shipping. En cuestión de menos de un segundo, los ocho hilos del pool están retenidos por llamadas colgadas a shipping. A partir de ahí, cada browse que llega —una lectura de catálogo que tomaría 20 ms— no encuentra un solo hilo libre y se queda en la fila; y como la fila también es finita, cuando se llena, orders empieza a rechazar requests. El catálogo está perfecto. La base de datos de catálogo responde en 20 ms. Y aun así los usuarios que solo querían ver un producto reciben un error, porque orders no tiene un hilo con el cual atenderlos. Eso es la falla en cascada del módulo 1, ahora vista desde adentro: no se propagó un error, se propagó una espera.
El número que quiero que veas hoy —lo mediremos a fondo en la lección 3— es este contraste. En la simulación del pool de orders con ocho hilos, bajo shipping colgado, comparando sin timeout contra un timeout de 300 ms:
browse (catalog, SANO) pool
SIN timeout : servidas 62/167 rechazadas 105 86% del tiempo al 100% lleno
CON timeout : servidas 167/167 rechazadas 0 0% del tiempo al 100% lleno
Léelo despacio, porque es la tesis del módulo en cuatro números. Sin timeout, de 167 requests sanas al catálogo, 105 se rechazan —el 63% de un tráfico que no tenía absolutamente nada roto—, y el pool pasa el 86% del tiempo completamente lleno, sin un hilo libre para nadie. Con un timeout de 300 ms, las 167 requests sanas se sirven todas, cero rechazos, y el pool nunca llega a llenarse. Lo único que cambió fue una línea que dice "deja de esperar a shipping después de 300 ms". shipping sigue igual de roto en los dos casos: el timeout no lo curó. Pero en un caso orders se cae con él, y en el otro orders sobrevive. Ese es todo el módulo.
El timeout como primera línea de defensa
Vale la pena decir con claridad por qué el timeout va primero, antes que cualquier otro patrón de esta guía. Piensa en el orden de los eventos cuando shipping se cuelga. Primero, orders hace la llamada y empieza a esperar: en ese instante ya retuvo un hilo. Todos los demás patrones de resiliencia actúan después de ese instante: el reintento (módulo 3) decide qué hacer cuando la llamada falla, pero la llamada tiene que fallar primero; el circuit breaker (módulo 5) decide no llamar en absoluto, pero para "abrirse" necesita haber acumulado fallas, y esas fallas llegan por... timeout. Sin un timeout, la llamada nunca falla: se queda esperando. Y mientras se queda esperando, el hilo sigue retenido. Ningún patrón posterior puede actuar sobre un recurso que ya quedó atrapado en una espera infinita. El timeout es lo que convierte "esperar para siempre" en "fallar en un tiempo acotado", y solo una vez que la llamada falla en un tiempo acotado tienen sentido los reintentos, los breakers y la degradación.
Por eso decimos que el timeout es la primera línea de defensa: es el patrón que hace posibles a todos los demás. Un sistema con timeouts pero sin reintentos ni breakers es frágil pero sobrevive —las requests fallan rápido y el pool respira—. Un sistema con reintentos y breakers elegantísimos pero sin timeouts es una casa preciosa construida sobre arena: al primer dependiente que se cuelgue, el pool se agota antes de que ninguna de esas defensas alcance a dispararse. Si de todo este módulo te llevas una sola cosa, que sea esta: ponle un timeout a cada llamada de red, siempre, sin excepción. El resto de la guía es cómo hacerlo bien; pero hacerlo mal es infinitamente mejor que no hacerlo.
El default traicionero: sin timeout es el comportamiento por defecto
Aquí está la parte cruel, y la razón por la que este bug es tan común: en casi todas las librerías, el timeout por defecto es "infinito". Si escribes requests.get("http://shipping/...") en Python sin pasar timeout=, la librería espera para siempre. Lo mismo ocurre, por defecto o casi, en muchos clientes HTTP, drivers de base de datos y SDKs. Nadie eligió activamente "esperar para siempre"; simplemente no se escribió el parámetro, y el parámetro no escrito significa "sin límite". El asesino silencioso no entra por la puerta con un letrero; entra por la línea que no escribiste.
Esto tiene una consecuencia práctica que vas a repetir en cada revisión de código el resto de tu carrera: una llamada de red sin timeout explícito es un bug, aunque hoy funcione. Funciona hoy porque la dependencia hoy responde. El día que la dependencia se cuelgue —y por la ley del módulo 1, ese día llega—, esa línea sin timeout se convierte en el hilo que no se libera, y de ahí en la cascada. La lección 2 te da el call_with_timeout que hace explícito ese límite, y en la lección 4 verás dónde ponerlo de verdad para que corte el I/O y no solo libere al llamador.
Errores comunes
Creer que el timeout sirve para "arreglar" la dependencia lenta. Qué pasa: alguien pone un timeout esperando que shipping mejore, y se decepciona cuando shipping sigue igual de roto. Por qué pasa: se confunde a quién protege el timeout. Cómo detectarlo: la queja es "puse el timeout y shipping sigue fallando". Cómo corregirlo: entender que el timeout no cura a la dependencia; corta tu espera para que la falla de la dependencia no se convierta en tu falla. shipping seguirá roto —eso lo atienden los reintentos, el breaker o la degradación—; el timeout solo evita que su lentitud agote tu pool. Proteges al llamador, no al llamado.
Pensar que "mi servicio está sano, así que estoy a salvo". Qué pasa: el equipo de orders revisa sus métricas, ve CPU baja y memoria normal, y concluye que no hay problema —mientras los usuarios reciben errores—. Por qué pasa: la falla en cascada no se ve en la salud del servicio caído, sino en la del que lo llama, y en una métrica poco intuitiva: hilos ocupados esperando. Cómo detectarlo: el pool de hilos/conexiones al 100%, la latencia de tu propio servicio disparada, y errores en operaciones que no tocan la dependencia rota (los browse cayéndose por culpa de los checkout). Cómo corregirlo: monitorear la ocupación del pool como métrica de primera clase, no solo CPU y memoria; un pool saturado con CPU baja es la firma exacta del contagio por espera.
Dejar el timeout implícito ("la librería ya trae uno"). Qué pasa: se asume que el cliente HTTP o el driver "seguro trae un default razonable", y no se pasa timeout=. Por qué pasa: es más fácil no escribir el parámetro, y el código funciona en las pruebas. Cómo detectarlo: busca en el código toda llamada de red sin un timeout explícito —es un grep que todo equipo debería correr—. Cómo corregirlo: pon un timeout explícito en cada llamada de red, incluso si crees que la librería trae uno; muchas traen "infinito" por defecto, y las que traen un default suelen elegir un valor demasiado grande (30 s, 60 s) que no te salva del pool exhaustion. El valor correcto lo eliges tú desde el p99 (lección 5), no lo hereda la librería.
Ejercicios
Ejercicio 1 — El hilo rehén. El pool de orders tiene 8 hilos. shipping se cuelga y cada llamada a shipping se queda esperando sin límite. Llegan requests de checkout (que llaman a shipping) a razón de una cada 100 ms. Sin hacer cuentas exactas, estima aproximadamente cuánto tarda en agotarse el pool entero, y explica qué le pasa a partir de ese momento a un browse que solo quería leer el catálogo.
Ver solución
Si llega un checkout cada 100 ms y cada uno se queda pegado a un hilo (porque shipping no responde), el octavo checkout toma el octavo hilo alrededor de los 700–800 ms (los checkout en t=0, 100, 200, …, 700 toman los ocho hilos). A partir de ese momento, el pool está completamente ocupado por llamadas colgadas y no queda ni un hilo libre.
Un browse que llega después de ese punto —una lectura de catálogo que tomaría 20 ms— no encuentra hilo, se va a la fila de espera; y cuando la fila también se llena, orders lo rechaza. El catálogo está perfecto, pero el usuario recibe un error porque orders no tiene con qué atenderlo. Ese es el contagio: la falla de shipping se convirtió en la falla de catalog, sin que catalog tuviera nada que ver. En la simulación de la lección 3 verás que el pool efectivamente satura cerca de los 750 ms y se queda al 100% el resto del incidente.
Ejercicio 2 — Por qué el timeout va primero. Un compañero propone: "no perdamos tiempo con timeouts; pongamos directo un circuit breaker, que es más inteligente: si shipping falla, dejamos de llamarlo". Explica por qué un circuit breaker sin timeouts no salva al pool.
Ver solución
El circuit breaker se "abre" (deja de llamar) cuando acumula un número de fallas. La pregunta es: ¿cómo se entera el breaker de que una llamada falló? Una llamada a shipping colgado no devuelve un error; se queda esperando. Sin un timeout, esa llamada nunca falla —simplemente no termina—, así que el breaker nunca cuenta una falla, nunca alcanza su umbral y nunca se abre. Mientras tanto, cada una de esas llamadas que "nunca falla" está reteniendo un hilo. El pool se agota antes de que el breaker tenga una sola falla que contar.
El timeout es lo que convierte una espera colgada en una falla contable. Solo cuando la llamada falla rápido (por timeout) el breaker puede contarla, alcanzar su umbral y abrirse. Por eso el orden es timeout primero (módulo 2), breaker después (módulo 5): el breaker se construye encima de los timeouts, no en su lugar. Los dos juntos son mejores que cualquiera solo —el breaker te ahorra incluso el costo de esperar el timeout—, pero el breaker sin timeouts no tiene de qué alimentarse.
Ejercicio 3 — Caza el bug en la revisión. Estás revisando este código de orders. ¿Qué está mal y por qué es peligroso aunque las pruebas pasen?
import requests
def create_shipment(order_id):
resp = requests.post("http://shipping/shipments", json={"order_id": order_id})
return resp.json()
Ver solución
El bug es que requests.post no lleva timeout=. En requests, si no pasas timeout, la llamada espera para siempre por defecto. Las pruebas pasan porque en el entorno de pruebas shipping responde en milisegundos; el bug es invisible mientras la dependencia esté sana.
Es peligroso porque el día que shipping se cuelgue —por un despliegue, una sobrecarga, un problema de red—, esta línea se convierte en un hilo de orders que se queda esperando sin límite. Con suficientes create_shipment colgados, el pool de orders se agota y orders cae en cascada, tumbando también el tráfico que no tocaba shipping. La corrección mínima es un timeout explícito, idealmente separando conexión y lectura (lección 4) y con un valor elegido desde el p99 de shipping (lección 5):
resp = requests.post(
"http://shipping/shipments",
json={"order_id": order_id},
timeout=(1.0, 0.3), # (connect=1 s, read=300 ms)
)
La regla de revisión: toda llamada de red sin timeout explícito es un bug, aunque hoy funcione.
De la ley del módulo 1 al primer patrón
En el módulo 1 aceptaste una verdad incómoda: en distribuido, algo siempre está lento o caído, y no puedes evitarlo. La pregunta que quedó abierta fue "¿y entonces qué hago?". Este módulo da la primera respuesta concreta, y es sorprendentemente humilde: deja de esperar. No puedes evitar que shipping se cuelgue, pero sí puedes decidir cuánto tiempo estás dispuesto a esperarlo antes de rendirte y liberar el recurso. Esa decisión —cuánto esperar— es el timeout, y es la diferencia entre un servicio que muere con su dependencia y uno que sobrevive a la muerte de su dependencia.
El resto del módulo es esa idea, refinada. Primero la haremos precisa (qué es exactamente un timeout, lección 2) y la mediremos hasta que el mecanismo sea innegable (el pool exhaustion, lección 3). Después la haremos correcta: dónde poner el timeout para que corte de verdad (conexión vs lectura, lección 4), qué valor ponerle (desde el p99, lección 5), qué promete ese valor hacia arriba (el contrato de tiempo, lección 6) y cómo se comporta cuando las llamadas se encadenan (el presupuesto, lección 7). Al final tendrás el checkout de Mercado con timeouts puestos con criterio, y la tabla de números que prueba que sirvieron.
Resumen y siguiente paso
En esta lección viste por qué la ausencia de timeouts es el asesino silencioso de los sistemas distribuidos. El mecanismo cabe en una frase: cada request en vuelo retiene un recurso del llamador hasta que la dependencia responde; si la dependencia no responde nunca, el recurso queda retenido para siempre, el pool se agota y el servicio cae aunque él mismo esté sano. Lo viste en la analogía del banco de ocho ventanillas congeladas y en el número que recorreremos todo el módulo: sin timeout, 105 de 167 lecturas sanas de catálogo se rechazan y el pool está al 100% el 86% del tiempo; con timeout, las 167 se sirven y el pool nunca se llena.
Aprendiste que el timeout es la primera línea de defensa —el patrón que hace posibles a todos los demás, porque convierte una espera infinita en una falla acotada sobre la cual sí pueden actuar los reintentos, los breakers y la degradación— y que su gran trampa es ser el comportamiento por defecto invisible: casi ninguna librería pone un límite si no lo escribes, así que una llamada sin timeout explícito es un bug latente que funciona hasta el día que la dependencia se cuelga.
Antes de avanzar deberías poder: enunciar de memoria el mecanismo de retención de recursos; explicar por qué un servicio sano puede caerse por culpa de una dependencia lenta; y justificar por qué el timeout va antes que cualquier otro patrón de la guía.
Lo que sigue es hacer el concepto preciso y ejecutable. La lección 2 define qué es exactamente un timeout —qué mide, cuándo "dispara" y qué pasa cuando dispara—, construye el primitivo call_with_timeout en Python, y te muestra el matiz honesto que casi nadie cuenta: un timeout puesto alrededor de una llamada libera al llamador, pero no siempre mata el trabajo que quedó colgado. Ese matiz es la razón por la que el timeout de verdad vive en el socket, y es el puente hacia la lección 4.
Recursos
- Michael T. Nygard, Release It! Design and Deploy Production-Ready Software, 2ª ed. (Pragmatic Bookshelf, 2018) — la fuente canónica de estos patrones. El capítulo sobre stability patterns trata el timeout como la defensa fundamental y describe el mecanismo de agotamiento de recursos con ejemplos reales de producción. En inglés.
- Marc Brooker, "Timeouts, retries, and backoff with jitter" — aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter. El artículo de la Amazon Builders' Library que fija la práctica de la industria. La sección de timeouts explica por qué son la primera defensa y cómo elegir el valor. Gratis y en inglés.
- Documentación de
requests: "Timeouts" — requests.readthedocs.io/en/latest/user/advanced/#timeouts. La nota oficial que advierte que, sintimeout, una request "puede colgarse durante minutos o más". Es el default traicionero, documentado por la propia librería. En inglés. - Documentación de
httpx: "Timeouts" — www.python-httpx.org/advanced/timeouts. El cliente HTTP moderno de Python trae timeouts por defecto (a diferencia derequests) y expone los cuatro tipos —connect, read, write, pool— por separado. Útil para ver el modelo completo que la lección 4 desarma. En inglés.