🏗️ Arquitectura & Sistemas 🔌 Backend & APIs

Patrones de resiliencia: circuit breakers, retries y timeouts explicados

Un servicio lento en el punto equivocado puede arrastrar a todo el sistema si nadie corta la llamada a tiempo. Timeouts, retries con backoff y jitter, y circuit breakers explicados con criterio de producción, no de diapositiva.

📅 22 de septiembre de 2026 ⏱️ 20 min de lectura ✍️ Equipo ProgramacionWebs

Un servicio de recomendaciones empieza a responder despacio. No se ha caído: responde, pero tarda ocho segundos en lugar de cien milisegundos. El servicio que lo llama no tiene timeout configurado, así que cada petición se queda esperando esos ocho segundos con un hilo o una conexión bloqueada. En cuestión de minutos, ese servicio agota su propio pool de conexiones esperando respuestas que sí llegan, solo que tarde. Los servicios que dependen de él empiezan a fallar también, no porque tengan ningún problema propio, sino porque están esperando a alguien que está esperando a alguien que va lento. Ese es el mecanismo exacto de una falla en cascada: no hace falta que nada se rompa del todo para tumbar un sistema completo, basta con que algo vaya lento en el punto equivocado y que nadie haya puesto un límite.

El libro de Site Reliability Engineering de Google dedica un capítulo entero a este patrón porque es, con diferencia, una de las causas más comunes de incidentes graves en sistemas distribuidos: la causa raíz casi nunca es el fallo original, sino la reacción en cadena que ese fallo desencadena en el resto del sistema. Los tres patrones de este artículo —timeouts, retries con backoff y circuit breakers— existen precisamente para cortar esa reacción en cadena antes de que se propague, y son la contraparte obligatoria de cualquier arquitectura que, como explicamos en el artículo sobre cuándo tienen sentido los microservicios, cambia llamadas de función por llamadas de red.

Por qué esto no es opcional en cuanto hay una llamada de red

Dentro de un mismo proceso, una llamada a una función no falla a medias ni se queda colgada indefinidamente: o devuelve un resultado, o lanza una excepción, casi siempre en microsegundos. En cuanto esa llamada cruza la red hacia otro servicio, esas garantías desaparecen. Una petición HTTP puede tardar 5 milisegundos o 50 segundos, puede caerse a mitad de respuesta, puede llegar a su destino pero perderse la respuesta de vuelta, o puede quedarse esperando indefinidamente porque el servicio remoto está saturado y ni siquiera ha empezado a procesarla.

Ninguno de estos patrones evita que ese fallo remoto ocurra. Lo que hacen es decidir, de forma explícita y configurada de antemano, qué hace tu sistema cuando ocurre: cuánto tiempo espera antes de rendirse (timeout), si tiene sentido intentarlo de nuevo y cómo (retry con backoff), y cuándo dejar de intentarlo por completo durante un tiempo porque insistir solo empeora las cosas (circuit breaker). Los tres trabajan en capas distintas del mismo problema y, como se ve más adelante, se combinan entre sí en lugar de sustituirse.

Timeouts: el límite que casi nunca está bien puesto

Un timeout es la decisión más básica de las tres y, paradójicamente, la que más se hace mal. Hay dos timeouts distintos que conviene distinguir, porque protegen de fallos distintos:

  • Timeout de conexión: cuánto tiempo se espera a establecer la conexión TCP/TLS con el servicio remoto antes de rendirse. Protege contra un host que no responde en absoluto.
  • Timeout de petición (o de lectura): cuánto tiempo se espera, una vez establecida la conexión, a recibir la respuesta completa. Protege contra un servicio que acepta la conexión pero procesa la petición muy despacio.

La guía de Microsoft sobre el patrón Circuit Breaker señala el problema con precisión: si el timeout es demasiado largo, un hilo que ejecuta la operación puede quedar bloqueado durante un periodo prolongado, y mientras tanto otras instancias de la aplicación siguen intentando invocar el mismo servicio, agotando recursos —hilos, memoria, conexiones— que no tienen nada que ver con el servicio que falló. Ese es exactamente el mecanismo del ejemplo del principio: no fue el servicio lento el que tumbó el sistema, fue la ausencia de un límite que impidiera que sus llamadas se acumularan.

El presupuesto de tiempo se hereda, no se multiplica

Un error frecuente en cadenas de llamadas —A llama a B, que llama a C— es que cada servicio configure su propio timeout de forma aislada, sin pensar en el conjunto. Si A tiene un timeout de 3 segundos hacia B, y B tiene un timeout de 5 segundos hacia C, B puede seguir esperando a C durante 2 segundos después de que A ya haya abandonado la petición y haya devuelto un error al usuario. El trabajo de C en esos 2 segundos finales es, literalmente, trabajo desperdiciado: nadie va a leer su respuesta. La práctica correcta es propagar un presupuesto de tiempo (deadline) desde el origen de la petición hacia cada llamada posterior, de forma que cada servicio en la cadena sepa cuánto tiempo le queda realmente, no cuánto tiempo tiene en abstracto.

Retries: cuándo repetir una petición y cuándo es directamente peligroso

Reintentar una petición fallida parece la respuesta obvia a un fallo transitorio, y muchas veces lo es. El problema es que “muchas veces” no es “siempre”, y la diferencia entre ambos casos es la pregunta más importante de todo este artículo: ¿es seguro ejecutar esta operación más de una vez?

Idempotencia: la propiedad que decide si puedes reintentar

Una operación es idempotente si ejecutarla varias veces produce el mismo efecto que ejecutarla una sola vez. El RFC 9110, que define la semántica de HTTP, es explícito sobre qué métodos tienen esta garantía por diseño: GET, HEAD, PUT, DELETE, OPTIONS y TRACE son idempotentes; POST y PATCH, en general, no lo son. Consultar un recurso dos veces no cambia nada; borrar el mismo recurso dos veces deja el sistema en el mismo estado que borrarlo una vez; pero crear un pedido con POST dos veces —si la segunda petición era en realidad un reintento de la primera, que sí se procesó pero cuya respuesta se perdió por el camino— puede significar cobrar dos veces la misma compra.

Este matiz es la razón por la que la biblioteca de ingeniería de Amazon dedica un artículo entero a explicar cómo hacer retries seguros incluso en operaciones que no son idempotentes por naturaleza: la solución no es “no reintentar nunca un POST”, porque eso deja sin proteger justo las operaciones más críticas. La solución es hacer esas operaciones idempotentes de forma explícita, con una clave de idempotencia.

Stripe popularizó este patrón para pagos con una explicación que resume bien el objetivo: el cliente genera un identificador único para la operación (normalmente un UUID) y lo envía en una cabecera; el servidor guarda el resultado de la primera petición asociado a esa clave, y si recibe una segunda petición con la misma clave, devuelve el resultado guardado en lugar de repetir la operación —incluida la respuesta, aunque esta haya sido un error.

import { randomUUID } from 'node:crypto';

interface ResultadoGuardado {
  status: number;
  body: unknown;
}

// Almacén simplificado (en producción: Redis o una tabla con TTL)
const idempotencyStore = new Map<string, ResultadoGuardado>();

app.post('/pagos', async (req, res) => {
  const idempotencyKey = req.header('Idempotency-Key');
  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Falta la cabecera Idempotency-Key' });
  }

  const previo = idempotencyStore.get(idempotencyKey);
  if (previo) {
    // Misma clave ya procesada: devolvemos el resultado guardado,
    // sin volver a ejecutar el cargo.
    return res.status(previo.status).json(previo.body);
  }

  try {
    const pago = await procesarPago(req.body);
    idempotencyStore.set(idempotencyKey, { status: 200, body: pago });
    return res.status(200).json(pago);
  } catch (error) {
    const respuestaError = { error: 'No se pudo procesar el pago' };
    idempotencyStore.set(idempotencyKey, { status: 502, body: respuestaError });
    return res.status(502).json(respuestaError);
  }
});

// El cliente que reintenta reutiliza la misma clave, no genera una nueva.
// conReintentos() se define más abajo, en la sección sobre backoff con jitter.
async function crearPagoConReintento(datos: unknown) {
  const idempotencyKey = randomUUID();
  return conReintentos(
    () =>
      fetch('/pagos', {
        method: 'POST',
        headers: { 'Idempotency-Key': idempotencyKey, 'Content-Type': 'application/json' },
        body: JSON.stringify(datos),
      }),
    { maxAttempts: 3, baseDelayMs: 200, maxDelayMs: 2000 }
  );
}

Guardar también las respuestas de error es deliberado: si no lo haces, un cliente que reintenta tras un error de verdad (datos inválidos) puede acabar generando un segundo intento de cargo si el error fue transitorio en el servidor pero el efecto secundario ya se había producido parcialmente. Guardar el resultado completo, sea éxito o fallo, es lo que hace que la clave de idempotencia cumpla su función también en los casos límite.

Qué NO conviene reintentar nunca

  • Errores 4xx que no sean 408 o 429. Un 400 Bad Request significa que la petición está mal formada; reenviarla exactamente igual produce exactamente el mismo error. Un 401 o 403 significa que faltan credenciales válidas; reintentar no las va a crear. La única excepción real entre los 4xx son el 408 Request Timeout y el 429 Too Many Requests, que sí son transitorios por naturaleza.
  • Operaciones con efectos secundarios sin idempotencia garantizada. Enviar un correo, cobrar una tarjeta o crear un recurso sin clave de idempotencia y sin haber verificado si la petición original llegó a procesarse.
  • Reintentos sin ningún límite de intentos o de tiempo total. Un bucle que reintenta indefinidamente sin backoff ni límite máximo es, según la documentación de Microsoft sobre el antipatrón retry storm, una de las formas más directas de convertir un fallo puntual en una sobrecarga sostenida sobre un servicio que ya estaba luchando por recuperarse.

Backoff exponencial con jitter: por qué reintentar mal es peor que no reintentar

Reintentar inmediatamente después de un fallo rara vez tiene sentido: si el servicio remoto está saturado, una petición que llega un milisegundo después de la anterior encuentra exactamente la misma saturación. El backoff exponencial espacia los reintentos, duplicando (u otro factor) el tiempo de espera en cada intento sucesivo, dando tiempo real al servicio remoto para recuperarse.

El problema que el backoff exponencial simple no resuelve por sí solo es la sincronización entre clientes. Si cien clientes distintos fallan a la vez —por ejemplo, porque el servicio remoto tuvo una caída de un segundo— y todos aplican exactamente la misma fórmula de backoff, sus reintentos seguirán llegando sincronizados, en oleadas cada vez más espaciadas pero igual de concentradas, en lugar de repartidos. El equipo de arquitectura de AWS documentó este efecto con datos concretos en un artículo ya clásico: añadir aleatoriedad (jitter) al tiempo de espera reduce el trabajo total del sistema en más de un 50% frente al mismo backoff sin aleatoriedad, precisamente porque rompe esa sincronización y convierte las oleadas de reintentos en un flujo mucho más uniforme.

Hay tres variantes de jitter con comportamientos distintos:

// Full jitter: la variante recomendada por defecto en el artículo de AWS
function fullJitter(intento: number, baseMs: number, capMs: number): number {
  const backoffMaximo = Math.min(capMs, baseMs * 2 ** intento);
  return Math.random() * backoffMaximo;
}

// Equal jitter: mantiene un suelo de espera, con menos variabilidad
function equalJitter(intento: number, baseMs: number, capMs: number): number {
  const backoffMaximo = Math.min(capMs, baseMs * 2 ** intento);
  return backoffMaximo / 2 + Math.random() * (backoffMaximo / 2);
}

// Decorrelated jitter: cada espera depende de la anterior, no solo del intento
function decorrelatedJitter(esperaPrevia: number, baseMs: number, capMs: number): number {
  return Math.min(capMs, baseMs + Math.random() * (esperaPrevia * 3 - baseMs));
}

Full jitter es la que AWS recomienda como opción por defecto: es la más simple y la que mejor reparte la carga en sus propias pruebas. Equal jitter sacrifica algo de esa dispersión a cambio de garantizar un tiempo mínimo de espera en cada intento, útil cuando reintentar demasiado pronto tiene un coste conocido. Decorrelated jitter introduce dependencia entre esperas sucesivas y en la práctica produce un comportamiento similar a full jitter con algo más de variabilidad en el tiempo total.

interface RetryOptions {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
}

function esReintentable(status: number | undefined): boolean {
  if (status === undefined) return true; // error de red, sin respuesta
  return status === 408 || status === 429 || status >= 500;
}

async function conReintentos(
  operacion: () => Promise<Response>,
  { maxAttempts, baseDelayMs, maxDelayMs }: RetryOptions
): Promise<Response> {
  let intento = 0;
  while (true) {
    const respuesta = await operacion();
    if (respuesta.ok || !esReintentable(respuesta.status)) {
      return respuesta;
    }

    intento += 1;
    if (intento >= maxAttempts) {
      return respuesta;
    }

    // Si el servidor indica cuándo reintentar, esa cabecera manda sobre el backoff local
    const retryAfter = respuesta.headers.get('Retry-After');
    const esperaMs = retryAfter
      ? Number(retryAfter) * 1000
      : fullJitter(intento, baseDelayMs, maxDelayMs);

    await new Promise((resolve) => setTimeout(resolve, esperaMs));
  }
}

Respetar la cabecera Retry-After cuando el servidor la envía —algo habitual en respuestas 429, como se detalla en el artículo sobre rate limiting y protección de APIs— tiene prioridad sobre cualquier cálculo de backoff local: el servidor conoce su propia capacidad de recuperación mejor que cualquier cliente adivinando desde fuera.

Circuit breaker: dejar de insistir antes de que la insistencia empeore las cosas

Los retries con backoff resuelven fallos puntuales y transitorios. No resuelven un fallo sostenido: si un servicio lleva caído dos minutos, seguir reintentando cada petición individual —aunque sea con backoff y jitter correctos— sigue generando tráfico constante hacia un servicio que necesita tiempo sin carga para recuperarse. El circuit breaker añade una capa de memoria que los retries no tienen: recuerda que el servicio remoto ha estado fallando y, a partir de un umbral, deja de intentarlo directamente durante un tiempo, devolviendo el fallo de inmediato sin siquiera hacer la llamada de red.

El patrón se modela como una máquina de estados con tres posiciones, tal como lo documenta la guía de Microsoft sobre el patrón:

stateDiagram-v2
[*] --> Closed
state "Half-Open" as HalfOpen
Closed --> Open: fallos consecutivos >= umbral
Open --> HalfOpen: expira el temporizador de espera
HalfOpen --> Closed: éxitos consecutivos >= umbral
HalfOpen --> Open: cualquier fallo durante la prueba
Closed --> Closed: éxito (reinicia el contador de fallos)
  • Closed (cerrado): estado normal. Las peticiones pasan hacia el servicio real. El circuito cuenta los fallos recientes; si superan un umbral dentro de una ventana de tiempo, el circuito se abre.
  • Open (abierto): las peticiones fallan de inmediato, sin llegar a intentar la llamada de red. Esto es lo que de verdad protege al sistema: ni el cliente desperdicia tiempo esperando un timeout, ni el servicio remoto recibe tráfico adicional mientras intenta recuperarse. Tras un periodo configurado, el circuito pasa a semiabierto.
  • Half-open (semiabierto): se permite pasar un número limitado de peticiones de prueba. Si tienen éxito, el circuito asume que el servicio se ha recuperado y vuelve a closed. Si cualquiera falla, vuelve a open y reinicia el temporizador de espera.

El estado semiabierto es el detalle que más se pasa por alto al implementar este patrón desde cero, y es precisamente el que evita un segundo problema: si el circuito pasara directamente de open a closed sin ninguna prueba intermedia, un servicio que se está recuperando pero todavía no soporta toda su carga normal recibiría de golpe el tráfico acumulado de todos los clientes con el circuito abierto, lo que puede tumbarlo de nuevo justo cuando empezaba a levantarse.

Implementación en TypeScript

type CircuitState = 'closed' | 'open' | 'half-open';

interface CircuitBreakerOptions {
  failureThreshold: number;  // fallos consecutivos que abren el circuito
  successThreshold: number;  // éxitos consecutivos en half-open que lo cierran
  openDurationMs: number;    // tiempo que permanece abierto antes de probar
}

export class CircuitBreakerOpenError extends Error {
  constructor() {
    super('Circuit breaker abierto: la operación no se ha intentado');
    this.name = 'CircuitBreakerOpenError';
  }
}

export class CircuitBreaker<T> {
  private state: CircuitState = 'closed';
  private failureCount = 0;
  private successCount = 0;
  private nextAttemptAt = 0;

  constructor(
    private readonly operation: () => Promise<T>,
    private readonly options: CircuitBreakerOptions
  ) {}

  async execute(): Promise<T> {
    if (this.state === 'open') {
      if (Date.now() < this.nextAttemptAt) {
        throw new CircuitBreakerOpenError();
      }
      // El temporizador ha expirado: probamos con cautela, un intento a la vez
      this.state = 'half-open';
      this.successCount = 0;
    }

    try {
      const result = await this.operation();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess(): void {
    if (this.state === 'half-open') {
      this.successCount += 1;
      if (this.successCount >= this.options.successThreshold) {
        this.close();
      }
      return;
    }
    this.failureCount = 0; // en 'closed', un éxito reinicia el contador de fallos
  }

  private onFailure(): void {
    if (this.state === 'half-open') {
      this.trip();
      return;
    }
    this.failureCount += 1;
    if (this.failureCount >= this.options.failureThreshold) {
      this.trip();
    }
  }

  private trip(): void {
    this.state = 'open';
    this.nextAttemptAt = Date.now() + this.options.openDurationMs;
  }

  private close(): void {
    this.state = 'closed';
    this.failureCount = 0;
    this.successCount = 0;
  }

  getState(): CircuitState {
    return this.state;
  }
}
// Un circuito por dependencia externa concreta, nunca uno compartido entre servicios distintos
const facturacionBreaker = new CircuitBreaker(
  () => fetch('https://facturacion.interno/emitir').then((r) => {
    if (!r.ok) throw new Error(`Facturación respondió ${r.status}`);
    return r.json();
  }),
  { failureThreshold: 5, successThreshold: 2, openDurationMs: 30_000 }
);

Esta implementación es deliberadamente mínima para que se entienda el mecanismo completo. En producción, salvo que tengas un motivo concreto para escribir la tuya, conviene usar una librería madura: en el ecosistema Node.js, opossum es la referencia habitual, con soporte de porcentaje de error (en lugar de solo conteo), timeout integrado por operación y funciones de repliegue (fallback) configurables. En .NET, la biblioteca equivalente es Polly, mencionada explícitamente en la documentación de Microsoft como la opción recomendada frente a escribir la lógica de reintentos a mano.

Combinando los tres patrones: el orden importa

Estos tres patrones no compiten entre sí: se anidan. El orden habitual, de fuera hacia dentro, es circuit breaker envolviendo a los reintentos, y los reintentos envolviendo a una llamada con su propio timeout:

function fetchConTimeout(url: string, timeoutMs: number): Promise<Response> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer));
}

// El circuit breaker envuelve a los reintentos, y los reintentos envuelven
// a una llamada con timeout propio: las tres capas, cada una en su sitio.
const inventarioResiliente = new CircuitBreaker(
  () =>
    conReintentos(() => fetchConTimeout('https://inventario.interno/stock', 2000), {
      maxAttempts: 3,
      baseDelayMs: 100,
      maxDelayMs: 2000,
    }),
  { failureThreshold: 5, successThreshold: 2, openDurationMs: 30_000 }
);

async function consultarStock() {
  try {
    const respuesta = await inventarioResiliente.execute();
    return await respuesta.json();
  } catch (error) {
    if (error instanceof CircuitBreakerOpenError) {
      return { disponible: null, degradado: true }; // respuesta de repliegue
    }
    throw error;
  }
}

La razón de este orden es que cada capa protege a la siguiente de un problema distinto: el timeout evita que una sola llamada se quede colgada indefinidamente; los reintentos con backoff absorben fallos puntuales y transitorios sin que el llamador tenga que gestionarlos; y el circuit breaker, por encima de todo, detecta cuando el problema ya no es puntual sino sostenido, y deja de alimentar al servicio que está luchando por recuperarse. La documentación de Microsoft lo resume con una advertencia importante: cuando se combinan Retry y Circuit Breaker, la lógica de reintentos debe ser sensible a las excepciones que devuelve el circuit breaker y dejar de reintentar en cuanto el circuito indica que el fallo no es transitorio — de lo contrario, los reintentos seguirían golpeando un circuito ya abierto sin ningún beneficio.

Errores frecuentes al implementar estos patrones

  • Timeouts ausentes en llamadas internas. Es habitual poner cuidado en el timeout hacia una API externa de terceros y olvidarlo por completo en llamadas entre servicios propios, asumiendo —incorrectamente— que “es nuestra propia infraestructura, no debería fallar”.
  • Reintentar POST sin verificar idempotencia. El error más caro de la lista: sin una clave de idempotencia, un reintento de una operación que sí llegó a ejecutarse en el servidor, pero cuya respuesta se perdió en la red, duplica el efecto —doble cargo, doble pedido, doble correo.
  • Backoff sin jitter en sistemas con muchos clientes. Funciona bien en pruebas con un solo cliente y falla exactamente cuando más se necesita: durante un incidente real, con todos los clientes fallando y reintentando a la vez.
  • Un único circuit breaker compartido para dependencias distintas. Ya mencionado arriba, y sigue siendo uno de los errores de diseño más comunes al adoptar el patrón por primera vez.
  • Ignorar el Retry-After. Si el servicio remoto ya te está diciendo cuánto tiempo esperar, calcular tu propio backoff en paralelo es, en el mejor de los casos, redundante, y en el peor, contraproducente.
  • No medir nada. Un circuit breaker que se abre sin que nadie lo note en un panel de observabilidad es un fallo silencioso doble: el sistema se está degradando y el equipo no se entera hasta que un usuario se queja.

Cuándo estos patrones son innecesarios

Aplicar circuit breakers y retries con backoff a cada llamada de tu sistema, incluidas las que ocurren dentro del mismo proceso, es sobreingeniería. La propia documentación de Microsoft es explícita al respecto: el patrón no tiene sentido para gestionar acceso a recursos privados en memoria dentro de la misma aplicación, donde añade una sobrecarga que no protege de nada real. Tampoco aporta gran cosa cuando la recuperación del sistema depende de infraestructura externa —un balanceador de carga o un service mesh con sus propios health checks— que ya gestiona el failover a otro nodo sano.

La pregunta que separa el uso correcto del innecesario es la misma que en cualquier decisión de arquitectura de sistemas: ¿existe una llamada de red hacia un recurso que puede fallar de forma parcial o lenta, y ese fallo puede propagarse hacia el resto del sistema si nadie lo contiene? Si la respuesta es sí —como ocurre casi siempre al escalar un sistema más allá de un único servidor o al adoptar una arquitectura de varios servicios—, estos tres patrones dejan de ser un extra y pasan a ser tan básicos como manejar excepciones.

Recomendación práctica

Antes de dar por resuelta la resiliencia de un sistema, comprueba estos cuatro puntos, en este orden de prioridad:

  1. ¿Toda llamada de red tiene un timeout explícito? Sin esto, ningún otro patrón importa: un timeout ausente puede bloquear recursos indefinidamente por sí solo.
  2. ¿Sabes, para cada operación que reintentas, si es idempotente? Si no lo es y no tiene clave de idempotencia, no la reintentes sin antes resolver ese problema.
  3. ¿El backoff incluye jitter? Sin él, tu sistema se comporta bien en desarrollo y mal exactamente durante el incidente real que más te importa evitar.
  4. ¿Las dependencias externas críticas están protegidas por un circuit breaker propio, con métricas visibles de cuándo se abre? Si la respuesta es no, cualquier fallo sostenido en esa dependencia seguirá golpeándola sin descanso, y arrastrando contigo al resto del sistema.

Ninguno de estos patrones evita que las cosas fallen —eso es inevitable en cualquier sistema con más de un proceso—. Lo que hacen es la diferencia entre un fallo contenido que un usuario nota como una demora puntual, y una cascada que convierte un problema de un servicio en una caída de la plataforma entera.

Compartir