Ya vemos lo que ocurre en TechCorp: logs, métricas y trazas. Ahora toca lo que el curso lleva prometiendo desde 02-01 ("diseñar para el fallo") y que 03-01, 05-05 y 06-02 fueron remitiendo aquí: cómo se comporta un servicio cuando la red se degrada, una dependencia no responde, un mensaje no se puede procesar o una saga se queda a medias. En un monolito casi todos los fallos son excepciones dentro de un proceso; en microservicios el fallo es la norma —las ocho falacias de 01-02— y la pregunta que lo domina todo es la que planteó el timeout de crearPedido: "¿se ejecutó o no?". Esta lección convierte esa pregunta en un conjunto de patrones implementados en Node.js dentro de @techcorp/comun-http y en la topología de RabbitMQ.
Contenido
- Tipos de fallo en un sistema distribuido y la pregunta "¿se ejecutó o no?"
- Timeouts: todo lo que sale por red tiene límite y presupuesto
- Reintentos con backoff exponencial y jitter: qué reintentar y qué no
- Circuit breaker: cortar antes de arrastrar a los demás
- Bulkhead: aislar dependencias
- Fallback y degradación elegante; rate limiting y backpressure
- Fail-fast en el arranque, tolerancia en ejecución
- Recuperación en el mundo asíncrono: reintentos, colas con TTL y DLQ
- Sagas atascadas: el vigilante de
TIMEOUT_PAGOy la reconciliación - Manejo de errores en el código:
ErrorNegocio, promesas ySIGTERM - Pruebas de resiliencia y chaos engineering básico
- Tabla resumen: patrón → problema → dónde vive en TechCorp
- Tipos de fallo en un sistema distribuido y la pregunta "¿se ejecutó o no?"
| Tipo de fallo | Ejemplo en TechCorp | Qué lo distingue | Respuesta adecuada |
|---|---|---|---|
| Latencia | Catálogo responde en 1,8 s en vez de 40 ms | Funciona, pero lento; consume recursos del llamante | Timeout, y vigilar (06-01) |
| Timeout | Clientes no responde en 2 s | No sabemos si procesó | Cancelar, y solo reintentar si es idempotente |
| Error transitorio | ECONNRESET, 503 de Catálogo durante un rolling, deadlock en PostgreSQL |
Desaparece solo en segundos | Reintentar con backoff |
| Error permanente | 404 producto inexistente, 422 datos inválidos, bug | Reintentar no ayuda | Fallar rápido, devolver RFC 7807 |
| Fallo parcial | 1 de 3 réplicas de Catálogo devuelve errores | El balanceador reparte, un 33 % de peticiones falla | Circuit breaker por instancia u outlierDetection |
| Sobrecarga | Black Friday: 20× en Catálogo, colas creciendo | Todo va lento; reintentar empeora | Rate limiting, backpressure, escalar (06-04) |
| Fallo en cascada | Catálogo lento → Pedidos agota conexiones → gateway devuelve 504 a todo | Un fallo local se vuelve global | Timeouts + breaker + bulkhead |
El timeout es el caso que más confunde a quien viene del monolito. Cuando crearPedido llama a POST /v1/reservas de Inventario y el AbortSignal corta a los 2 s, hay tres realidades posibles: la petición no llegó; llegó y se procesó pero la respuesta no volvió; llegó y sigue procesándose. El código del llamante no puede distinguirlas. De ahí las tres herramientas que ya construimos: idempotencia en el receptor (Idempotency-Key, procesarUnaVez en 02-05), eventos con outbox en vez de llamadas síncronas para lo que cambia estado (la saga), y reconciliación para lo que se escape (apartado 9). Los patrones de esta lección se apoyan en esas bases; sin idempotencia, ninguno de ellos es seguro.
- Timeouts: todo lo que sale por red tiene límite y presupuesto
Ya lo hacíamos en 03-01: fetch(url, { signal: AbortSignal.timeout(2000) }) con TIMEOUT_HTTP_MS=2000. Lo elevamos a regla y le añadimos el concepto de presupuesto:
- Toda operación que sale por red tiene timeout: HTTP,
pg(statement_timeoutyconnectionTimeoutMillisen el pool), MongoDB (serverSelectionTimeoutMS,maxTimeMS), RabbitMQ (publishcon confirmación y tiempo límite). - El timeout de una llamada saliente debe ser menor que el que le queda al llamante. Si el gateway corta a los 5 s y Pedidos a los 2 s por dependencia con dos dependencias secuenciales, Pedidos ya ha gastado 4 s más lo suyo. Regla en TechCorp: gateway 5 s → Pedidos 3 s por petición → 2 s por dependencia con las llamadas en paralelo (06-02).
- Un timeout debe traducirse en algo que el llamante entienda:
ErrorNegocio('DEPENDENCIA_NO_DISPONIBLE', …, 503)conRetry-After, no un 500 anónimo.
En @techcorp/comun-http centralizamos el cliente:
// @techcorp/comun-http/src/clienteHttp.js
const { ErrorNegocio } = require('./errores');
function crearClienteHttp({ urlBase, timeoutMs = 2000, nombre }) {
async function peticion(ruta, { metodo = 'GET', cuerpo, requestId, idempotencyKey } = {}) {
const cabeceras = { 'content-type': 'application/json', 'x-request-id': requestId };
if (idempotencyKey) cabeceras['idempotency-key'] = idempotencyKey;
let res;
try {
res = await fetch(urlBase + ruta, { method: metodo, headers: cabeceras, body: cuerpo && JSON.stringify(cuerpo), signal: AbortSignal.timeout(timeoutMs) });
} catch (err) {
const transitorio = err.name === 'TimeoutError' || ['ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN'].includes(err.cause?.code);
throw new ErrorNegocio('DEPENDENCIA_NO_DISPONIBLE', `${nombre} no responde`, 503, { causa: err.name, transitorio });
}
if (res.status === 503 || res.status === 429) throw new ErrorNegocio('DEPENDENCIA_NO_DISPONIBLE', `${nombre} devolvió ${res.status}`, 503, { transitorio: true, retryAfter: res.headers.get('retry-after') });
if (!res.ok) throw new ErrorNegocio('DEPENDENCIA_ERROR', `${nombre} devolvió ${res.status}`, 502, { transitorio: false, status: res.status });
return res.json();
}
return { peticion };
}- El
catchdistingue fallo de red/timeout (transitorio) de cualquier otra cosa; los 503/429 del servidor también son transitorios; un 4xx distinto es permanente y no se reintentará. - La propiedad
transitorioen el error es la que consultan los patrones siguientes: el reintento y el breaker no adivinan, preguntan. catalogoClienteyclientesClientede 04-04 se reescriben sobrecrearClienteHttp(nombre: 'catalogo',urlBase: CATALOGO_URL).
- Reintentos con backoff exponencial y jitter: qué reintentar y qué no
Un reintento inmediato contra un servicio que se está recuperando es una pequeña denegación de servicio: cien clientes reintentando a la vez a los 100 ms exactos producen un pico sincronizado. Por eso el reintento se hace con espera exponencial (100, 200, 400 ms…) y aleatoria (jitter), y solo cuando (a) el error es transitorio y (b) la operación es idempotente:
// @techcorp/comun-http/src/reintentar.js
async function reintentar(fn, { intentos = 3, baseMs = 100, factor = 2, maxMs = 2000, jitter = true, reintentarSi = (err) => err.transitorio === true, alReintentar } = {}) {
let ultimoError;
for (let intento = 1; intento <= intentos; intento++) {
try {
return await fn(intento);
} catch (err) {
ultimoError = err;
if (intento === intentos || !reintentarSi(err)) throw err;
let espera = Math.min(maxMs, baseMs * factor ** (intento - 1)); // 100, 200, 400 …
if (jitter) espera = Math.random() * espera; // "full jitter": entre 0 y la espera
alReintentar?.({ intento, espera, err });
await new Promise((r) => setTimeout(r, espera));
}
}
throw ultimoError;
}
module.exports = { reintentar };intentoscuenta el primero: 3 intentos = 1 llamada + 2 reintentos.reintentarSipor defecto solo acepta errores marcadostransitorio; se puede pasar otra función.- Full jitter (aleatorio entre 0 y la espera calculada) es la variante que mejor reparte la carga según el estudio clásico de AWS.
alReintentarsirve para loguear enwarn(06-01) conintentoyespera.
Uso en catalogoCliente:
obtenerProductos: (ids, { requestId }) =>
reintentar(() => http.peticion(`/v1/productos?ids=${ids.join(',')}`, { requestId }),
{ intentos: 3, alReintentar: ({ intento, espera, err }) => logger.warn({ requestId, intento, espera, err }, 'reintentando catálogo') })Ojo con el presupuesto: 3 intentos × 2 s de timeout son hasta 6 s, más las esperas. O se baja el timeout por intento (700 ms) o se reducen los intentos: la suma debe caber en los 3 s que Pedidos tiene. Y la tabla de decisiones que hay que tener presente:
| Situación | ¿Reintentar? | Por qué |
|---|---|---|
GET /v1/productos → 503, 429, ECONNRESET, timeout |
Sí | Lectura idempotente, error transitorio |
GET /v1/clientes/{id} → 404 |
No | Permanente: el cliente no existe |
| Cualquier petición → 400, 422 | No | Nuestra petición está mal; repetirla da lo mismo |
| Cualquier petición → 500 | Con cuidado | Puede ser transitorio o un bug; máximo 1 reintento |
POST /v1/pedidos → timeout |
No, salvo con Idempotency-Key |
Podría crear dos pedidos; con la clave, el segundo devuelve el primero (03-01) |
POST /v1/reservas → timeout |
Sí, con Idempotency-Key = pedidoId |
Inventario ya lo hace idempotente por pedido |
INSERT en PostgreSQL → deadlock (40P01) |
Sí | Transitorio por definición; la transacción se reintenta entera |
| Publicar en RabbitMQ → canal cerrado | Sí (lo hace el relay en el siguiente ciclo) | El outbox garantiza que no se pierde |
- Circuit breaker: cortar antes de arrastrar a los demás
Si Catálogo está caído, cada POST /v1/pedidos espera 2 s, reintenta y vuelve a esperar: Pedidos acumula peticiones abiertas, agota conexiones y acaba fallando también. El circuit breaker observa la tasa de fallos hacia una dependencia y, cuando supera un umbral, deja de llamarla durante un tiempo, fallando al instante; después prueba con una petición y, si va bien, se cierra.
stateDiagram-v2 [*] --> CLOSED CLOSED --> OPEN: fallos alcanzan el umbral en la ventana OPEN --> HALF_OPEN: pasa tiempoAbiertoMs HALF_OPEN --> CLOSED: la petición de prueba tiene éxito HALF_OPEN --> OPEN: la petición de prueba falla CLOSED --> CLOSED: éxito (reinicia contador)
Implementación didáctica en la librería (en producción vale igual la biblioteca opossum, con la misma semántica y métricas incluidas):
// @techcorp/comun-http/src/circuitBreaker.js
const { ErrorNegocio } = require('./errores');
function crearCircuitBreaker({ nombre, umbralFallos = 5, ventanaMs = 10000, tiempoAbiertoMs = 30000, esFallo = (err) => err.transitorio !== false, alCambiar }) {
let estado = 'CLOSED';
let fallos = []; // marcas de tiempo de los fallos recientes
let abiertoHasta = 0;
function cambiar(nuevo) { if (estado !== nuevo) { estado = nuevo; alCambiar?.({ nombre, estado }); } }
async function ejecutar(fn) {
if (estado === 'OPEN') {
if (Date.now() < abiertoHasta) throw new ErrorNegocio('DEPENDENCIA_NO_DISPONIBLE', `${nombre}: circuito abierto`, 503, { transitorio: true, retryAfter: Math.ceil((abiertoHasta - Date.now()) / 1000) });
cambiar('HALF_OPEN');
}
try {
const resultado = await fn();
if (estado === 'HALF_OPEN') { fallos = []; cambiar('CLOSED'); }
return resultado;
} catch (err) {
if (esFallo(err)) {
const ahora = Date.now();
fallos = fallos.filter((t) => ahora - t < ventanaMs).concat(ahora);
if (estado === 'HALF_OPEN' || fallos.length >= umbralFallos) { abiertoHasta = ahora + tiempoAbiertoMs; cambiar('OPEN'); }
}
throw err;
}
}
return { ejecutar, estado: () => estado };
}
module.exports = { crearCircuitBreaker };- En
CLOSEDse cuentan los fallos de los últimosventanaMs; al llegar aumbralFallos(5 en 10 s) se abre durantetiempoAbiertoMs(30 s). - En
OPENse falla sin llamar, con 503 yRetry-Aftercalculado: Pedidos responde en microsegundos, no en 2 s, y el gateway puede informar al cliente. - Al expirar, la primera llamada pasa en
HALF_OPEN: si va bien, se cierra; si falla, se vuelve a abrir otros 30 s. esFalloexcluye los errores no transitorios: un 404 de producto no debe abrir el circuito.alCambiares el gancho para el log (warn) y para el gaugecircuit_breaker_estado{dependencia}(0 cerrado, 1 medio abierto, 2 abierto), que 06-05 podrá vigilar.
Aplicado a catalogoCliente, el orden es breaker fuera, reintento dentro: si el circuito está abierto no tiene sentido reintentar.
const breakerCatalogo = crearCircuitBreaker({ nombre: 'catalogo', alCambiar: ({ estado }) => { logger.warn({ dependencia: 'catalogo', estado }, 'circuito cambia'); metricas.breaker.set({ dependencia: 'catalogo' }, ESTADOS[estado]); } });
obtenerProductos: (ids, { requestId }) =>
breakerCatalogo.ejecutar(() => reintentar(() => http.peticion(`/v1/productos?ids=${ids.join(',')}`, { requestId }), { intentos: 2 }))Un breaker por dependencia (Catálogo, Clientes), no uno global; y por proceso: cada réplica de Pedidos tiene el suyo, lo que está bien porque cada una ve su propia experiencia. Relación con 05-05: el outlierDetection de Istio hace algo parecido por instancia de destino (expulsa del balanceo el pod que falla) y sin conocer el negocio; el breaker en código decide por dependencia lógica y sabe qué es un fallo. No son excluyentes; TechCorp decidió empezar por el código.
- Bulkhead: aislar dependencias
En un barco, los mamparos (bulkheads) impiden que una vía de agua inunde todo el casco. En un servicio, el equivalente es limitar la concurrencia por dependencia para que una lenta no consuma todos los recursos. Node no tiene threads que agotar, pero sí sockets, memoria y conexiones del pool:
const pLimit = require('p-limit');
const limiteCatalogo = pLimit(20); // como mucho 20 peticiones simultáneas a Catálogo por réplica
const limiteClientes = pLimit(20);
obtenerProductos: (ids, opts) => limiteCatalogo(() => breakerCatalogo.ejecutar(() => reintentar(() => http.peticion(/* … */), { intentos: 2 })))Si Catálogo se ralentiza, como mucho 20 peticiones esperan; la 21 se encola en memoria (o, con p-limit más una comprobación de activeCount, se rechaza con 503 inmediato). El resto del servicio —GET /v1/pedidos/{id}, el consumidor de la saga— sigue funcionando. Otros mamparos que ya existen en TechCorp: pools de conexiones separados para el tráfico HTTP y para el relay del outbox (que no se quede sin conexión de pg cuando llegan mil peticiones); el canal de RabbitMQ del consumidor separado del del publicador; y, en Kubernetes, resources.limits por pod (05-02) para que un servicio no se coma el nodo.
- Fallback y degradación elegante; rate limiting y backpressure
Cuando una dependencia falla, hay dos preguntas: ¿puedo responder algo útil sin ella? y, si no, ¿cómo fallo bien?
- El BFF de 03-04 muestra la lista de pedidos con nombres de producto que obtiene de Catálogo. Si Catálogo está caído, el BFF puede responder los pedidos con los
productoIdy sin nombres, marcando"parcial": true. Es un fallback: el usuario ve su historial aunque un poco más pobre. - Pedidos no puede degradar la validación de productos y precios al crear un pedido: aceptar un pedido con precios desconocidos es peor que no aceptarlo. Responde
503 DEPENDENCIA_NO_DISPONIBLEconRetry-After: 30(el del breaker) y el cuerpo RFC 7807 de 03-01, y el gateway/BFF muestra "inténtalo en unos segundos". El pedido nunca queda a medias. - Otras degradaciones aceptadas: Notificaciones que no puede enviar correo lo encola y no bloquea la saga (la lección del monolito de 01-02); Catálogo que no llega a MongoDB puede servir desde su caché (06-04) marcando
Age.
Rate limiting y backpressure son la degradación frente a la sobrecarga: mejor rechazar una parte con 429 que degradar el 100 %. El gateway ya limita a 300/min por cliente (03-04) y responde 429 con Retry-After; los servicios pueden añadir un límite propio para las rutas caras. En RabbitMQ el mecanismo es el prefetch(10) de 03-02: el consumidor no acepta más de 10 mensajes sin confirmar; si Inventario va lento, los mensajes se quedan en la cola en vez de amontonarse en memoria del proceso, y rabbitmq_queue_messages_ready sube (06-01) para que alguien lo vea o el autoescalado actúe (06-04). El sistema empuja hacia atrás en lugar de reventar hacia delante.
- Fail-fast en el arranque, tolerancia en ejecución
Hay un momento en el que no queremos tolerancia: el arranque. En 04-03 la configuración se valida con zod y el proceso muere con fatal si falta PEDIDOS_DB_URL; Kubernetes lo reintenta y el CrashLoopBackOff es visible. Sería peor arrancar "a medias" y fallar en la primera petición. En cambio, una vez arrancado, un servicio no debe morir porque una dependencia falte: si RabbitMQ no está disponible al arrancar, /health/ready devuelve 503 (el pod no recibe tráfico) y el servicio reintenta la conexión con backoff; cuando vuelve, ready pasa a 200. La combinación es: config inválida → morir; dependencia caída → no listo, seguir intentando.
- Recuperación en el mundo asíncrono: reintentos, colas con TTL y DLQ
En 03-02 el consumidor hacía nack con requeue: true una vez y a la segunda enviaba a la DLQ. Es simple pero tiene un defecto: el reintento es inmediato (el mensaje vuelve a la cabeza de la cola) y no hay espera. Si Pagos ha fallado por un 503 del PSP, reintentar en 5 ms es inútil. La solución idiomática en RabbitMQ es una cola de reintento con TTL: el mensaje fallido se publica en una cola sin consumidores cuyo x-message-ttl lo devuelve, al expirar, a la cola principal a través de un dead-letter exchange.
flowchart LR E[techcorp.eventos] --> Q[pedidos.saga] Q -->|fallo transitorio,<br/>menos de 5 intentos| R[pedidos.saga.reintento<br/>x-message-ttl 30000] R -->|TTL expira → DLX| E2[techcorp.eventos.reintento] --> Q Q -->|5 intentos agotados<br/>o error permanente| D[pedidos.saga.dlq]
Ampliación de mensajeria/topologia.js de la librería:
async function declararColaConReintento(canal, { cola, routingKeys, ttlReintentoMs = 30000 }) {
await canal.assertExchange('techcorp.eventos', 'topic', { durable: true });
await canal.assertExchange('techcorp.eventos.dlx', 'topic', { durable: true });
await canal.assertExchange('techcorp.eventos.reintento', 'direct', { durable: true });
await canal.assertQueue(cola, { durable: true, deadLetterExchange: 'techcorp.eventos.dlx' }); // cola principal
await canal.assertQueue(`${cola}.reintento`, { durable: true, messageTtl: ttlReintentoMs,
deadLetterExchange: 'techcorp.eventos.reintento', deadLetterRoutingKey: cola }); // espera y vuelve
await canal.assertQueue(`${cola}.dlq`, { durable: true }); // final
for (const rk of routingKeys) await canal.bindQueue(cola, 'techcorp.eventos', rk);
await canal.bindQueue(cola, 'techcorp.eventos.reintento', cola); // vuelta desde reintento
await canal.bindQueue(`${cola}.dlq`, 'techcorp.eventos.dlx', '#');
}pedidos.saga.reintentono tiene consumidor: los mensajes solo esperan 30 s. Al caducar, RabbitMQ los reenvía portechcorp.eventos.reintentocon routing keypedidos.saga, y esa cola principal está enlazada a ese exchange con esa clave: el mensaje reaparece 30 s después.- La DLQ recibe lo que la cola principal rechace con
requeue: false.
Y el consumidor genérico decide a dónde va cada fallo:
// @techcorp/comun-http/src/mensajeria/consumidor.js (fragmento del manejador)
canal.consume(cola, async (msg) => {
const intentos = (msg.properties.headers['x-intentos'] || 0);
try {
await procesar(msg);
canal.ack(msg);
} catch (err) {
const permanente = err.transitorio === false || err instanceof ErrorNegocio && !err.transitorio;
if (permanente || intentos + 1 >= maxIntentos) {
logger.error({ eventoId: msg.properties.messageId, intentos, err }, permanente ? 'mensaje venenoso, a la DLQ' : 'agotados los reintentos, a la DLQ');
canal.nack(msg, false, false); // → DLX → <cola>.dlq
} else {
logger.warn({ eventoId: msg.properties.messageId, intento: intentos + 1, err }, 'reintento diferido');
canal.publish('', `${cola}.reintento`, msg.content, { ...msg.properties, headers: { ...msg.properties.headers, 'x-intentos': intentos + 1 } });
canal.ack(msg); // ya está copiado en la cola de reintento
}
}
}, { noAck: false });- El contador
x-intentosviaja en las cabeceras del propio mensaje (RabbitMQ no lo cuenta por nosotros de forma fiable). Se conservan el resto de cabeceras (requestId,traceparentde 06-02). - Un mensaje venenoso (poison message) es el que fallará siempre: JSON mal formado, un
pedidoIdque no existe, un bug del consumidor con ese caso concreto. Reintentarlo 5 veces cada 30 s solo retrasa a los demás; por eso un error permanente va a la DLQ a la primera. Y por eso elprefetch(10)importa: conprefetch(1)un mensaje venenoso bloquearía toda la cola hasta agotar reintentos. - Con
maxIntentos = 5y TTL de 30 s, un mensaje se procesa hasta 5 veces a lo largo de ~2 minutos: suficiente para que un PSP se recupere de un 503; menos que los 15 minutos deexpira_ende la reserva.
Procesar la DLQ. Una DLQ con mensajes es un incidente pequeño (en 06-05 será una alerta al equipo dueño de la cola). El procedimiento: (1) inspeccionar en la consola de RabbitMQ o con el script (cabeceras, x-intentos, la primera excepción que se logueó con ese eventoId en Loki); (2) si la causa era transitoria y ya se resolvió (Pagos volvió), reprocesar; (3) si el mensaje es venenoso, corregir el consumidor o descartar el mensaje documentando por qué. Script scripts/reprocesarDlq.js de la librería:
// node scripts/reprocesarDlq.js --cola pedidos.saga --max 50 [--filtro-evento pedido.cancelado] [--descartar]
async function reprocesar({ cola, max, filtroEvento, descartar }) {
const canal = await conectar(process.env.RABBITMQ_URL);
let movidos = 0;
while (movidos < max) {
const msg = await canal.get(`${cola}.dlq`, { noAck: false });
if (!msg) break;
const tipo = msg.fields.routingKey;
if (filtroEvento && tipo !== filtroEvento) { canal.nack(msg, false, true); continue; } // lo dejamos en la DLQ
if (descartar) { logger.warn({ eventoId: msg.properties.messageId, tipo }, 'descartado de la DLQ'); canal.ack(msg); movidos++; continue; }
const headers = { ...msg.properties.headers, 'x-intentos': 0, 'x-reproceso': new Date().toISOString() };
canal.publish('', cola, msg.content, { ...msg.properties, headers }); // directo a la cola principal
canal.ack(msg);
logger.info({ eventoId: msg.properties.messageId, tipo }, 'reprocesado desde la DLQ');
movidos++;
}
await canal.close();
}Se ejecuta desde un pod efímero (kubectl run con la imagen del servicio) o como Job; reinicia x-intentos y deja huella con x-reproceso (y el span con link de 06-02). Es la operación más habitual de la guardia (06-05).
- Sagas atascadas: el vigilante de
TIMEOUT_PAGO y la reconciliación
TIMEOUT_PAGO y la reconciliaciónLa saga por coreografía de 02-05 no tiene coordinador: si pago.confirmado no llega nunca (Pagos caído más de lo que duran los reintentos, mensaje en la DLQ), el pedido se queda en STOCK_RESERVADO y la reserva bloquea stock. En 02-05 definimos el vigilante de TIMEOUT_PAGO; ahora lo implementamos como un job periódico dentro de servicio-pedidos (mismo proceso que el relay, con setInterval, y protegido con SELECT … FOR UPDATE SKIP LOCKED para que dos réplicas no cancelen el mismo pedido):
// servicio-pedidos/src/mensajeria/vigilanteSaga.js
function crearVigilanteSaga({ bd, intervaloMs = 60000, limiteMinutos = 10, logger }) {
async function ciclo() {
const cliente = await bd.connect();
try {
await cliente.query('BEGIN');
const { rows } = await cliente.query(
`SELECT id FROM pedidos WHERE estado = 'STOCK_RESERVADO' AND actualizado_en < now() - ($1 || ' minutes')::interval
FOR UPDATE SKIP LOCKED LIMIT 100`, [limiteMinutos]);
for (const { id } of rows) {
await cliente.query(`UPDATE pedidos SET estado = 'CANCELADO', motivo_cancelacion = 'TIMEOUT_PAGO', actualizado_en = now() WHERE id = $1`, [id]);
await cliente.query(`INSERT INTO outbox (id, tipo, datos, cabeceras) VALUES ($1, 'pedido.cancelado', $2, $3)`,
[ulid(), { pedidoId: id, motivo: 'TIMEOUT_PAGO' }, { origen: 'vigilanteSaga' }]);
logger.warn({ pedidoId: id }, 'pedido cancelado por TIMEOUT_PAGO');
}
await cliente.query('COMMIT');
} catch (err) { await cliente.query('ROLLBACK'); logger.error({ err }, 'error en el vigilante'); }
finally { cliente.release(); }
}
const temporizador = setInterval(() => ciclo().catch(() => {}), intervaloMs);
return { detener: () => clearInterval(temporizador) };
}- Cada minuto busca pedidos con más de 10 minutos en
STOCK_RESERVADO(menos que los 15 deexpira_en: cancelamos antes de que la reserva caduque sola, y elpedido.canceladohace que Inventario publiquestock.liberado). - Cancelación y evento en la misma transacción, vía outbox: si el vigilante muere a medias, no hay pedido cancelado sin evento.
SKIP LOCKEDpermite que varias réplicas ejecuten el vigilante sin pisarse.- Es idempotente por construcción: al segundo ciclo el pedido ya no está en
STOCK_RESERVADO.
Y como último recurso, la reconciliación periódica entre servicios: un CronJob de Kubernetes (reconciliar-reservas, cada hora) en Inventario lista sus reservas ACTIVAS con más de 20 minutos, pregunta a Pedidos (GET /v1/pedidos/{id} interno) por el estado de cada una y libera las de pedidos CANCELADO o inexistentes, logueando cada discrepancia con error (no debería haber ninguna: si las hay, algo falla en la saga y hay que investigar). La reconciliación no sustituye a la saga; es la red bajo el trapecio.
- Manejo de errores en el código:
ErrorNegocio, promesas y SIGTERM
ErrorNegocio, promesas y SIGTERMReglas del código de TechCorp, ya iniciadas en 04-02 y ahora completas:
ErrorNegociopara lo esperado,Errorpara lo inesperado.ErrorNegocio('SIN_STOCK', …, 409)es una respuesta; unTypeErrores un bug.middlewareErrorestraduce el primero a RFC 7807 con su código y el segundo a un 500 genérico conrequestId(sin filtrar el stack al cliente) y lo loguea conerror.- No tragar excepciones. Un
catch (e) {}convierte un fallo visible en un pedido perdido en silencio. Si se captura, se hace algo: se traduce, se reintenta, se compensa o se relanza. - Promesas sin capturar. Un
awaitolvidado en un consumidor genera ununhandledRejectionque Node 20 convierte en caída del proceso. Enservidor.js:process.on('unhandledRejection', (err) => { logger.fatal({ err }, 'promesa sin capturar'); process.exit(1); }); mejor morir ruidosamente y que Kubernetes reinicie que seguir en un estado desconocido. Lo mismo conuncaughtException. - Cerrar bien en
SIGTERM(05-02,terminationGracePeriodSeconds: 30): dejar de aceptar conexiones (server.close), poner/health/readyen 503, esperar a que terminen las peticiones en curso, cancelar elconsumede RabbitMQ y esperar a que los mensajes ya recibidos se procesen o se hagannack, detener el relay y el vigilante, cerrar pools y vaciar el SDK de trazas (06-02). Sin esto, cada rolling produce mensajes reentregados y peticiones cortadas.
- Pruebas de resiliencia y chaos engineering básico
Los patrones que no se prueban no funcionan el día que hacen falta. Tres niveles, de menos a más:
- Pruebas unitarias de los patrones (04-05):
reintentarcon un doble que falla dos veces y luego responde; el breaker que se abre al quinto fallo y se cierra tras la prueba; el consumidor que envía a.reintentoun 503 y a.dlqun JSON inválido. Conjest.useFakeTimers()no hay que esperar 30 s reales. - Fallos inyectados en integración: en Testcontainers, parar el contenedor de Catálogo a mitad de una prueba y comprobar que
POST /v1/pedidosdevuelve 503 conRetry-Afteren menos de 3 s; añadir latencia con toxiproxy (un proxy que se sitúa entre Pedidos y Catálogo y añade 3 s o corta conexiones) o contc qdisc add dev eth0 root netem delay 500msdentro del contenedor. - Game days en staging: una vez al trimestre el equipo de Plataforma mata pods al azar (
kubectl delete pod -l app=servicio-pagos), corta RabbitMQ 5 minutos, llena la DLQ o pone a Catálogo a 2 s, y los equipos observan si los dashboards lo muestran, si las alertas (06-05) saltan, si la saga se recupera y cuánto tardan en entenderlo. Se anota lo que no funcionó y se convierte en tareas. Herramientas como Chaos Mesh o LitmusChaos automatizan la inyección en Kubernetes; TechCorp empieza a mano.
- Tabla resumen: patrón → problema → dónde vive en TechCorp
| Patrón | Problema que resuelve | Dónde vive |
|---|---|---|
| Timeout con presupuesto | Esperas indefinidas, agotamiento de recursos | crearClienteHttp (AbortSignal.timeout), pools de pg/MongoDB, TIMEOUT_HTTP_MS |
| Reintento con backoff + jitter | Errores transitorios | reintentar() en @techcorp/comun-http; solo idempotentes/transitorios |
| Idempotencia | "¿Se ejecutó o no?" | Idempotency-Key, procesarUnaVez, eventos_procesados (02-05, 03-01) |
| Circuit breaker | Fallo en cascada, dependencia caída | crearCircuitBreaker() en catalogoCliente/clientesCliente; métrica circuit_breaker_estado |
| Bulkhead | Una dependencia lenta agota el servicio | p-limit por dependencia; pools separados; resources.limits |
| Fallback / degradación | Responder algo útil sin la dependencia | BFF sin nombres de producto; Pedidos → 503 + Retry-After |
| Rate limiting / backpressure | Sobrecarga | Gateway 300/min y 429 (03-04); prefetch(10) |
| Fail-fast en arranque | Estado inconsistente | Config zod (04-03); /health/ready 503 hasta tener dependencias |
| Cola de reintento con TTL + DLQ | Fallos transitorios y mensajes venenosos en consumidores | <cola>.reintento (TTL 30 s), <cola>.dlq, x-intentos, scripts/reprocesarDlq.js |
| Vigilante de saga | Sagas atascadas | vigilanteSaga.js en Pedidos (TIMEOUT_PAGO, 10 min) |
| Reconciliación | Lo que se escapa de todo lo anterior | CronJob reconciliar-reservas en Inventario |
| Apagado ordenado | Mensajes y peticiones perdidos en despliegues | SIGTERM en servidor.js |
| Chaos engineering | Comprobar que todo lo anterior funciona | Pruebas con toxiproxy, game days trimestrales |
Errores Comunes y Consejos
- Reintentar todo. Reintentar un
POSTsin clave de idempotencia crea duplicados; reintentar un 400 es perder tiempo; reintentar contra un servicio sobrecargado lo hunde. Reintento = transitorio y idempotente. - Reintentar sin jitter. Todos los clientes vuelven a la vez y crean el "rebaño atronador".
- Breaker global para todas las dependencias. Un fallo en Clientes cerraría también Catálogo. Uno por dependencia.
- Timeouts iguales en toda la cadena. El llamante caduca al mismo tiempo que el llamado y nadie devuelve una respuesta útil. Decrecientes hacia dentro.
prefetch(1)"para procesar en orden". Un mensaje venenoso bloquea la cola. El orden se trata de otra forma (06-04).- DLQ sin dueño ni procedimiento. Los mensajes se acumulan meses; nadie sabe si se pueden reprocesar. Cada cola tiene equipo dueño (02-01) y su DLQ, alerta y runbook (06-05).
- Ignorar
SIGTERM. Cada despliegue deja pedidos a medias que luego el vigilante cancela. Apagado ordenado completo. - Confundir fallback con mentir. Devolver "stock disponible" porque Inventario no responde no es degradación elegante, es un pedido que se cancelará. Degradar solo lo que no compromete el negocio.
- Consejo: cada patrón deja rastro en logs (
warn) y métricas (circuit_breaker_estado,rabbitmq_queue_messages_ready{queue=~".*reintento|.*dlq"}); si un patrón actúa a menudo, no es un éxito del patrón, es un problema que hay que arreglar en la dependencia. - Consejo: documentar por servicio, en su
README, la tabla de dependencias con timeout, reintentos, breaker y fallback de cada una. Es la primera página que abre la guardia.
Ejercicios
Ejercicio 1: cliente de Pagos hacia el PSP
servicio-pagos llama a un proveedor de pagos externo (POST /cobros) que a veces devuelve 503 y a veces tarda más de 5 s. Diseña la cadena de patrones (timeout, reintento, breaker, bulkhead) con valores concretos y explica cómo garantizas que un timeout no produce un doble cobro.
Ejercicio 2: decidir el destino de tres mensajes
En pagos.stock (consumidor de stock.reservado en Pagos) llegan tres mensajes que fallan: (a) el PSP devolvió 503; (b) el JSON del mensaje no tiene pedidoId; (c) la consulta a PostgreSQL falla con 40P01 deadlock_detected. Indica para cada uno si va a .reintento, a la .dlq o se reintenta en el propio proceso, y qué se loguea.
Ejercicio 3: el vigilante y la reserva
Un pedido lleva 12 minutos en STOCK_RESERVADO porque el mensaje pago.confirmado está en pedidos.saga.dlq (Pedidos tenía un bug al procesarlo). Describe la secuencia de lo que hace el sistema automáticamente, qué ve la guardia y qué debe hacer, y qué habría pasado si el vigilante hubiera actuado a los 16 minutos en vez de a los 10.
Soluciones
Ejercicio 1
- Timeout: 5 s por intento (el PSP es lento por naturaleza; el gateway no espera esta llamada, es un consumidor de
stock.reservado). - Idempotencia primero: el PSP admite una clave de idempotencia; se envía
Idempotency-Key = pedidoId. Así el segundo intento tras un timeout devuelve el mismo cobro en vez de uno nuevo. Además, antes de llamar, Pagos registra en su BDcobros(pedidoId, estado='INICIADO'); si el proceso muere y el mensaje se reentrega, comprueba primero el estado y consulta al PSP por la clave antes de volver a cobrar. - Reintento:
reintentar(fn, { intentos: 3, baseMs: 500, maxMs: 5000 })solo para 503/429/timeout/ECONNRESET; un 402 (tarjeta rechazada) es permanente →pago.rechazado. - Breaker:
crearCircuitBreaker({ nombre: 'psp', umbralFallos: 5, ventanaMs: 30000, tiempoAbiertoMs: 60000 }); abierto → el mensaje va apagos.stock.reintento(transitorio) sin llamar. - Bulkhead:
p-limit(10)hacia el PSP; el resto de Pagos (consultas de estado) no depende de él. - Presupuesto total del mensaje: 3 × 5 s + esperas ≈ 20 s, muy por debajo de los 10 minutos del vigilante.
Ejercicio 2
- (a) 503 del PSP:
transitorio: true→ publicar enpagos.stock.reintentoconx-intentos+1;warn"reintento diferido" coneventoId,pedidoId,intento. - (b) sin
pedidoId: error de validación permanente (mensaje venenoso) →nack(requeue=false)→pagos.stock.dlqa la primera;error"mensaje venenoso, a la DLQ" con eleventoIdy el motivo. Alguien tendrá que ver por qué Inventario publicó ese evento (contrato de 03-06). - (c) deadlock: transitorio y local → reintentar la transacción dentro del proceso (
reintentarconintentos: 3, baseMs: 50) sin pasar por la cola; solo si agota los intentos, a.reintento.warnpor cada intento con el código SQL.
Ejercicio 3
Secuencia: (1) el consumidor de pedidos.saga falla al procesar pago.confirmado con un TypeError (permanente) → DLQ a la primera; error en Loki con eventoId y pedidoId; rabbitmq_queue_messages_ready{queue="pedidos.saga.dlq"} = 1. (2) A los 10 minutos el vigilante encuentra el pedido en STOCK_RESERVADO, lo pasa a CANCELADO con TIMEOUT_PAGO y publica pedido.cancelado. (3) Inventario libera la reserva (stock.liberado) y Pagos, al recibir pedido.cancelado de un pedido que sí cobró, publica pago.reembolsado (compensación de 02-05); Notificaciones avisa al cliente. La guardia ve la alerta de DLQ (06-05), lee el error en Loki, corrige el bug y despliega; después decide si reprocesar el mensaje: no en este caso, porque el pedido ya está cancelado y reembolsado (reprocesar pago.confirmado sobre un CANCELADO debe ser ignorado por la máquina de estados, y así lo comprueba); lo descarta con --descartar y contacta con el cliente si procede. Si el vigilante actuara a los 16 minutos, la reserva habría expirado sola a los 15 (expira_en) y el stock se habría liberado sin evento coordinado; el pedido seguiría en STOCK_RESERVADO un minuto más con una reserva ya inexistente y el reembolso llegaría más tarde: funciona, pero es menos limpio. Por eso el vigilante corre antes que la expiración.
Conclusión
Esta lección ha cumplido la promesa de "diseñar para el fallo". Todo lo que sale por red tiene timeout con presupuesto decreciente (crearClienteHttp); los errores transitorios sobre operaciones idempotentes se reintentan con reintentar() (backoff exponencial y jitter); las dependencias caídas se aíslan con crearCircuitBreaker() por dependencia (métrica circuit_breaker_estado) y p-limit como bulkhead; se degrada lo que se puede (BFF) y se falla bien lo que no (Pedidos → 503 con Retry-After); la sobrecarga se frena con 429 y prefetch; el arranque falla rápido y la ejecución tolera. En el mundo asíncrono, cada cola tiene ahora <cola>.reintento con TTL de 30 s, <cola>.dlq tras 5 intentos o a la primera para mensajes venenosos, y scripts/reprocesarDlq.js; las sagas atascadas las cancela vigilanteSaga.js a los 10 minutos con TIMEOUT_PAGO y la reconciliación horaria de reservas es la última red; el proceso muere ante promesas sin capturar y se apaga con orden en SIGTERM; y todo se prueba con dobles, toxiproxy y game days. Con la resiliencia resuelta, la pregunta siguiente es de capacidad: cuando llegue el Black Friday con el catálogo ×20 y los pedidos ×3, ¿cuántas réplicas hacen falta, cómo escalan solas y dónde están los cuellos de botella? Ese es el tema de la siguiente lección: escalabilidad y rendimiento.
Curso de Microservicios
Módulo 1: Introducción a los Microservicios
- Conceptos Básicos de Microservicios
- Ventajas y Desventajas de los Microservicios
- Comparación con la Arquitectura Monolítica
- Cuándo Adoptar Microservicios: Criterios de Decisión
- El Caso Práctico del Curso: la Tienda Online de TechCorp
Módulo 2: Diseño de Microservicios
- Principios de Diseño de Microservicios
- Descomposición de Aplicaciones Monolíticas
- Definición de Bounded Contexts
- Gestión de Datos: una Base de Datos por Servicio
- Consistencia Distribuida: Sagas, CQRS y Event Sourcing
Módulo 3: Comunicación entre Microservicios
- APIs RESTful
- Mensajería Asíncrona
- Protocolos de Comunicación: gRPC, GraphQL
- API Gateway y Backend for Frontend
- Descubrimiento de Servicios y Balanceo de Carga
- Contratos y Versionado de APIs
Módulo 4: Implementación de Microservicios
- Elección de Tecnologías y Herramientas
- Desarrollo de un Microservicio Simple
- Gestión de Configuración
- Integración Práctica: Consumir APIs y Publicar Eventos
- Pruebas en Microservicios: Unitarias, de Integración y de Contrato
Módulo 5: Despliegue y Orquestación
- Contenedores y Docker
- Orquestación con Kubernetes
- CI/CD para Microservicios
- Estrategias de Despliegue: Rolling, Blue-Green y Canary
- Service Mesh: Istio y Linkerd
Módulo 6: Monitoreo y Mantenimiento
- Monitoreo y Logging
- Trazabilidad Distribuida con OpenTelemetry
- Gestión de Errores y Recuperación
- Escalabilidad y Rendimiento
- SLOs, Alertas y Gestión de Incidentes
Módulo 7: Seguridad en Microservicios
- Autenticación y Autorización
- Seguridad en la Comunicación
- Prácticas de Seguridad
- Seguridad en Contenedores y Kubernetes
