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

  1. Tipos de fallo en un sistema distribuido y la pregunta "¿se ejecutó o no?"
  2. Timeouts: todo lo que sale por red tiene límite y presupuesto
  3. Reintentos con backoff exponencial y jitter: qué reintentar y qué no
  4. Circuit breaker: cortar antes de arrastrar a los demás
  5. Bulkhead: aislar dependencias
  6. Fallback y degradación elegante; rate limiting y backpressure
  7. Fail-fast en el arranque, tolerancia en ejecución
  8. Recuperación en el mundo asíncrono: reintentos, colas con TTL y DLQ
  9. Sagas atascadas: el vigilante de TIMEOUT_PAGO y la reconciliación
  10. Manejo de errores en el código: ErrorNegocio, promesas y SIGTERM
  11. Pruebas de resiliencia y chaos engineering básico
  12. Tabla resumen: patrón → problema → dónde vive en TechCorp

  1. 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.

  1. 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_timeout y connectionTimeoutMillis en el pool), MongoDB (serverSelectionTimeoutMS, maxTimeMS), RabbitMQ (publish con 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) con Retry-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 catch distingue 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 transitorio en el error es la que consultan los patrones siguientes: el reintento y el breaker no adivinan, preguntan.
  • catalogoCliente y clientesCliente de 04-04 se reescriben sobre crearClienteHttp (nombre: 'catalogo', urlBase: CATALOGO_URL).

  1. 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 };
  • intentos cuenta el primero: 3 intentos = 1 llamada + 2 reintentos.
  • reintentarSi por defecto solo acepta errores marcados transitorio; 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.
  • alReintentar sirve para loguear en warn (06-01) con intento y espera.

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 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) Transitorio por definición; la transacción se reintenta entera
Publicar en RabbitMQ → canal cerrado (lo hace el relay en el siguiente ciclo) El outbox garantiza que no se pierde

  1. 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 CLOSED se cuentan los fallos de los últimos ventanaMs; al llegar a umbralFallos (5 en 10 s) se abre durante tiempoAbiertoMs (30 s).
  • En OPEN se falla sin llamar, con 503 y Retry-After calculado: 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.
  • esFallo excluye los errores no transitorios: un 404 de producto no debe abrir el circuito.
  • alCambiar es el gancho para el log (warn) y para el gauge circuit_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.

  1. 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.

  1. 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 productoId y 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_DISPONIBLE con Retry-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.

  1. 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.

  1. 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.reintento no tiene consumidor: los mensajes solo esperan 30 s. Al caducar, RabbitMQ los reenvía por techcorp.eventos.reintento con routing key pedidos.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-intentos viaja en las cabeceras del propio mensaje (RabbitMQ no lo cuenta por nosotros de forma fiable). Se conservan el resto de cabeceras (requestId, traceparent de 06-02).
  • Un mensaje venenoso (poison message) es el que fallará siempre: JSON mal formado, un pedidoId que 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 el prefetch(10) importa: con prefetch(1) un mensaje venenoso bloquearía toda la cola hasta agotar reintentos.
  • Con maxIntentos = 5 y 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 de expira_en de 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).

  1. Sagas atascadas: el vigilante de TIMEOUT_PAGO y la reconciliación

La 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 de expira_en: cancelamos antes de que la reserva caduque sola, y el pedido.cancelado hace que Inventario publique stock.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 LOCKED permite 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.

  1. Manejo de errores en el código: ErrorNegocio, promesas y SIGTERM

Reglas del código de TechCorp, ya iniciadas en 04-02 y ahora completas:

  • ErrorNegocio para lo esperado, Error para lo inesperado. ErrorNegocio('SIN_STOCK', …, 409) es una respuesta; un TypeError es un bug. middlewareErrores traduce el primero a RFC 7807 con su código y el segundo a un 500 genérico con requestId (sin filtrar el stack al cliente) y lo loguea con error.
  • 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 await olvidado en un consumidor genera un unhandledRejection que Node 20 convierte en caída del proceso. En servidor.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 con uncaughtException.
  • Cerrar bien en SIGTERM (05-02, terminationGracePeriodSeconds: 30): dejar de aceptar conexiones (server.close), poner /health/ready en 503, esperar a que terminen las peticiones en curso, cancelar el consume de RabbitMQ y esperar a que los mensajes ya recibidos se procesen o se hagan nack, 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.

  1. 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:

  1. Pruebas unitarias de los patrones (04-05): reintentar con 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 .reintento un 503 y a .dlq un JSON inválido. Con jest.useFakeTimers() no hay que esperar 30 s reales.
  2. Fallos inyectados en integración: en Testcontainers, parar el contenedor de Catálogo a mitad de una prueba y comprobar que POST /v1/pedidos devuelve 503 con Retry-After en 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 con tc qdisc add dev eth0 root netem delay 500ms dentro del contenedor.
  3. 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.

  1. 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 POST sin 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 BD cobros(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 a pagos.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 en pagos.stock.reintento con x-intentos+1; warn "reintento diferido" con eventoId, pedidoId, intento.
  • (b) sin pedidoId: error de validación permanente (mensaje venenoso) → nack(requeue=false)pagos.stock.dlq a la primera; error "mensaje venenoso, a la DLQ" con el eventoId y 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 (reintentar con intentos: 3, baseMs: 50) sin pasar por la cola; solo si agota los intentos, a .reintento. warn por 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

Módulo 2: Diseño de Microservicios

Módulo 3: Comunicación entre Microservicios

Módulo 4: Implementación de Microservicios

Módulo 5: Despliegue y Orquestación

Módulo 6: Monitoreo y Mantenimiento

Módulo 7: Seguridad en Microservicios

Módulo 8: Casos de Estudio y Ejemplos Prácticos

© Copyright 2026. Todos los derechos reservados