Patrón Timeout
Evita que las operaciones se cuelguen indefinidamente imponiendo un tiempo máximo de ejecución. Un patrón de resiliencia para tiempos de respuesta predecibles.
Resumen
El Patrón Timeout es un patrón de resiliencia que evita que las operaciones se cuelguen indefinidamente imponiendo un tiempo máximo de ejecución. Sin timeouts, un único servicio descendiente lento puede retener hilos, conexiones y peticiones de usuarios indefinidamente, causando fallos en cascada en todo el sistema.
Suena trivialmente simple —“deja de esperar tras N segundos”— pero hacerlo bien implica tres preguntas que la mayoría de equipos se salta: cuánto tiempo es suficiente, qué pasa con el trabajo que abandonaste, y quién más abajo todavía cree que la petición sigue viva.
Cuándo usarlo
Usa el Patrón Timeout cuando:
- Llames a servicios externos, bases de datos o APIs que pueden dejar de responder
- Necesites garantizar tiempos de respuesta máximos a usuarios o llamadores upstream
- Las operaciones colgadas puedan agotar pools de hilos, conexiones o memoria
- Quieras fallar rápido en lugar de esperar indefinidamente una respuesta
Cuándo no usarlo: las operaciones legítimamente largas no van bajo un timeout corto. Exportaciones batch, generación de informes, procesamiento de vídeo y subidas de ficheros grandes necesitan otra forma — un trabajo asíncrono con cola y un endpoint de progreso, no una petición que muere a los 30 segundos. Las respuestas en streaming también piden cuidado: un timeout global sobre todo el stream mata a los consumidores lentos, así que limita las lecturas individuales, no la respuesta.
Solución
Python
import functools
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
def with_timeout(seconds: float):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(func, *args, **kwargs)
try:
return future.result(timeout=seconds)
except FutureTimeout:
raise TimeoutError(f"Operation timed out after {seconds}s")
return wrapper
return decorator
@with_timeout(seconds=2.0)
def fetch_slow_data():
import time
time.sleep(5)
return "data"
# Uso
try:
result = fetch_slow_data()
print(result)
except TimeoutError as e:
print(f"Failed: {e}")
JavaScript
function withTimeout(fn, timeoutMs) {
return function(...args) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
Promise.resolve(fn(...args))
.then(resolve)
.catch(reject)
.finally(() => clearTimeout(timer));
});
};
}
async function fetchSlowData() {
await new Promise(r => setTimeout(r, 5000));
return "data";
}
const timedFetch = withTimeout(fetchSlowData, 2000);
// Uso
timedFetch()
.then(console.log)
.catch(e => console.log("Failed:", e.message));
Java
import java.util.concurrent.*;
public class Timeout {
public static <T> T execute(Callable<T> task, long timeoutMs) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<T> future = executor.submit(task);
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
throw new RuntimeException("Operation timed out after " + timeoutMs + "ms");
} finally {
executor.shutdownNow();
}
}
public static void main(String[] args) {
try {
String result = execute(() -> {
Thread.sleep(5000);
return "data";
}, 2000);
System.out.println(result);
} catch (Exception e) {
System.out.println("Failed: " + e.getMessage());
}
}
}
Explicación
El Patrón Timeout impone un límite duro a las operaciones, construido sobre tres piezas móviles:
- Deadline: el tiempo máximo que una operación puede ejecutarse.
- Abandono: cuando el límite expira, el llamador deja de esperar. Que el trabajo subyacente se detenga de verdad es otra cuestión — ver Cancelación más abajo.
- Propagación: los timeouts deberían encoger a medida que bajan por la cadena de llamadas. Si una llamada API tiene un presupuesto de 5s y llama a una base de datos que tarda 4s, la llamada a la DB necesita un timeout más corto — digamos 2s — para que la base de datos no siga consumiendo tiempo después de que la API se dio por vencida.
Esto previene el agotamiento de pools de hilos, fugas de conexiones y esa miseria particular de una petición de usuario que se cuelga noventa segundos porque una dependencia va lenta.
El timeout de cada capa deja margen para que la capa superior decida qué hacer con el fallo — reintentar, hacer fallback o devolver un error. Por eso el presupuesto externo siempre es mayor que la suma de timeouts internos: la diferencia es el tiempo que tiene el llamador para reaccionar.
Timeout frente a cancelación
Una sutileza que muerde en producción: agotar la espera no es lo mismo que detener el trabajo. En los ejemplos de Python y Java, future.result(timeout) y future.get(timeout) lanzan una excepción al llamador, pero el hilo trabajador sigue corriendo hasta terminar. Si tu timeout se dispara mil veces por minuto sobre una base de datos enferma, sigues reteniendo mil conexiones.
La cancelación real depende de la plataforma:
- JavaScript:
AbortControllersí señala al fetch/socket subyacente para que pare. - Java:
future.cancel(true)envía una interrupción — funciona solo si el código interno comprueba la interrupción (la I/O bloqueante en sockets normalmente la ignora). - Python: los hilos no se pueden matar con seguridad; la respuesta estándar es poner timeouts en la propia I/O (
requests.get(url, timeout=3),statement_timeoutde psycopg2) para que el hilo abandonado muera solo. - gRPC / HTTP/2: los deadlines viajan en los metadatos, y el servidor puede escuchar al cliente cancelar — cancelación real de extremo a extremo.
La combinación más segura es un timeout en cada nivel: el wrapper de llamada anterior más timeouts de socket en el cliente HTTP y el driver, para que el trabajo abandonado muera rápido en lugar de fugar conexiones.
Llamadas externas con AbortController
// Patrón timeout con AbortController (Node.js 18+)
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
return response;
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
throw new Error(`Request timeout after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeout);
}
}
// Uso: llamar a una API externa con timeout de 5s
try {
const res = await fetchWithTimeout("https://api.stripe.com/v1/charges", 5000);
const data = await res.json();
} catch (err) {
if (err.message.includes("timeout")) {
// Manejar el timeout: retry, fallback o error al usuario
console.error("Stripe API timeout, using fallback");
}
}
A diferencia de los wrappers tipo Promise.race, controller.abort() desmonta el socket subyacente — la petición no sigue ocupando la conexión después de que el llamador siguió adelante.
Cómo elegir valores de timeout
El consejo habitual —“2-3 veces el p99”— es un punto de partida, no la respuesta. Un modelo mental mejor:
| Servicio | Timeout | Reintentos | Backoff |
|---|---|---|---|
| Stripe API | 5s | 3 | Exponencial |
| Consulta a DB | 10s | 0 | N/A |
| Redis | 1s | 2 | Fijo 500ms |
| Subida a S3 | 30s | 2 | Exponencial |
| API interna | 3s | 2 | Exponencial |
| Servicio de email | 10s | 3 | Exponencial |
Algunas reglas detrás de los números:
- Las dependencias rápidas y baratas reciben timeouts cortos. Redis con p99 = 50ms no merece 5s — un techo de 1s saca a la luz un nodo muerto rápidamente.
- Los reintentos solo tienen sentido dentro del presupuesto. Tres reintentos a 5s son 15s en el peor caso; asegúrate de que la capa superior lo puede pagar.
- Las llamadas no idempotentes reciben cero reintentos por defecto. Un
POSTagotado pudo haber tenido éxito en el servidor; la decisión de reintentar pertenece al llamador, no al wrapper genérico. - Re-mide cada trimestre. La latencia deriva; un timeout fijado para el p99 del año pasado estará mal ahora.
Variantes
| Variante | Descripción | Caso de uso |
|---|---|---|
| Timeout fijo | Mismo timeout para todas las llamadas | Comportamiento simple y predecible |
| Timeout adaptativo | Timeout basado en latencias históricas (P99) | Respuesta dinámica a la salud del servicio |
| Propagación de deadline | Pasa el tiempo restante por la cadena de llamadas | Presupuestos de latencia extremo a extremo |
| Resultados parciales | Devuelve lo obtenido antes del timeout | Streaming, búsqueda, agregación |
Lo que funciona
- Pon siempre timeouts en llamadas externas — I/O de red, consultas a base de datos, peticiones HTTP
- Propaga los deadlines por tu cadena de llamadas (contexto gRPC, headers HTTP, parámetros explícitos)
- Pon timeouts más cortos en niveles inferiores — deja margen para reintentos y fallbacks
- Registra los eventos de timeout con el nombre del servicio destino; la tasa de timeouts por dependencia es un indicador temprano de problemas
- Combínalo con Circuit Breaker — si los timeouts son frecuentes, deja de llamar al servicio que falla — y con Retry para fallos transitorios
- Prefiere la cancelación real a las esperas abandonadas — AbortController, cancelación por contexto, timeouts de socket
Errores comunes
- No poner ningún timeout, dejando que las operaciones se cuelguen para siempre
- Timeouts demasiado largos, anulando el propósito de fallar rápido — los defaults de 60s son un bug, no un margen de seguridad
- Timeouts demasiado cortos, causando fallos innecesarios durante picos normales de latencia
- No cancelar la operación subyacente cuando el timeout se dispara — el llamador vuelve, pero el trabajo, y sus conexiones, siguen vivos
- Ignorar la propagación de deadlines, causando expiraciones en cascada
- Reintentar operaciones no idempotentes tras un timeout — puedes ejecutar el efecto lateral dos veces
Solución de problemas
- Los timeouts saltan con carga normal: tu techo está dentro de la distribución de latencia. Mide el p99 (no el p50) y sube el timeout por encima, o investiga primero la regresión de latencia.
- Timeout pero el trabajo sigue corriendo: el wrapper abandonó la espera pero nunca canceló la operación. Añade timeouts de socket o usa una primitiva con cancelación real.
- Todo expira cuando una dependencia va lenta: el margen está invertido — un timeout interno mayor que el presupuesto externo deja al downstream reteniendo recursos después de que el llamador se rindió.
- Los reintentos amplifican la caída: timeouts + reintentos sin jitter crean una stampede contra un servicio que se está recuperando. Añade jitter y acota los reintentos dentro del presupuesto de llamada.
Ver También
Preguntas frecuentes
¿Qué valor de timeout debería usar?
Básalo en tu SLA y en la latencia p99 del servicio descendiente. Si tu API promete una respuesta de 500ms y una consulta a la DB tarda 100ms en p99, fija el timeout de la DB en ~150ms para dejar margen a reintentos y procesamiento. Después mide: si el timeout nunca se dispara, está demasiado suelto; si se dispara con tráfico normal, está demasiado corto.
¿Un timeout cancela la operación subyacente?
No automáticamente. Future.get(timeout) y Promise.race abandonan la espera mientras el trabajo sigue corriendo. La cancelación real necesita primitivas cooperativas: AbortController en JavaScript, future.cancel(true) en Java, timeouts de socket en la propia I/O en Python. Asume que el trabajo abandonado fuga recursos hasta que demuestres lo contrario.
¿Debería reintentar después de un timeout?
Solo si la operación es idempotente y el timeout pudo ser transitorio. Una escritura agotada pudo haber tenido éxito en el servidor, así que reintentar a ciegas puede duplicar efectos laterales. Y si los timeouts son frecuentes, deja de reintentar — combínalo con Circuit Breaker para no martillear un servicio crónicamente lento.
¿Cuál es la diferencia entre un timeout y un deadline?
Un timeout es local: "esta llamada tiene 2s". Un deadline es global: "esta petición debe terminar en 5s, haga las llamadas que haga". Los deadlines propagan el presupuesto restante por la cadena de llamadas — gRPC lo hace nativamente con context.Deadline, y los servicios HTTP suelen pasarlo como header. Los timeouts por llamada son el respaldo cuando no hay deadline propagado.
¿Cómo gestiono operaciones legítimamente lentas?
No les pongas un timeout corto — restructura en su lugar. Exportaciones largas, importaciones y trabajos batch van detrás de una cola: acepta la petición, devuelve un ID de trabajo inmediatamente, procesa en asíncrono y deja que el cliente consulte o se suscriba al resultado. Un timeout de 30 segundos sobre una operación de 5 minutos solo produce reintentos sobre trabajo que nunca va a encajar.
Recursos Relacionados
Patrón Retry
Reintenta una operación que ha fallado con errores transitorios, usando estrategias configurables como delay fijo, backoff exponencial o integración con circuit breaker.
PatternPatrón Circuit Breaker en Python, JavaScript y Java
Previene fallos en cascada deteniendo solicitudes a servicios que están fallando. Un patrón arquitectural para sistemas distribuidos resilientes.
PatternPatrón Bulkhead: Aísla Recursos y Limita el Radio de Impacto
Cómo aislar recursos por servicio para limitar el radio de impacto. Cubre aislamiento de thread pools, pools de conexión, bulkheads con semáforos y cuotas de recursos.
PatternPatrón Graceful Degradation
Degradar funcionalidad en lugar de fallar cuando dependencias no estan disponibles. Servir resultados parciales, datos cacheados o features de fallback.