Ja veiem el que passa a TechCorp: logs, mètriques i traces. Ara toca el que el curs porta prometent des de 02-01 ("dissenyar per a la fallada") i que 03-01, 05-05 i 06-02 van anar remetent aquí: com es comporta un servei quan la xarxa es degrada, una dependència no respon, un missatge no es pot processar o una saga es queda a mitges. En un monòlit gairebé totes les fallades són excepcions dins d'un procés; en microserveis la fallada és la norma —les vuit fal·làcies de 01-02— i la pregunta que ho domina tot és la que va plantejar el timeout de crearComanda: "s'ha executat o no?". Aquesta lliçó converteix aquesta pregunta en un conjunt de patrons implementats en Node.js dins de @techcorp/comu-http i a la topologia de RabbitMQ.

Contingut

  1. Tipus de fallada en un sistema distribuït i la pregunta "s'ha executat o no?"
  2. Timeouts: tot el que surt per xarxa té límit i pressupost
  3. Reintents amb backoff exponencial i jitter: què reintentar i què no
  4. Circuit breaker: tallar abans d'arrossegar els altres
  5. Bulkhead: aïllar dependències
  6. Fallback i degradació elegant; rate limiting i backpressure
  7. Fail-fast a l'arrencada, tolerància en execució
  8. Recuperació al món asíncron: reintents, cues amb TTL i DLQ
  9. Sagues encallades: el vigilant de TIMEOUT_PAGAMENT i la reconciliació
  10. Gestió d'errors al codi: ErrorNegoci, promeses i SIGTERM
  11. Proves de resiliència i chaos engineering bàsic
  12. Taula resum: patró → problema → on viu a TechCorp

  1. Tipus de fallada en un sistema distribuït i la pregunta "s'ha executat o no?"

Tipus de fallada Exemple a TechCorp Què la distingeix Resposta adequada
Latència Catàleg respon en 1,8 s en comptes de 40 ms Funciona, però lent; consumeix recursos del cridant Timeout, i vigilar (06-01)
Timeout Clients no respon en 2 s No sabem si ha processat Cancel·lar, i només reintentar si és idempotent
Error transitori ECONNRESET, 503 de Catàleg durant un rolling, deadlock a PostgreSQL Desapareix sol en segons Reintentar amb backoff
Error permanent 404 producte inexistent, 422 dades invàlides, bug Reintentar no ajuda Fallar ràpid, retornar RFC 7807
Fallada parcial 1 de 3 rèpliques de Catàleg retorna errors El balancejador reparteix, un 33 % de peticions falla Circuit breaker per instància o outlierDetection
Sobrecàrrega Black Friday: 20× a Catàleg, cues creixent Tot va lent; reintentar ho empitjora Rate limiting, backpressure, escalar (06-04)
Fallada en cascada Catàleg lent → Comandes esgota connexions → gateway retorna 504 a tothom Una fallada local es torna global Timeouts + breaker + bulkhead

El timeout és el cas que més confon qui ve del monòlit. Quan crearComanda crida POST /v1/reserves d'Inventari i l'AbortSignal talla als 2 s, hi ha tres realitats possibles: la petició no va arribar; va arribar i es va processar però la resposta no va tornar; va arribar i encara s'està processant. El codi del cridant no les pot distingir. D'aquí les tres eines que ja vam construir: idempotència al receptor (Idempotency-Key, processarUnCop a 02-05), esdeveniments amb outbox en comptes de crides síncrones per al que canvia estat (la saga), i reconciliació per al que s'escapi (apartat 9). Els patrons d'aquesta lliçó es recolzen en aquestes bases; sense idempotència, cap d'ells no és segur.

  1. Timeouts: tot el que surt per xarxa té límit i pressupost

Ja ho fèiem a 03-01: fetch(url, { signal: AbortSignal.timeout(2000) }) amb TIMEOUT_HTTP_MS=2000. Ho elevem a regla i hi afegim el concepte de pressupost:

  • Tota operació que surt per xarxa té timeout: HTTP, pg (statement_timeout i connectionTimeoutMillis al pool), MongoDB (serverSelectionTimeoutMS, maxTimeMS), RabbitMQ (publish amb confirmació i temps límit).
  • El timeout d'una crida sortint ha de ser menor que el que li queda al cridant. Si el gateway talla als 5 s i Comandes als 2 s per dependència amb dues dependències seqüencials, Comandes ja ha gastat 4 s més el seu. Regla a TechCorp: gateway 5 s → Comandes 3 s per petició → 2 s per dependència amb les crides en paral·lel (06-02).
  • Un timeout s'ha de traduir en alguna cosa que el cridant entengui: ErrorNegoci('DEPENDENCIA_NO_DISPONIBLE', …, 503) amb Retry-After, no un 500 anònim.

A @techcorp/comu-http centralitzem el client:

// @techcorp/comu-http/src/clientHttp.js
const { ErrorNegoci } = require('./errors');

function crearClientHttp({ urlBase, timeoutMs = 2000, nom }) {
  async function peticio(ruta, { metode = 'GET', cos, requestId, idempotencyKey } = {}) {
    const capcaleres = { 'content-type': 'application/json', 'x-request-id': requestId };
    if (idempotencyKey) capcaleres['idempotency-key'] = idempotencyKey;
    let res;
    try {
      res = await fetch(urlBase + ruta, { method: metode, headers: capcaleres, body: cos && JSON.stringify(cos), signal: AbortSignal.timeout(timeoutMs) });
    } catch (err) {
      const transitori = err.name === 'TimeoutError' || ['ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN'].includes(err.cause?.code);
      throw new ErrorNegoci('DEPENDENCIA_NO_DISPONIBLE', `${nom} no respon`, 503, { causa: err.name, transitori });
    }
    if (res.status === 503 || res.status === 429) throw new ErrorNegoci('DEPENDENCIA_NO_DISPONIBLE', `${nom} ha retornat ${res.status}`, 503, { transitori: true, retryAfter: res.headers.get('retry-after') });
    if (!res.ok) throw new ErrorNegoci('DEPENDENCIA_ERROR', `${nom} ha retornat ${res.status}`, 502, { transitori: false, status: res.status });
    return res.json();
  }
  return { peticio };
}
  • El catch distingeix fallada de xarxa/timeout (transitòria) de qualsevol altra cosa; els 503/429 del servidor també són transitoris; un 4xx diferent és permanent i no es reintentarà.
  • La propietat transitori de l'error és la que consulten els patrons següents: el reintent i el breaker no endevinen, pregunten.
  • catalegClient i clientsClient de 04-04 es reescriuen sobre crearClientHttp (nom: 'cataleg', urlBase: CATALEG_URL).

  1. Reintents amb backoff exponencial i jitter: què reintentar i què no

Un reintent immediat contra un servei que s'està recuperant és una petita denegació de servei: cent clients reintentant alhora als 100 ms exactes produeixen un pic sincronitzat. Per això el reintent es fa amb espera exponencial (100, 200, 400 ms…) i aleatòria (jitter), i només quan (a) l'error és transitori i (b) l'operació és idempotent:

// @techcorp/comu-http/src/reintentar.js
async function reintentar(fn, { intents = 3, baseMs = 100, factor = 2, maxMs = 2000, jitter = true, reintentarSi = (err) => err.transitori === true, enReintentar } = {}) {
  let ultimError;
  for (let intent = 1; intent <= intents; intent++) {
    try {
      return await fn(intent);
    } catch (err) {
      ultimError = err;
      if (intent === intents || !reintentarSi(err)) throw err;
      let espera = Math.min(maxMs, baseMs * factor ** (intent - 1));   // 100, 200, 400 …
      if (jitter) espera = Math.random() * espera;                       // "full jitter": entre 0 i l'espera
      enReintentar?.({ intent, espera, err });
      await new Promise((r) => setTimeout(r, espera));
    }
  }
  throw ultimError;
}
module.exports = { reintentar };
  • intents compta el primer: 3 intents = 1 crida + 2 reintents.
  • reintentarSi per defecte només accepta errors marcats transitori; s'hi pot passar una altra funció.
  • Full jitter (aleatori entre 0 i l'espera calculada) és la variant que millor reparteix la càrrega segons l'estudi clàssic d'AWS.
  • enReintentar serveix per registrar en warn (06-01) amb intent i espera.

Ús a catalegClient:

obtenirProductes: (ids, { requestId }) =>
  reintentar(() => http.peticio(`/v1/productes?ids=${ids.join(',')}`, { requestId }),
    { intents: 3, enReintentar: ({ intent, espera, err }) => logger.warn({ requestId, intent, espera, err }, 'reintentant catàleg') })

Compte amb el pressupost: 3 intents × 2 s de timeout són fins a 6 s, més les esperes. O s'abaixa el timeout per intent (700 ms) o es redueixen els intents: la suma ha de cabre en els 3 s que Comandes té. I la taula de decisions que cal tenir present:

Situació Reintentar? Per què
GET /v1/productes → 503, 429, ECONNRESET, timeout Lectura idempotent, error transitori
GET /v1/clients/{id} → 404 No Permanent: el client no existeix
Qualsevol petició → 400, 422 No La nostra petició està malament; repetir-la dona el mateix
Qualsevol petició → 500 Amb compte Pot ser transitori o un bug; màxim 1 reintent
POST /v1/comandes → timeout No, tret que hi hagi Idempotency-Key Podria crear dues comandes; amb la clau, el segon retorna el primer (03-01)
POST /v1/reserves → timeout Sí, amb Idempotency-Key = comandaId Inventari ja ho fa idempotent per comanda
INSERT a PostgreSQL → deadlock (40P01) Transitori per definició; la transacció es reintenta sencera
Publicar a RabbitMQ → canal tancat (ho fa el relay al cicle següent) L'outbox garanteix que no es perd

  1. Circuit breaker: tallar abans d'arrossegar els altres

Si Catàleg està caigut, cada POST /v1/comandes espera 2 s, reintenta i torna a esperar: Comandes acumula peticions obertes, esgota connexions i acaba fallant també. El circuit breaker observa la taxa de fallades cap a una dependència i, quan supera un llindar, deixa de cridar-la durant un temps, fallant a l'instant; després prova amb una petició i, si va bé, es tanca.

stateDiagram-v2
  [*] --> CLOSED
  CLOSED --> OPEN: les fallades arriben al llindar a la finestra
  OPEN --> HALF_OPEN: passa tempsObertMs
  HALF_OPEN --> CLOSED: la petició de prova té èxit
  HALF_OPEN --> OPEN: la petició de prova falla
  CLOSED --> CLOSED: èxit (reinicia el comptador)

Implementació didàctica a la llibreria (en producció val igual la biblioteca opossum, amb la mateixa semàntica i mètriques incloses):

// @techcorp/comu-http/src/circuitBreaker.js
const { ErrorNegoci } = require('./errors');

function crearCircuitBreaker({ nom, llindarFallades = 5, finestraMs = 10000, tempsObertMs = 30000, esFallada = (err) => err.transitori !== false, enCanviar }) {
  let estat = 'CLOSED';
  let fallades = [];                     // marques de temps de les fallades recents
  let obertFins = 0;

  function canviar(nou) { if (estat !== nou) { estat = nou; enCanviar?.({ nom, estat }); } }

  async function executar(fn) {
    if (estat === 'OPEN') {
      if (Date.now() < obertFins) throw new ErrorNegoci('DEPENDENCIA_NO_DISPONIBLE', `${nom}: circuit obert`, 503, { transitori: true, retryAfter: Math.ceil((obertFins - Date.now()) / 1000) });
      canviar('HALF_OPEN');
    }
    try {
      const resultat = await fn();
      if (estat === 'HALF_OPEN') { fallades = []; canviar('CLOSED'); }
      return resultat;
    } catch (err) {
      if (esFallada(err)) {
        const ara = Date.now();
        fallades = fallades.filter((t) => ara - t < finestraMs).concat(ara);
        if (estat === 'HALF_OPEN' || fallades.length >= llindarFallades) { obertFins = ara + tempsObertMs; canviar('OPEN'); }
      }
      throw err;
    }
  }
  return { executar, estat: () => estat };
}
module.exports = { crearCircuitBreaker };
  • En CLOSED es compten les fallades dels últims finestraMs; en arribar a llindarFallades (5 en 10 s) s'obre durant tempsObertMs (30 s).
  • En OPEN es falla sense cridar, amb 503 i Retry-After calculat: Comandes respon en microsegons, no en 2 s, i el gateway pot informar el client.
  • En expirar, la primera crida passa en HALF_OPEN: si va bé, es tanca; si falla, es torna a obrir 30 s més.
  • esFallada exclou els errors no transitoris: un 404 de producte no ha d'obrir el circuit.
  • enCanviar és el ganxo per al log (warn) i per al gauge circuit_breaker_estat{dependencia} (0 tancat, 1 mig obert, 2 obert), que 06-05 podrà vigilar.

Aplicat a catalegClient, l'ordre és breaker fora, reintent dins: si el circuit està obert no té sentit reintentar.

const breakerCataleg = crearCircuitBreaker({ nom: 'cataleg', enCanviar: ({ estat }) => { logger.warn({ dependencia: 'cataleg', estat }, 'circuit canvia'); metriques.breaker.set({ dependencia: 'cataleg' }, ESTATS[estat]); } });

obtenirProductes: (ids, { requestId }) =>
  breakerCataleg.executar(() => reintentar(() => http.peticio(`/v1/productes?ids=${ids.join(',')}`, { requestId }), { intents: 2 }))

Un breaker per dependència (Catàleg, Clients), no un de global; i per procés: cada rèplica de Comandes té el seu, cosa que està bé perquè cadascuna veu la seva pròpia experiència. Relació amb 05-05: l'outlierDetection d'Istio fa una cosa semblant per instància de destí (expulsa del balanceig el pod que falla) i sense conèixer el negoci; el breaker en codi decideix per dependència lògica i sap què és una fallada. No són excloents; TechCorp va decidir començar pel codi.

  1. Bulkhead: aïllar dependències

En un vaixell, els mampares (bulkheads) impedeixen que una via d'aigua inundi tot el buc. En un servei, l'equivalent és limitar la concurrència per dependència perquè una de lenta no consumeixi tots els recursos. Node no té threads que esgotar, però sí sockets, memòria i connexions del pool:

const pLimit = require('p-limit');
const limitCataleg = pLimit(20);          // com a molt 20 peticions simultànies a Catàleg per rèplica
const limitClients = pLimit(20);

obtenirProductes: (ids, opts) => limitCataleg(() => breakerCataleg.executar(() => reintentar(() => http.peticio(/* … */), { intents: 2 })))

Si Catàleg s'alenteix, com a molt 20 peticions esperen; la 21 s'encua en memòria (o, amb p-limit més una comprovació d'activeCount, es rebutja amb 503 immediat). La resta del servei —GET /v1/comandes/{id}, el consumidor de la saga— continua funcionant. Altres mampares que ja existeixen a TechCorp: pools de connexions separats per al trànsit HTTP i per al relay de l'outbox (que no es quedi sense connexió de pg quan arriben mil peticions); el canal de RabbitMQ del consumidor separat del del publicador; i, a Kubernetes, resources.limits per pod (05-02) perquè un servei no es mengi el node.

  1. Fallback i degradació elegant; rate limiting i backpressure

Quan una dependència falla, hi ha dues preguntes: puc respondre alguna cosa útil sense ella? i, si no, com fallo bé?

  • El BFF de 03-04 mostra la llista de comandes amb noms de producte que obté de Catàleg. Si Catàleg està caigut, el BFF pot respondre les comandes amb els producteId i sense noms, marcant "parcial": true. És un fallback: l'usuari veu el seu historial encara que una mica més pobre.
  • Comandes no pot degradar la validació de productes i preus en crear una comanda: acceptar una comanda amb preus desconeguts és pitjor que no acceptar-la. Respon 503 DEPENDENCIA_NO_DISPONIBLE amb Retry-After: 30 (el del breaker) i el cos RFC 7807 de 03-01, i el gateway/BFF mostra "torna-ho a provar d'aquí a uns segons". La comanda mai no queda a mitges.
  • Altres degradacions acceptades: Notificacions que no pot enviar correu l'encua i no bloqueja la saga (la lliçó del monòlit de 01-02); Catàleg que no arriba a MongoDB pot servir des de la seva memòria cau (06-04) marcant Age.

Rate limiting i backpressure són la degradació davant la sobrecàrrega: millor rebutjar una part amb 429 que degradar el 100 %. El gateway ja limita a 300/min per client (03-04) i respon 429 amb Retry-After; els serveis poden afegir un límit propi per a les rutes cares. A RabbitMQ el mecanisme és el prefetch(10) de 03-02: el consumidor no accepta més de 10 missatges sense confirmar; si Inventari va lent, els missatges es queden a la cua en comptes d'amuntegar-se a la memòria del procés, i rabbitmq_queue_messages_ready puja (06-01) perquè algú ho vegi o l'autoescalat actuï (06-04). El sistema empeny cap enrere en comptes de rebentar cap endavant.

  1. Fail-fast a l'arrencada, tolerància en execució

Hi ha un moment en què no volem tolerància: l'arrencada. A 04-03 la configuració es valida amb zod i el procés mor amb fatal si falta COMANDES_DB_URL; Kubernetes ho reintenta i el CrashLoopBackOff és visible. Seria pitjor arrencar "a mitges" i fallar a la primera petició. En canvi, un cop arrencat, un servei no ha de morir perquè una dependència falti: si RabbitMQ no està disponible en arrencar, /health/ready retorna 503 (el pod no rep trànsit) i el servei reintenta la connexió amb backoff; quan torna, ready passa a 200. La combinació és: config invàlida → morir; dependència caiguda → no llest, continuar intentant-ho.

  1. Recuperació al món asíncron: reintents, cues amb TTL i DLQ

A 03-02 el consumidor feia nack amb requeue: true un cop i a la segona enviava a la DLQ. És simple però té un defecte: el reintent és immediat (el missatge torna al cap de la cua) i no hi ha espera. Si Pagaments ha fallat per un 503 del PSP, reintentar en 5 ms és inútil. La solució idiomàtica a RabbitMQ és una cua de reintent amb TTL: el missatge fallit es publica en una cua sense consumidors el x-message-ttl de la qual el retorna, en expirar, a la cua principal a través d'un dead-letter exchange.

flowchart LR
  E[techcorp.esdeveniments] --> Q[comandes.saga]
  Q -->|fallada transitòria,<br/>menys de 5 intents| R[comandes.saga.reintent<br/>x-message-ttl 30000]
  R -->|TTL expira → DLX| E2[techcorp.esdeveniments.reintent] --> Q
  Q -->|5 intents esgotats<br/>o error permanent| D[comandes.saga.dlq]

Ampliació de missatgeria/topologia.js de la llibreria:

async function declararCuaAmbReintent(canal, { cua, routingKeys, ttlReintentMs = 30000 }) {
  await canal.assertExchange('techcorp.esdeveniments', 'topic', { durable: true });
  await canal.assertExchange('techcorp.esdeveniments.dlx', 'topic', { durable: true });
  await canal.assertExchange('techcorp.esdeveniments.reintent', 'direct', { durable: true });

  await canal.assertQueue(cua, { durable: true, deadLetterExchange: 'techcorp.esdeveniments.dlx' });     // cua principal
  await canal.assertQueue(`${cua}.reintent`, { durable: true, messageTtl: ttlReintentMs,
    deadLetterExchange: 'techcorp.esdeveniments.reintent', deadLetterRoutingKey: cua });                 // espera i torna
  await canal.assertQueue(`${cua}.dlq`, { durable: true });                                              // final

  for (const rk of routingKeys) await canal.bindQueue(cua, 'techcorp.esdeveniments', rk);
  await canal.bindQueue(cua, 'techcorp.esdeveniments.reintent', cua);                                    // tornada des de reintent
  await canal.bindQueue(`${cua}.dlq`, 'techcorp.esdeveniments.dlx', '#');
}
  • comandes.saga.reintent no té consumidor: els missatges només esperen 30 s. En caducar, RabbitMQ els reenvia per techcorp.esdeveniments.reintent amb routing key comandes.saga, i aquesta cua principal està enllaçada a aquest exchange amb aquesta clau: el missatge reapareix 30 s després.
  • La DLQ rep el que la cua principal rebutgi amb requeue: false.

I el consumidor genèric decideix on va cada fallada:

// @techcorp/comu-http/src/missatgeria/consumidor.js (fragment del gestor)
canal.consume(cua, async (msg) => {
  const intents = (msg.properties.headers['x-intents'] || 0);
  try {
    await processar(msg);
    canal.ack(msg);
  } catch (err) {
    const permanent = err.transitori === false || err instanceof ErrorNegoci && !err.transitori;
    if (permanent || intents + 1 >= maxIntents) {
      logger.error({ esdevenimentId: msg.properties.messageId, intents, err }, permanent ? 'missatge verinós, a la DLQ' : 'reintents esgotats, a la DLQ');
      canal.nack(msg, false, false);                                                                       // → DLX → <cua>.dlq
    } else {
      logger.warn({ esdevenimentId: msg.properties.messageId, intent: intents + 1, err }, 'reintent diferit');
      canal.publish('', `${cua}.reintent`, msg.content, { ...msg.properties, headers: { ...msg.properties.headers, 'x-intents': intents + 1 } });
      canal.ack(msg);                                                                                      // ja està copiat a la cua de reintent
    }
  }
}, { noAck: false });
  • El comptador x-intents viatja a les capçaleres del mateix missatge (RabbitMQ no el compta per nosaltres de manera fiable). Es conserva la resta de capçaleres (requestId, traceparent de 06-02).
  • Un missatge verinós (poison message) és el que fallarà sempre: JSON mal format, un comandaId que no existeix, un bug del consumidor amb aquest cas concret. Reintentar-lo 5 vegades cada 30 s només retarda els altres; per això un error permanent va a la DLQ a la primera. I per això el prefetch(10) importa: amb prefetch(1) un missatge verinós bloquejaria tota la cua fins a esgotar reintents.
  • Amb maxIntents = 5 i TTL de 30 s, un missatge es processa fins a 5 vegades al llarg de ~2 minuts: suficient perquè un PSP es recuperi d'un 503; menys que els 15 minuts d'expira_en de la reserva.

Processar la DLQ. Una DLQ amb missatges és un incident petit (a 06-05 serà una alerta a l'equip propietari de la cua). El procediment: (1) inspeccionar a la consola de RabbitMQ o amb l'script (capçaleres, x-intents, la primera excepció que es va registrar amb aquest esdevenimentId a Loki); (2) si la causa era transitòria i ja s'ha resolt (Pagaments ha tornat), reprocessar; (3) si el missatge és verinós, corregir el consumidor o descartar el missatge documentant per què. Script scripts/reprocessarDlq.js de la llibreria:

// node scripts/reprocessarDlq.js --cua comandes.saga --max 50 [--filtre-esdeveniment comanda.cancellada] [--descartar]
async function reprocessar({ cua, max, filtreEsdeveniment, descartar }) {
  const canal = await connectar(process.env.RABBITMQ_URL);
  let moguts = 0;
  while (moguts < max) {
    const msg = await canal.get(`${cua}.dlq`, { noAck: false });
    if (!msg) break;
    const tipus = msg.fields.routingKey;
    if (filtreEsdeveniment && tipus !== filtreEsdeveniment) { canal.nack(msg, false, true); continue; }   // el deixem a la DLQ
    if (descartar) { logger.warn({ esdevenimentId: msg.properties.messageId, tipus }, 'descartat de la DLQ'); canal.ack(msg); moguts++; continue; }
    const headers = { ...msg.properties.headers, 'x-intents': 0, 'x-reproces': new Date().toISOString() };
    canal.publish('', cua, msg.content, { ...msg.properties, headers });          // directe a la cua principal
    canal.ack(msg);
    logger.info({ esdevenimentId: msg.properties.messageId, tipus }, 'reprocessat des de la DLQ');
    moguts++;
  }
  await canal.close();
}

S'executa des d'un pod efímer (kubectl run amb la imatge del servei) o com a Job; reinicia x-intents i deixa empremta amb x-reproces (i l'span amb link de 06-02). És l'operació més habitual de la guàrdia (06-05).

  1. Sagues encallades: el vigilant de TIMEOUT_PAGAMENT i la reconciliació

La saga per coreografia de 02-05 no té coordinador: si pagament.confirmat no arriba mai (Pagaments caigut més del que duren els reintents, missatge a la DLQ), la comanda es queda en ESTOC_RESERVAT i la reserva bloqueja estoc. A 02-05 vam definir el vigilant de TIMEOUT_PAGAMENT; ara l'implementem com un job periòdic dins de servei-comandes (mateix procés que el relay, amb setInterval, i protegit amb SELECT … FOR UPDATE SKIP LOCKED perquè dues rèpliques no cancel·lin la mateixa comanda):

// servei-comandes/src/missatgeria/vigilantSaga.js
function crearVigilantSaga({ bd, intervalMs = 60000, limitMinuts = 10, logger }) {
  async function cicle() {
    const client = await bd.connect();
    try {
      await client.query('BEGIN');
      const { rows } = await client.query(
        `SELECT id FROM comandes WHERE estat = 'ESTOC_RESERVAT' AND actualitzat_en < now() - ($1 || ' minutes')::interval
         FOR UPDATE SKIP LOCKED LIMIT 100`, [limitMinuts]);
      for (const { id } of rows) {
        await client.query(`UPDATE comandes SET estat = 'CANCELLADA', motiu_cancellacio = 'TIMEOUT_PAGAMENT', actualitzat_en = now() WHERE id = $1`, [id]);
        await client.query(`INSERT INTO outbox (id, tipus, dades, capcaleres) VALUES ($1, 'comanda.cancellada', $2, $3)`,
          [ulid(), { comandaId: id, motiu: 'TIMEOUT_PAGAMENT' }, { origen: 'vigilantSaga' }]);
        logger.warn({ comandaId: id }, 'comanda cancel·lada per TIMEOUT_PAGAMENT');
      }
      await client.query('COMMIT');
    } catch (err) { await client.query('ROLLBACK'); logger.error({ err }, 'error al vigilant'); }
    finally { client.release(); }
  }
  const temporitzador = setInterval(() => cicle().catch(() => {}), intervalMs);
  return { aturar: () => clearInterval(temporitzador) };
}
  • Cada minut busca comandes amb més de 10 minuts en ESTOC_RESERVAT (menys que els 15 d'expira_en: cancel·lem abans que la reserva caduqui sola, i la comanda.cancellada fa que Inventari publiqui estoc.alliberat).
  • Cancel·lació i esdeveniment a la mateixa transacció, via outbox: si el vigilant mor a mitges, no hi ha comanda cancel·lada sense esdeveniment.
  • SKIP LOCKED permet que diverses rèpliques executin el vigilant sense trepitjar-se.
  • És idempotent per construcció: al segon cicle la comanda ja no és en ESTOC_RESERVAT.

I com a últim recurs, la reconciliació periòdica entre serveis: un CronJob de Kubernetes (reconciliar-reserves, cada hora) a Inventari llista les seves reserves ACTIVES amb més de 20 minuts, pregunta a Comandes (GET /v1/comandes/{id} intern) per l'estat de cadascuna i allibera les de comandes CANCELLADA o inexistents, registrant cada discrepància amb error (no n'hi hauria d'haver cap: si n'hi ha, alguna cosa falla a la saga i cal investigar). La reconciliació no substitueix la saga; és la xarxa sota el trapezi.

  1. Gestió d'errors al codi: ErrorNegoci, promeses i SIGTERM

Regles del codi de TechCorp, ja iniciades a 04-02 i ara completes:

  • ErrorNegoci per al que és esperat, Error per al que és inesperat. ErrorNegoci('SENSE_ESTOC', …, 409) és una resposta; un TypeError és un bug. middlewareErrors tradueix el primer a RFC 7807 amb el seu codi i el segon a un 500 genèric amb requestId (sense filtrar l'stack al client) i el registra amb error.
  • No empassar-se excepcions. Un catch (e) {} converteix una fallada visible en una comanda perduda en silenci. Si es captura, es fa alguna cosa: es tradueix, es reintenta, es compensa o es rellança.
  • Promeses sense capturar. Un await oblidat en un consumidor genera un unhandledRejection que Node 20 converteix en caiguda del procés. A servidor.js: process.on('unhandledRejection', (err) => { logger.fatal({ err }, 'promesa sense capturar'); process.exit(1); }); millor morir sorollosament i que Kubernetes reiniciï que continuar en un estat desconegut. El mateix amb uncaughtException.
  • Tancar bé en SIGTERM (05-02, terminationGracePeriodSeconds: 30): deixar d'acceptar connexions (server.close), posar /health/ready en 503, esperar que acabin les peticions en curs, cancel·lar el consume de RabbitMQ i esperar que els missatges ja rebuts es processin o es facin nack, aturar el relay i el vigilant, tancar pools i buidar l'SDK de traces (06-02). Sense això, cada rolling produeix missatges reentregats i peticions tallades.

  1. Proves de resiliència i chaos engineering bàsic

Els patrons que no es proven no funcionen el dia que fan falta. Tres nivells, de menys a més:

  1. Proves unitàries dels patrons (04-05): reintentar amb un doble que falla dues vegades i després respon; el breaker que s'obre a la cinquena fallada i es tanca després de la prova; el consumidor que envia a .reintent un 503 i a .dlq un JSON invàlid. Amb jest.useFakeTimers() no cal esperar 30 s reals.
  2. Fallades injectades en integració: a Testcontainers, aturar el contenidor de Catàleg a meitat d'una prova i comprovar que POST /v1/comandes retorna 503 amb Retry-After en menys de 3 s; afegir latència amb toxiproxy (un proxy que se situa entre Comandes i Catàleg i afegeix 3 s o talla connexions) o amb tc qdisc add dev eth0 root netem delay 500ms dins del contenidor.
  3. Game days a staging: un cop al trimestre l'equip de Plataforma mata pods a l'atzar (kubectl delete pod -l app=servei-pagaments), talla RabbitMQ 5 minuts, omple la DLQ o posa Catàleg a 2 s, i els equips observen si els dashboards ho mostren, si les alertes (06-05) salten, si la saga es recupera i quant triguen a entendre-ho. S'anota el que no ha funcionat i es converteix en tasques. Eines com Chaos Mesh o LitmusChaos automatitzen la injecció a Kubernetes; TechCorp comença a mà.

  1. Taula resum: patró → problema → on viu a TechCorp

Patró Problema que resol On viu
Timeout amb pressupost Esperes indefinides, esgotament de recursos crearClientHttp (AbortSignal.timeout), pools de pg/MongoDB, TIMEOUT_HTTP_MS
Reintent amb backoff + jitter Errors transitoris reintentar() a @techcorp/comu-http; només idempotents/transitoris
Idempotència "S'ha executat o no?" Idempotency-Key, processarUnCop, esdeveniments_processats (02-05, 03-01)
Circuit breaker Fallada en cascada, dependència caiguda crearCircuitBreaker() a catalegClient/clientsClient; mètrica circuit_breaker_estat
Bulkhead Una dependència lenta esgota el servei p-limit per dependència; pools separats; resources.limits
Fallback / degradació Respondre alguna cosa útil sense la dependència BFF sense noms de producte; Comandes → 503 + Retry-After
Rate limiting / backpressure Sobrecàrrega Gateway 300/min i 429 (03-04); prefetch(10)
Fail-fast a l'arrencada Estat inconsistent Config zod (04-03); /health/ready 503 fins a tenir dependències
Cua de reintent amb TTL + DLQ Fallades transitòries i missatges verinosos en consumidors <cua>.reintent (TTL 30 s), <cua>.dlq, x-intents, scripts/reprocessarDlq.js
Vigilant de saga Sagues encallades vigilantSaga.js a Comandes (TIMEOUT_PAGAMENT, 10 min)
Reconciliació El que s'escapa de tot l'anterior CronJob reconciliar-reserves a Inventari
Aturada ordenada Missatges i peticions perduts en desplegaments SIGTERM a servidor.js
Chaos engineering Comprovar que tot l'anterior funciona Proves amb toxiproxy, game days trimestrals

Errors Comuns i Consells

  • Reintentar-ho tot. Reintentar un POST sense clau d'idempotència crea duplicats; reintentar un 400 és perdre el temps; reintentar contra un servei sobrecarregat l'enfonsa. Reintent = transitori i idempotent.
  • Reintentar sense jitter. Tots els clients tornen alhora i creen el "ramat atronador".
  • Breaker global per a totes les dependències. Una fallada a Clients tancaria també Catàleg. Un per dependència.
  • Timeouts iguals a tota la cadena. El cridant caduca al mateix temps que el cridat i ningú no retorna una resposta útil. Decreixents cap endins.
  • prefetch(1) "per processar en ordre". Un missatge verinós bloqueja la cua. L'ordre es tracta d'una altra manera (06-04).
  • DLQ sense propietari ni procediment. Els missatges s'acumulen mesos; ningú no sap si es poden reprocessar. Cada cua té equip propietari (02-01) i la seva DLQ, alerta i runbook (06-05).
  • Ignorar SIGTERM. Cada desplegament deixa comandes a mitges que després el vigilant cancel·la. Aturada ordenada completa.
  • Confondre fallback amb mentir. Retornar "estoc disponible" perquè Inventari no respon no és degradació elegant, és una comanda que es cancel·larà. Degradar només el que no compromet el negoci.
  • Consell: cada patró deixa rastre als logs (warn) i a les mètriques (circuit_breaker_estat, rabbitmq_queue_messages_ready{queue=~".*reintent|.*dlq"}); si un patró actua sovint, no és un èxit del patró, és un problema que cal arreglar a la dependència.
  • Consell: documentar per servei, al seu README, la taula de dependències amb timeout, reintents, breaker i fallback de cadascuna. És la primera pàgina que obre la guàrdia.

Exercicis

Exercici 1: client de Pagaments cap al PSP

servei-pagaments crida un proveïdor de pagaments extern (POST /cobraments) que de vegades retorna 503 i de vegades triga més de 5 s. Dissenya la cadena de patrons (timeout, reintent, breaker, bulkhead) amb valors concrets i explica com garanteixes que un timeout no produeix un doble cobrament.

Exercici 2: decidir el destí de tres missatges

A pagaments.estoc (consumidor d'estoc.reservat a Pagaments) arriben tres missatges que fallen: (a) el PSP ha retornat 503; (b) el JSON del missatge no té comandaId; (c) la consulta a PostgreSQL falla amb 40P01 deadlock_detected. Indica per a cadascun si va a .reintent, a la .dlq o es reintenta al mateix procés, i què es registra.

Exercici 3: el vigilant i la reserva

Una comanda porta 12 minuts en ESTOC_RESERVAT perquè el missatge pagament.confirmat és a comandes.saga.dlq (Comandes tenia un bug en processar-lo). Descriu la seqüència del que fa el sistema automàticament, què veu la guàrdia i què ha de fer, i què hauria passat si el vigilant hagués actuat als 16 minuts en comptes d'als 10.

Solucions

Exercici 1

  • Timeout: 5 s per intent (el PSP és lent per naturalesa; el gateway no espera aquesta crida, és un consumidor d'estoc.reservat).
  • Idempotència primer: el PSP admet una clau d'idempotència; s'envia Idempotency-Key = comandaId. Així el segon intent després d'un timeout retorna el mateix cobrament en comptes d'un de nou. A més, abans de cridar, Pagaments registra a la seva BD cobraments(comandaId, estat='INICIAT'); si el procés mor i el missatge es reentrega, comprova primer l'estat i consulta al PSP per la clau abans de tornar a cobrar.
  • Reintent: reintentar(fn, { intents: 3, baseMs: 500, maxMs: 5000 }) només per a 503/429/timeout/ECONNRESET; un 402 (targeta rebutjada) és permanent → pagament.rebutjat.
  • Breaker: crearCircuitBreaker({ nom: 'psp', llindarFallades: 5, finestraMs: 30000, tempsObertMs: 60000 }); obert → el missatge va a pagaments.estoc.reintent (transitori) sense cridar.
  • Bulkhead: p-limit(10) cap al PSP; la resta de Pagaments (consultes d'estat) no en depèn.
  • Pressupost total del missatge: 3 × 5 s + esperes ≈ 20 s, molt per sota dels 10 minuts del vigilant.

Exercici 2

  • (a) 503 del PSP: transitori: true → publicar a pagaments.estoc.reintent amb x-intents+1; warn "reintent diferit" amb esdevenimentId, comandaId, intent.
  • (b) sense comandaId: error de validació permanent (missatge verinós) → nack(requeue=false)pagaments.estoc.dlq a la primera; error "missatge verinós, a la DLQ" amb l'esdevenimentId i el motiu. Algú haurà de veure per què Inventari va publicar aquest esdeveniment (contracte de 03-06).
  • (c) deadlock: transitori i local → reintentar la transacció dins del procés (reintentar amb intents: 3, baseMs: 50) sense passar per la cua; només si esgota els intents, a .reintent. warn per cada intent amb el codi SQL.

Exercici 3

Seqüència: (1) el consumidor de comandes.saga falla en processar pagament.confirmat amb un TypeError (permanent) → DLQ a la primera; error a Loki amb esdevenimentId i comandaId; rabbitmq_queue_messages_ready{queue="comandes.saga.dlq"} = 1. (2) Als 10 minuts el vigilant troba la comanda en ESTOC_RESERVAT, la passa a CANCELLADA amb TIMEOUT_PAGAMENT i publica comanda.cancellada. (3) Inventari allibera la reserva (estoc.alliberat) i Pagaments, en rebre comanda.cancellada d'una comanda que sí que va cobrar, publica pagament.reemborsat (compensació de 02-05); Notificacions avisa el client. La guàrdia veu l'alerta de DLQ (06-05), llegeix l'error a Loki, corregeix el bug i desplega; després decideix si reprocessar el missatge: no en aquest cas, perquè la comanda ja està cancel·lada i reemborsada (reprocessar pagament.confirmat sobre una CANCELLADA ha de ser ignorat per la màquina d'estats, i així ho comprova); el descarta amb --descartar i contacta amb el client si escau. Si el vigilant actués als 16 minuts, la reserva hauria expirat sola als 15 (expira_en) i l'estoc s'hauria alliberat sense esdeveniment coordinat; la comanda continuaria en ESTOC_RESERVAT un minut més amb una reserva ja inexistent i el reemborsament arribaria més tard: funciona, però és menys net. Per això el vigilant corre abans que l'expiració.

Conclusió

Aquesta lliçó ha complert la promesa de "dissenyar per a la fallada". Tot el que surt per xarxa té timeout amb pressupost decreixent (crearClientHttp); els errors transitoris sobre operacions idempotents es reintenten amb reintentar() (backoff exponencial i jitter); les dependències caigudes s'aïllen amb crearCircuitBreaker() per dependència (mètrica circuit_breaker_estat) i p-limit com a bulkhead; es degrada el que es pot (BFF) i es falla bé el que no (Comandes → 503 amb Retry-After); la sobrecàrrega es frena amb 429 i prefetch; l'arrencada falla ràpid i l'execució tolera. Al món asíncron, cada cua té ara <cua>.reintent amb TTL de 30 s, <cua>.dlq després de 5 intents o a la primera per a missatges verinosos, i scripts/reprocessarDlq.js; les sagues encallades les cancel·la vigilantSaga.js als 10 minuts amb TIMEOUT_PAGAMENT i la reconciliació horària de reserves és l'última xarxa; el procés mor davant de promeses sense capturar i s'atura amb ordre en SIGTERM; i tot es prova amb dobles, toxiproxy i game days. Amb la resiliència resolta, la pregunta següent és de capacitat: quan arribi el Black Friday amb el catàleg ×20 i les comandes ×3, quantes rèpliques calen, com escalen soles i on són els colls d'ampolla? Aquest és el tema de la lliçó següent: escalabilitat i rendiment.

Curs de Microserveis

Mòdul 1: Introducció als Microserveis

Mòdul 2: Disseny de Microserveis

Mòdul 3: Comunicació entre Microserveis

Mòdul 4: Implementació de Microserveis

Mòdul 5: Desplegament i Orquestració

Mòdul 6: Monitoratge i Manteniment

Mòdul 7: Seguretat en Microserveis

Mòdul 8: Casos d'Estudi i Exemples Pràctics

© Copyright 2026. Tots els drets reservats