Sistemas distribuidos resilientes: timeouts y reintentos
Cómo implementar timeouts, reintentos y estrategias de backoff que evitan fallos en cascada en arquitecturas distribuidas.

En un sistema distribuido, cualquier llamada de red puede fallar, quedarse colgada o tardar órdenes de magnitud más de lo esperado. Un servicio downstream que normalmente responde en 50 ms podría tardar 30 segundos durante un despliegue, o directamente no responder nunca. Sin timeouts ni reintentos, un único servicio lento puede desencadenar una caída completa del sistema a medida que los hilos, las conexiones y la memoria se agotan esperando respuestas que nunca llegan.
El reto no está en añadir timeouts y reintentos, sino en configurarlos para que ayuden durante incidentes reales en lugar de empeorar la situación.
Timeouts: el valor por defecto innegociable
Toda llamada de red saliente necesita un timeout. Sin excepciones. Un timeout ausente convierte un problema downstream temporal en una fuga de recursos permanente en tu servicio.
// ❌ No timeout — hangs indefinitely if downstream is slow
const response = await fetch('https://api.payment-provider.com/charge', {
method: 'POST',
body: JSON.stringify(payload),
});
// ✅ Explicit timeout — fails fast when downstream is unresponsive
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('https://api.payment-provider.com/charge', {
method: 'POST',
body: JSON.stringify(payload),
signal: controller.signal,
});
return await response.json();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new TimeoutError('Payment provider did not respond within 5s');
}
throw error;
} finally {
clearTimeout(timeout);
}Cómo elegir los valores de timeout
interface TimeoutConfig {
// Connect timeout: how long to wait for TCP connection
connectTimeout: number;
// Read timeout: how long to wait for response after connected
readTimeout: number;
// Total timeout: maximum wall-clock time for the entire operation
totalTimeout: number;
}
// ❌ Timeout too high — defeats the purpose
const bad: TimeoutConfig = {
connectTimeout: 60_000,
readTimeout: 120_000,
totalTimeout: 180_000,
};
// ✅ Based on p99 latency + margin
const good: TimeoutConfig = {
connectTimeout: 1_000, // Most connects finish in <100ms
readTimeout: 3_000, // p99 is 800ms, 3x margin
totalTimeout: 5_000, // Hard ceiling for the operation
};Define los timeouts en función de la latencia p99 observada del servicio downstream, no del promedio. Un timeout equivalente a 3 veces el p99 detecta fallos reales sin penalizar la variación normal de latencia.
Estrategias de reintento
Los reintentos gestionan fallos transitorios — cortes de red breves, reinicios momentáneos de un servicio, sobrecargas temporales. Pero los reintentos ingenuos amplifican los problemas en lugar de resolverlos.
// ❌ Immediate retry — hammers a struggling service
async function naiveRetry<T>(fn: () => Promise<T>, maxRetries: number): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
// Retries instantly — adds load to an already struggling service
}
}
throw new Error('Unreachable');
}
// ✅ Exponential backoff with jitter — backs off and spreads retry load
async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
}
): Promise<T> {
const { maxRetries, baseDelayMs, maxDelayMs } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
if (!isRetryable(error)) throw error;
const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * exponentialDelay;
const delay = Math.min(exponentialDelay + jitter, maxDelayMs);
await sleep(delay);
}
}
throw new Error('Unreachable');
}El jitter evita el efecto thundering herd
Sin jitter, todos los clientes reintentan al mismo tiempo, lo que genera una ráfaga sincronizada que satura al servicio que se está recuperando.
// Without jitter: 1000 clients all retry at exactly t+1s, t+2s, t+4s
// With jitter: 1000 clients retry spread over [0-1s], [0-2s], [0-4s]
function calculateBackoff(attempt: number, baseMs: number, maxMs: number): number {
// Full jitter — recommended by AWS
const ceiling = Math.min(maxMs, baseMs * Math.pow(2, attempt));
return Math.random() * ceiling;
}
// Decorrelated jitter — even better spread
function decorrelatedJitter(
previousDelay: number,
baseMs: number,
maxMs: number
): number {
return Math.min(maxMs, baseMs + Math.random() * (previousDelay * 3 - baseMs));
}Presupuestos de reintentos
En una cadena de microservicios, los reintentos se multiplican. Si A llama a B con 3 reintentos y B llama a C con 3 reintentos, un fallo en C puede generar hasta 9 solicitudes desde B y 27 desde A. Esta amplificación puede convertir un fallo pequeño en una sobrecarga de todo el sistema.
class RetryBudget {
private attempts = 0;
private successes = 0;
// Only retry if retry rate is below threshold
canRetry(): boolean {
if (this.attempts === 0) return true;
const retryRate = 1 - (this.successes / this.attempts);
return retryRate < 0.2; // Max 20% retry rate
}
recordAttempt(): void {
this.attempts++;
}
recordSuccess(): void {
this.successes++;
}
// Reset counters periodically
reset(): void {
this.attempts = 0;
this.successes = 0;
}
}Un presupuesto de reintentos limita la tasa total de reintentos de todas las solicitudes, no por solicitud individual. Cuando el sistema está sano, el presupuesto es generoso. Cuando los fallos se disparan, el presupuesto se restringe, evitando así tormentas de reintentos.
Idempotencia para reintentos seguros
Los reintentos solo son seguros si la operación es idempotente — es decir, si ejecutarla dos veces produce el mismo resultado que ejecutarla una sola vez. Sin idempotencia, los reintentos pueden duplicar cobros, crear registros duplicados o enviar correos repetidos.
// ❌ Not idempotent — retry creates duplicate payment
app.post('/charge', async (req, res) => {
const result = await paymentProvider.charge(req.body.amount);
await db.payments.create({ amount: req.body.amount, txId: result.id });
return res.json({ success: true });
});
// ✅ Idempotent — uses idempotency key to prevent duplicates
app.post('/charge', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'] as string;
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key header required' });
}
// Check if this request was already processed
const existing = await db.idempotencyKeys.findUnique({
where: { key: idempotencyKey },
});
if (existing) {
return res.json(existing.response);
}
const result = await paymentProvider.charge(req.body.amount);
const response = { success: true, txId: result.id };
await db.idempotencyKeys.create({
data: { key: idempotencyKey, response },
});
return res.json(response);
});Jerarquía de timeouts en cadenas de servicios
En una cadena de llamadas (API Gateway → servicio A → servicio B → base de datos), los timeouts deben ir disminuyendo en cada salto. Si el servicio A tiene un timeout de 5 s y el servicio B tiene uno de 10 s, B podría seguir trabajando cuando A ya se ha rendido — desperdiciando recursos.
// Service chain timeout configuration
const timeouts = {
apiGateway: 10_000, // 10s — highest timeout, user-facing
serviceA: 7_000, // 7s — must finish before gateway timeout
serviceB: 4_000, // 4s — must finish before A's timeout
database: 2_000, // 2s — must finish before B's timeout
};
// Each level leaves headroom for retries and processing
// Gateway (10s) > A (7s) > B (4s) > DB (2s)Propagación del deadline
En lugar de que cada servicio configure timeouts de forma independiente, propaga un deadline desde la solicitud original.
interface RequestContext {
deadline: number; // Unix timestamp when the entire chain must complete
correlationId: string;
}
async function callDownstream(ctx: RequestContext, url: string): Promise<Response> {
const remainingMs = ctx.deadline - Date.now();
if (remainingMs <= 0) {
throw new DeadlineExceeded('No time remaining for downstream call');
}
// Use remaining time as timeout, with safety margin
const timeout = Math.max(remainingMs - 100, 0);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
return await fetch(url, {
headers: {
'X-Request-Deadline': String(ctx.deadline),
'X-Correlation-Id': ctx.correlationId,
},
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}La propagación del deadline garantiza que los servicios downstream no empiecen un trabajo que no podrán terminar antes de que quien los llamó se rinda.
Puntos clave
- Toda llamada de red necesita un timeout — un timeout ausente convierte un fallo transitorio en un agotamiento de recursos permanente
- Basa los timeouts en el p99, no en el promedio — configúralos entre 2 y 3 veces la latencia p99 observada
- Usa siempre exponential backoff con jitter — evita el efecto thundering herd sobre el servicio que se recupera
- Los presupuestos de reintentos evitan la amplificación — limitan la tasa total de reintentos, no solo los reintentos por solicitud
- Los reintentos requieren idempotencia — sin ella, los reintentos duplican efectos secundarios
- Los timeouts disminuyen a lo largo de la cadena de llamadas — y la propagación del deadline lo garantiza automáticamente


