Lección 9 de 912 min
Patrones de resiliencia
Circuit Breaker, reintentos con backoff, Bulkhead y Saga: sistemas que aguantan cuando algo falla.
El servicio de recomendaciones de tu tienda empieza a tardar 30 segundos en responder. Cada página de producto lo llama y se queda esperando. En pocos minutos, todas las conexiones del servidor están ocupadas esperando, y la tienda entera deja de responder, incluido el pago, que no tiene nada que ver con las recomendaciones.
En un sistema distribuido, siempre hay algo fallando. Los patrones de resiliencia no evitan los fallos: impiden que un fallo pequeño se convierta en una caída general, el llamado fallo en cascada.
Cuando una dependencia falla
Timeouts
Ponle siempre un límite de tiempo a cualquier llamada que salga de tu proceso.
Una llamada por la red puede quedarse esperando muchísimo tiempo. Un timeout es el tiempo máximo que aceptas esperar una respuesta antes de darte por vencido y liberar los recursos. Es la base de todos los demás patrones: sin timeout, un servicio lento es peor que uno caído, porque te retiene.
En JavaScript, fetch no tiene una opción de timeout, pero puedes pasarle signal: AbortSignal.timeout(ms). Elige el valor a partir de lo que tarda de verdad la dependencia (algo por encima de lo que tardan casi todas sus respuestas), no con un número redondo al azar.
Úsalo cuando
Siempre: en llamadas HTTP, consultas a bases de datos, cachés y brokers. Ninguna llamada por la red debería poder esperar indefinidamente.
Ten en cuenta
Un timeout demasiado bajo convierte en fallos peticiones que habrían ido bien; uno demasiado alto no protege. Y que se agote el tiempo no significa que el otro lado no hiciera el trabajo: el cobro pudo realizarse.
export async function recomendaciones(productoId: string) {
try {
const respuesta = await fetch(`https://recomendaciones.interno/api/${productoId}`, {
signal: AbortSignal.timeout(800), // como mucho 800 ms
});
return await respuesta.json();
} catch {
return []; // sin recomendaciones, pero la página sigue funcionando
}
}Reintentos con backoff
Repetir una llamada fallida esperando cada vez más, con algo de azar, si es segura.
- Solo para:
- Operaciones idempotentes
Muchos fallos son pasajeros: un paquete perdido, un servidor que se reinicia, un pico momentáneo. Reintentar los resuelve sin que el usuario se entere. Pero reintentar enseguida y todos a la vez empeora el problema: si un servicio está saturado, miles de clientes reintentando al instante lo hunden del todo.
Por eso se espera con backoff exponencial (200 ms, luego 400, 800…) y se añade jitter, un componente aleatorio en la espera, para que los clientes no reintenten sincronizados. Se limita el número de intentos y solo se reintentan errores pasajeros (un timeout, un 503, un 429), nunca un 400 o un 404, que volverán a fallar igual.
Y lo más importante: solo se reintentan operaciones idempotentes, las que tienen el mismo efecto si se ejecutan dos veces, como un GET o un PUT. Reintentar «cobrar 50 €» puede cobrar dos veces; si necesitas reintentarlo, envía una clave de idempotencia que el servidor reconozca para no repetir el cobro.
Úsalo cuando
Ante fallos pasajeros en llamadas idempotentes a otros servicios, bases de datos o APIs externas.
Ten en cuenta
Los reintentos multiplican la carga: si tres capas reintentan tres veces cada una, una sola petición puede convertirse en 27. Reintenta en una sola capa y combínalo con un circuit breaker.
export async function conReintentos<T>(operacion: () => Promise<T>, intentos = 4) {
for (let i = 1; ; i++) {
try {
return await operacion();
} catch (error) {
if (i === intentos || !esPasajero(error)) throw error;
const tope = 100 * 2 ** i; // 200, 400, 800… ms
const espera = Math.random() * tope; // jitter: entre 0 y el tope
await new Promise((r) => setTimeout(r, espera));
}
}
}Circuit Breaker
Cortar las llamadas a un servicio que falla y fallar al instante hasta que se recupere.
- Estados:
- Cerrado, abierto, semiabierto
- Bibliotecas:
- Resilience4j, Polly, opossum
Funciona como el interruptor diferencial de una casa: si detecta un problema, salta y deja de pasar corriente. Un circuit breaker (cortacircuitos) envuelve las llamadas a una dependencia y tiene tres estados. En cerrado, las llamadas pasan con normalidad y se cuentan los fallos. Si superan un umbral, pasa a abierto: durante un tiempo, rechaza las llamadas al instante, sin intentarlo siquiera.
Pasado ese tiempo, pasa a semiabierto (half-open) y deja pasar una llamada de prueba. Si sale bien, vuelve a cerrado; si falla, se abre otra vez. Fallar rápido protege a los dos lados: tu servicio no se queda bloqueado esperando y el servicio enfermo deja de recibir tráfico mientras se recupera.
Úsalo cuando
En llamadas a dependencias remotas que pueden caerse o ralentizarse, sobre todo si tienes una alternativa razonable: datos en caché, un valor por defecto u ocultar esa sección de la página.
Ten en cuenta
Ajustar el umbral y el tiempo de apertura exige conocer la dependencia: muy sensible, salta con el ruido normal; muy permisivo, no protege. Muchas implementaciones cuentan el porcentaje de fallos en una ventana de tiempo, no solo los fallos seguidos.
class Cortacircuitos {
private estado: "cerrado" | "abierto" | "semiabierto" = "cerrado";
private fallos = 0;
private reabrirEn = 0;
constructor(private umbral = 5, private esperaMs = 30_000) {}
async llamar<T>(operacion: () => Promise<T>): Promise<T> {
if (this.estado === "abierto" && Date.now() < this.reabrirEn) {
throw new Error("Circuito abierto"); // fallo rápido: ni se llama al servicio
}
if (this.estado === "abierto") this.estado = "semiabierto"; // llamada de prueba
try {
const resultado = await operacion();
this.estado = "cerrado";
this.fallos = 0;
return resultado;
} catch (error) {
if (this.estado === "semiabierto" || ++this.fallos >= this.umbral) {
this.estado = "abierto";
this.reabrirEn = Date.now() + this.esperaMs;
}
throw error;
}
}
}Bulkhead
Compartimentos estancos: limitar los recursos de cada dependencia por separado.
- Origen del nombre:
- Los mamparos de un barco
El nombre viene de los barcos: el casco se divide con mamparos (bulkheads) en compartimentos estancos, para que una vía de agua inunde solo uno y el barco siga a flote. En software, un bulkhead reparte los recursos limitados (conexiones, hilos, peticiones simultáneas) en grupos separados por dependencia o por tipo de cliente.
Si el servicio de recomendaciones se vuelve lento, solo puede ocupar, por ejemplo, 10 peticiones simultáneas. La undécima se rechaza al momento, y el resto de los recursos sigue libre para los pagos o el carrito. Sin compartimentos, las peticiones lentas acapararían todas las conexiones.
Úsalo cuando
Cuando un servicio llama a varias dependencias de importancia distinta, o atiende a clientes que no deben perjudicarse entre sí, como el tráfico de un cliente enorme frente al del resto.
Ten en cuenta
Repartir los recursos en compartimentos fijos puede desaprovecharlos cuando todo va bien. Ajusta los límites con datos reales y vigila cuántas peticiones se rechazan.
function compartimento(maximo: number) {
let enCurso = 0;
return async <T>(operacion: () => Promise<T>): Promise<T> => {
if (enCurso >= maximo) throw new Error("Compartimento lleno");
enCurso++;
try {
return await operacion();
} finally {
enCurso--;
}
};
}
// Cada dependencia tiene su propio cupo de peticiones simultáneas
const paraRecomendaciones = compartimento(10);
const paraPagos = compartimento(50);Se usan juntos
El timeout limita cada intento, los reintentos absorben los fallos pasajeros, el bulkhead limita cuánto puede acaparar una dependencia y el circuit breaker deja de insistir cuando el fallo ya no es pasajero. Pruébalo en el simulador: sube la tasa de fallos, llama varias veces y fíjate en el tiempo de espera acumulado antes y después de que salte el circuito.
Interactivo
Un cortacircuitos delante de un servicio que falla
Decide cuánto falla el servicio remoto, llama y avanza el reloj. Observa cuándo salta el circuito y cuánto tiempo de espera se ahorra al fallar rápido.
Cerrado → abierto: se alcanza el umbral. Abierto → semiabierto: pasa el tiempo. Semiabierto → cerrado si la prueba sale bien, o → abierto si falla.
Cerrado: las llamadas llegan al servicio. Llevas 0 fallos seguidos; al llegar a 3, el circuito se abrirá.
- Llamadas al servicio
- 0
- Éxitos
- 0
- Fallos
- 0
- Rechazadas al instante
- 0
- Tiempo total esperando
- 0,0 s
Registro (lo más reciente arriba)
Aún no has hecho ninguna llamada. Empieza con «Llamar 5 veces».
Compara el tiempo de espera de un fallo (2 s retenidos hasta el timeout) con el de un rechazo (0 s). Con cientos de usuarios por segundo, fallar rápido es lo que evita que tu servicio se quede sin conexiones libres esperando a otro que no responde, y deja al servicio enfermo sin tráfico mientras se recupera.
Transacciones que cruzan servicios
Saga
Una transacción larga dividida en pasos locales, cada uno con su compensación.
- Coordinación:
- Orquestada o coreografiada
Con una sola base de datos, crear un pedido, cobrar y programar el envío cabría en una transacción. Con microservicios, cada paso ocurre en un servicio con su propia base de datos y no hay transacción común. Una saga encadena transacciones locales: cada servicio hace su parte y la confirma. Si un paso falla, se ejecutan acciones de compensación que deshacen los pasos anteriores en orden inverso: liberar el stock, devolver el cobro.
Se coordina de dos formas. En una saga orquestada, un orquestador central dice a cada servicio qué hacer y decide el siguiente paso. En una saga coreografiada no hay jefe: cada servicio escucha eventos y reacciona («StockReservado» hace que Pagos cobre; «PagoRechazado» hace que Inventario libere el stock).
Compensar no es lo mismo que deshacer: un correo enviado no se puede desenviar, así que se envía otro con la cancelación. Y mientras la saga avanza, los demás pueden ver estados intermedios, como un pedido «pendiente de pago», que hay que diseñar.
Úsalo cuando
En procesos de negocio que atraviesan varios servicios con bases de datos propias: reservas de viajes (vuelo, hotel, coche), pedidos con pago y envío, altas que tocan varios sistemas.
Ten en cuenta
Las compensaciones también pueden fallar, así que deben poder reintentarse sin efectos dobles. Las sagas coreografiadas con muchos pasos se vuelven difíciles de seguir: a partir de cierto tamaño, un orquestador da más visibilidad.
export async function sagaPedido(pedido: Pedido) {
const compensaciones: Array<() => Promise<void>> = [];
try {
await inventario.reservar(pedido);
compensaciones.push(() => inventario.liberar(pedido));
await pagos.cobrar(pedido);
compensaciones.push(() => pagos.reembolsar(pedido));
await envios.programar(pedido);
} catch (error) {
// Deshace en orden inverso lo que ya se hizo
for (const compensar of compensaciones.reverse()) await compensar();
throw error;
}
}¿Sabías que…?
El término viene de un artículo de 1987 de Hector Garcia-Molina y Kenneth Salem sobre transacciones de larga duración en bases de datos, mucho antes de que existieran los microservicios.
Interactivo
Saga orquestada o coreografiada
Activa o desactiva opciones para compararlas cara a cara.
| Característica | Orquestada | Coreografiada |
|---|---|---|
| Quién decide el siguiente paso | Un orquestador central | Cada servicio, al reaccionar a eventos |
| Acoplamiento | Los servicios no se conocen; el orquestador los conoce a todos | Bajo: solo comparten eventos |
| Visibilidad del flujo | Alta: el flujo está en un solo sitio | Baja: el flujo está repartido |
| Riesgo principal | Que el orquestador concentre demasiada lógica | Ciclos de eventos y flujos difíciles de seguir |
| Encaja cuando | Hay muchos pasos o el flujo cambia a menudo | Hay pocos pasos y equipos muy autónomos |
Ponlo en práctica
Saber qué es cada cosa es la mitad del trabajo. La otra mitad es reconocer cuándo usarla.
Interactivo
¿Qué usarías?
Lee cada situación y elige la opción que mejor la resuelve. Fallar aquí no cuenta: es para practicar.
Tu servicio llama a una API de tipos de cambio que a veces tarda minutos en responder, y mientras tanto los usuarios esperan con la página colgada.
Para recordar
- Sin timeout, un servicio lento es peor que uno caído: limita siempre el tiempo de espera.
- Reintenta solo errores pasajeros y operaciones idempotentes, con backoff exponencial y jitter.
- Un circuit breaker abierto falla al instante: protege tus recursos y da respiro al servicio enfermo, y el estado semiabierto comprueba si ya se ha recuperado.
- Un bulkhead separa los recursos por dependencia para que un fallo no los agote todos.
- Una saga sustituye la transacción distribuida por pasos locales y compensaciones, orquestadas o coreografiadas.
Comprueba lo aprendido
Cuestionario
Responde las 5 preguntas para completar la lección.
1.Un circuit breaker está abierto. ¿Qué pasa con una llamada nueva?
2.¿Para qué sirve el estado semiabierto?
3.¿Por qué se añade jitter al backoff exponencial?
4.¿Cuál de estas operaciones no deberías reintentar sin más?
5.En una saga, el pago falla después de haber reservado el stock. ¿Qué ocurre?