Fem inventari del deute acumulat. A controladors/cafes.js hi ha un res.status(404).json({error: {...}}) repetit tres vegades; a controladors/comandes.js, un altre de gairebé idèntic; el middleware de validació construeix el seu 400; el d'autenticació, dues variants de 401 i un 403; el servei d'autenticació retorna {error: 'email_ja_registrat'} i el de comandes {noTrobat: true}, dos convenis diferents per al mateix; i des que bcrypt va portar controladors async, una excepció inesperada deixa la petició penjada sense resposta. Avui tot això se substitueix per dues peces: una classe d'error de domini, ErrorApi, que els serveis llancen sense saber que existeix HTTP, i un únic middleware d'errors que la tradueix al format del contracte. En acabar, cap fitxer fora de src/middleware/errors.js no construirà una resposta d'error.

Contingut

  1. Per què centralitzar
  2. La classe ErrorApi
  3. Les fàbriques d'errors del catàleg
  4. Serveis que llancen en comptes de retornar
  5. L'embolcall asincron(fn)
  6. El middleware d'errors d'Express
  7. ErrorApi davant d'error inesperat
  8. El tracaId i la seva correlació amb els logs
  9. Traduir el ZodError
  10. Traduir els errors de SQLite
  11. Traduir els errors d'express.json()
  12. El 404 de rutes i el 405 amb Allow
  13. L'ordre definitiu de src/app.js
  14. Què no exposar mai en un error
  15. Errors no capturats del procés i apagada ordenada
  16. Taula de referència: situació → excepció → resposta
  17. problem+json i per què mantenim el format propi

  1. Per què centralitzar

Els quatre problemes de l'enfocament dispers, per ordre de gravetat:

Problema Conseqüència
Format inconsistent Un 404 amb detalls i un altre sense; els clients han de programar defensivament
Canvis impossibles Afegir tracaId a tots els 5xx obliga a tocar vint fitxers
Fuites accidentals Un res.json({ error: err.message }) publica un camí del sistema o una consulta SQL
Capes contaminades El servei decideix codis HTTP i deixa de ser reutilitzable fora de l'API

La solució té dues meitats que cal entendre juntes:

  • Els serveis llancen errors de domini. Diuen què ha passat (el cafè no existeix), no com es respon. No esmenten codis HTTP.
  • Un middleware tradueix. És l'únic que coneix el format de la resposta d'error, i per tant l'únic que pot garantir que sempre és el mateix.
graph TD
  S[Servei: throw ErrorApi] --> C[Controlador]
  C -->|no captura| E[Middleware d'errors]
  V[Zod: ZodError] --> E
  B[SQLite: SqliteError] --> E
  J[express.json: SyntaxError] --> E
  X[Bug inesperat: TypeError] --> E
  E --> R[Resposta única del contracte]

  1. La classe ErrorApi

// src/errors/error-api.js

/**
 * Error de domini de l'API.
 *
 * Porta la informació necessària per construir la resposta, però es llança
 * des dels serveis sense que aquests sàpiguen res d'Express: 'estat' és un
 * número, no una crida a res.status().
 */
export class ErrorApi extends Error {
  /**
   * @param {number} estat     Codi HTTP (404, 409, ...)
   * @param {string} codi      Codi del catàleg de 02-04 ('cafe_no_trobat')
   * @param {string} missatge  Missatge llegible per al consumidor
   * @param {object[]} detalls Llista de problemes concrets; SEMPRE present
   */
  constructor(estat, codi, missatge, detalls = []) {
    super(missatge);

    // Sense això, error.name seria 'Error' i els logs perdrien informació.
    this.name = 'ErrorApi';
    this.estat = estat;
    this.codi = codi;
    this.detalls = detalls;

    // Marca explícita: el middleware la fa servir per distingir un error
    // previst del catàleg d'una fallada inesperada del programa.
    this.esErrorApi = true;

    // Retalla la traça perquè comenci on es va llançar, no al constructor.
    Error.captureStackTrace?.(this, ErrorApi);
  }
}

Dues decisions. Estendre Error conserva la traça de la pila i fa que la classe funcioni amb throw, try/catch i les eines de depuració. I la marca esErrorApi en comptes de fiar-se només d'instanceof: si per qualsevol motiu hi hagués dues còpies del mòdul carregades —cosa que passa amb certes configuracions de proves o amb dependències duplicades—, instanceof fallaria mentre que la propietat continua allà.

  1. Les fàbriques d'errors del catàleg

Escriure new ErrorApi(404, 'cafe_no_trobat', '...') a vint llocs reintrodueix el problema que volem resoldre: res no garanteix que el codi i l'estat lliguin. Les fàbriques ho garanteixen.

// src/errors/error-api.js  (continuació)

export const errors = {
  // --- 400 ---
  dadesInvalides(detalls = []) {
    return new ErrorApi(
      400,
      'dades_invalides',
      'El cos de la petició conté errors de validació.',
      detalls
    );
  },

  parametreInvalid(detalls = []) {
    return new ErrorApi(
      400,
      'parametre_invalid',
      'Els paràmetres de la petició contenen errors.',
      detalls
    );
  },

  // --- 401 ---
  noAutenticat(missatge = "Aquesta operació requereix autenticació.") {
    return new ErrorApi(401, 'no_autenticat', missatge);
  },

  tokenCaducat() {
    return new ErrorApi(401, 'token_caducat', 'El token ha caducat. Torna a iniciar sessió.');
  },

  // --- 403 ---
  permisDenegat(rolsPermesos = []) {
    const detall =
      rolsPermesos.length > 0
        ? ` Es requereix un d'aquests rols: ${rolsPermesos.join(', ')}.`
        : '';
    return new ErrorApi(403, 'permisos_insuficients', `No tens permís.${detall}`);
  },

  // --- 404 ---
  noTrobat(tipus, id) {
    // Mapa entitat → codi del catàleg. Un tipus nou s'afegeix aquí.
    const codis = {
      cafe: 'cafe_no_trobat',
      client: 'client_no_trobat',
      comanda: 'comanda_no_trobada',
      ressenya: 'ressenya_no_trobada',
      cistella: 'cistella_no_trobada',
    };
    return new ErrorApi(
      404,
      codis[tipus] ?? 'ruta_no_trobada',
      `No existeix cap ${tipus} amb l'identificador '${id}'.`
    );
  },

  rutaNoTrobada(metode, url) {
    return new ErrorApi(404, 'ruta_no_trobada', `No existeix el recurs ${metode} ${url}.`);
  },

  // --- 405 / 409 / 413 / 415 ---
  metodeNoPermes(metode, ruta) {
    return new ErrorApi(405, 'metode_no_permes', `${metode} no està permès sobre ${ruta}.`);
  },

  conflicte(codi, missatge, detalls = []) {
    return new ErrorApi(409, codi, missatge, detalls);
  },

  cosMassaGran() {
    return new ErrorApi(413, 'cos_massa_gran', 'El cos supera la mida màxima (100 kB).');
  },

  formatNoSuportat(tipus) {
    return new ErrorApi(
      415,
      'format_no_suportat',
      `El tipus de contingut '${tipus}' no s'admet en aquesta operació.`
    );
  },

  // --- 500 ---
  errorIntern() {
    return new ErrorApi(500, 'error_intern', "S'ha produït un error inesperat.");
  },
};

Fixa't en conflicte(codi, ...): els 409 comparteixen estat però no codi —estoc_insuficient, comanda_ja_pagada, conflicte_versio, cistella_buida—, així que la fàbrica rep el codi i garanteix només el 409. L'important és que el catàleg de 02-04 i aquest fitxer són la mateixa cosa; si un codi no és aquí, no existeix.

  1. Serveis que llancen en comptes de retornar

Ara els serveis es netegen. Abans:

// ABANS: convenis ad hoc que el controlador havia de conèixer
obtenir(id) {
  return repositoriCafes.buscarPerId(id);   // undefined si no existeix
},

Després:

// src/serveis/cafes.js  (modificat)
import { errors } from '../errors/error-api.js';

export const serveiCafes = {
  /** Retorna un cafè. Llança si no existeix. */
  obtenir(id) {
    const cafe = repositoriCafes.buscarPerId(id);
    if (!cafe) throw errors.noTrobat('cafe', id);
    return cafe;
  },

  reemplacar(id, dades) {
    this.obtenir(id);   // llança 404 si no existeix: sense duplicar el missatge
    return repositoriCafes.actualitzar(id, { /* ...camps... */ });
  },

  esborrar(id) {
    if (!repositoriCafes.esborrar(id)) throw errors.noTrobat('cafe', id);
  },
};

I el servei de comandes, amb els seus errors de negoci:

// src/serveis/comandes.js  (modificat)
import { errors } from '../errors/error-api.js';
import { crearComandaAtomica } from '../repositoris/comandes-sqlite.js';

export const serveiComandes = {
  obtenirPer(id, usuari) {
    const comanda = repositoriComandes.buscarPerId(id);
    const esPersonal = ['empleat', 'administrador'].includes(usuari.rol);

    // Comanda inexistent i comanda aliena donen EXACTAMENT el mateix error,
    // per no filtrar-ne l'existència (03-06, secció 14).
    if (!comanda || (comanda.clientId !== usuari.id && !esPersonal)) {
      throw errors.noTrobat('comanda', id);
    }
    return comanda;
  },

  crear({ clientId, linies }) {
    try {
      const id = crearComandaAtomica({ clientId, linies });
      return repositoriComandes.buscarPerId(id);
    } catch (error) {
      // El repositori llança errors amb 'codiDomini' (03-05); aquí es
      // converteixen en ErrorApi. La traducció viu al servei perquè
      // és ell qui decideix que un estoc insuficient és un 409.
      if (error.codiDomini === 'estoc_insuficient') {
        throw errors.conflicte('estoc_insuficient', error.message);
      }
      if (error.codiDomini === 'cafe_no_trobat') {
        throw errors.noTrobat('cafe', "d'alguna línia de la comanda");
      }
      throw error;   // no és nostre: que el middleware el tracti com un 500
    }
  },

  pagar(id, usuari) {
    const comanda = this.obtenirPer(id, usuari);
    if (comanda.estat === 'pagat') {
      throw errors.conflicte('comanda_ja_pagada', `La comanda '${id}' ja està pagada.`);
    }
    // ...marcar com a pagada...
  },
};

Aquell darrer throw error és important: el que no sabem traduir, es propaga. Empassar-se un error desconegut per "no trencar res" és la manera més eficaç que una fallada greu passi desapercebuda.

I el controlador queda reduït al seu paper real:

// src/controladors/cafes.js  (modificat)
obtenir(req, res) {
  const cafe = serveiCafes.obtenir(req.params.id);   // si falla, llança
  res.status(200).json(projectar(cafeARepresentacio(cafe), req.query.camps));
},

Una línia de feina i una de resposta. No hi ha if (!cafe), no hi ha 404 escrit a mà, no hi ha missatge duplicat.

  1. L'embolcall asincron(fn)

Amb controladors síncrons, Express 4 captura el throw i el porta al middleware d'errors. Amb controladors async —els de clients i sessions ja ho són, i tots ho serien amb PostgreSQL— no, tal com vam diagnosticar a 03-03: la funció retorna una promesa rebutjada que ningú no observa, i la petició es queda penjada.

// src/middleware/asincron.js

/**
 * Embolcalla un gestor perquè qualsevol rebuig de promesa acabi a
 * next(error) i, per tant, al middleware d'errors.
 *
 * Promise.resolve() funciona igual si fn és síncrona (retorna una promesa
 * ja resolta) o asíncrona, així que es pot embolcallar TOT sense pensar-hi.
 */
export function asincron(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

Ús a les rutes:

// src/rutes/cafes.js  (modificat)
import { asincron } from '../middleware/asincron.js';

rutesCafes.get('/', validar(esquemaConsultaCafes, 'query'), asincron(controladorCafes.llistar));
rutesCafes.get('/:id', validar(esquemaIdCafe, 'params'), asincron(controladorCafes.obtenir));
rutesCafes.post(
  '/',
  autenticar,
  exigirRol('empleat', 'administrador'),
  validar(esquemaCrearCafe),
  asincron(controladorCafes.crear)
);
// ...i així a totes

Embolcalla tots els gestors, els síncrons inclosos. El cost és nul i elimina la classe de bug més difícil de detectar: el dia que algú converteixi un controlador en async sense recordar-se de l'embolcall, la ruta deixaria de respondre davant de qualsevol error i ningú no ho notaria fins a producció.

Hi ha paquets que fan això (express-async-errors, que apedaça Express en importar-lo) i Express 5 ja ho fa de sèrie: un gestor async que rebutja va directe al middleware d'errors. És la raó més pràctica per migrar. El nostre embolcall és explícit, són quatre línies i no depèn de res.

  1. El middleware d'errors d'Express

// src/middleware/errors.js
import { ZodError } from 'zod';
import { ErrorApi, errors } from '../errors/error-api.js';
import { entorn } from '../config/entorn.js';

/**
 * Middleware d'errors.
 *
 * LA SIGNATURA DE QUATRE ARGUMENTS ÉS OBLIGATÒRIA. Express distingeix un
 * middleware d'errors d'un de normal comptant els paràmetres de la
 * funció: amb tres és normal, amb quatre és d'errors. Per això 'next'
 * s'ha de declarar encara que no es faci servir, i per això ESLint
 * necessitava la regla argsIgnorePattern: '^_' que vam configurar a 03-01.
 */
// eslint-disable-next-line no-unused-vars
export function gestorErrors(err, req, res, next) {
  const errorApi = traduir(err);

  // --- Registre per a l'equip ---
  if (errorApi.estat >= 500) {
    // Els 5xx són fallades NOSTRES: es registren senceres, amb traça.
    console.error(
      JSON.stringify({
        nivell: 'error',
        tracaId: req.tracaId,
        metode: req.method,
        ruta: req.originalUrl,
        usuari: req.usuari?.id ?? null,
        missatge: err.message,
        pila: err.stack,
      })
    );
  } else {
    // Els 4xx són fallades del client: n'hi ha prou amb una línia informativa.
    console.warn(
      JSON.stringify({
        nivell: 'avis',
        tracaId: req.tracaId,
        metode: req.method,
        ruta: req.originalUrl,
        codi: errorApi.codi,
      })
    );
  }

  // --- Capçaleres que exigeix el contracte segons el cas ---
  if (errorApi.estat === 401) {
    res.set('WWW-Authenticate', 'Bearer realm="api.botigaaroma.example"');
  }
  if (errorApi.estat === 405 && err.metodesPermesos) {
    res.set('Allow', err.metodesPermesos.join(', '));
  }
  if (errorApi.codi === 'format_no_suportat' && req.method === 'PATCH') {
    res.set('Accept-Patch', 'application/merge-patch+json');
  }

  // --- Cos del contracte (02-04) ---
  const cos = {
    error: {
      codi: errorApi.codi,
      missatge: errorApi.missatge ?? errorApi.message,
      detalls: errorApi.detalls ?? [],
    },
  };

  // tracaId NOMÉS als 5xx, com vam decidir a 02-04.
  if (errorApi.estat >= 500) {
    cos.error.tracaId = req.tracaId;

    // En desenvolupament ajuda veure la causa; en producció, MAI.
    if (entorn.nodeEnv !== 'produccio') {
      cos.error.depuracio = err.message;
    }
  }

  res.status(errorApi.estat).json(cos);
}

I la funció que decideix què és cada error:

// src/middleware/errors.js  (continuació)

/** Converteix qualsevol excepció en un ErrorApi del catàleg. */
function traduir(err) {
  // 1. Ja és nostre: es fa servir tal qual.
  if (err?.esErrorApi) return err;

  // 2. Validació de Zod que s'ha escapat del middleware de validació.
  if (err instanceof ZodError) {
    return errors.dadesInvalides(
      err.issues.map((i) => ({
        camp: i.path.join('.') || '(cos)',
        codi: 'valor_invalid',
        missatge: i.message,
      }))
    );
  }

  // 3. JSON mal format, detectat per express.json().
  if (err instanceof SyntaxError && 'body' in err) {
    return new ErrorApi(400, 'dades_invalides', 'El cos no és JSON vàlid.', [
      { camp: '(cos)', codi: 'json_mal_format', missatge: err.message },
    ]);
  }

  // 4. Cos massa gran (limit d'express.json).
  if (err?.type === 'entity.too.large') return errors.cosMassaGran();

  // 5. Errors de SQLite.
  const deSqlite = traduirSqlite(err);
  if (deSqlite) return deSqlite;

  // 6. Qualsevol altra cosa és un bug nostre: 500 sense filtrar res.
  return errors.errorIntern();
}

  1. ErrorApi davant d'error inesperat

La distinció del pas 6 és el cor del middleware:

ErrorApi (previst) Error inesperat (bug)
Origen Llançat a propòsit per un servei TypeError, ReferenceError, fallada de llibreria
Estat El que diu l'error: 400, 404, 409… Sempre 500
codi Del catàleg Sempre error_intern
Missatge al client Específic i útil Genèric: "S'ha produït un error inesperat"
tracaId No
Log Avís d'una línia Error complet amb pila
De qui és la culpa? Del client Nostra

Un exemple del segon cas. Si un dia un bug produeix TypeError: Cannot read properties of undefined (reading 'preuCentims'), el client rep:

{
  "error": {
    "codi": "error_intern",
    "missatge": "S'ha produït un error inesperat.",
    "detalls": [],
    "tracaId": "trz_8f4a1c92"
  }
}

I als logs del servidor queda el detall complet, amb el fitxer, la línia, l'usuari i la ruta. El consumidor rep el que necessita per demanar ajuda; l'equip, el que necessita per arreglar-ho. Aquesta asimetria és intencionada i és una mesura de seguretat, no només d'estètica.

  1. El tracaId i la seva correlació amb els logs

// src/middleware/traca.js
import { randomBytes } from 'node:crypto';

/**
 * Assigna un identificador únic a cada petició.
 * Si ve d'un proxy o d'una passarel·la que ja el va generar, es respecta:
 * així la traça és contínua al llarg de tot el sistema (04-07).
 */
export function assignarTracaId(req, res, next) {
  const heretat = req.get('Aroma-Traca-Id');
  req.tracaId = heretat ?? `trz_${randomBytes(4).toString('hex')}`;

  // Es retorna sempre, no només als errors: permet al client
  // referenciar qualsevol petició en obrir una incidència.
  res.set('Aroma-Traca-Id', req.tracaId);
  next();
}

El flux quan alguna cosa falla és aquest: l'usuari veu tracaId: "trz_8f4a1c92", obre una incidència amb aquell identificador, i l'equip busca aquella cadena als logs i troba la petició exacta, amb el seu mètode, la seva ruta, el seu usuari i la pila de l'error. Sense tracaId, la investigació comença per "a quina hora va ser, més o menys?".

Fixa't en el prefix: Aroma-Traca-Id, no X-Traca-Id, per la decisió de 02-05 i el RFC 6648.

Això és el primer graó de l'observabilitat. Els logs estructurats amb nivells i pino, les mètriques, les traces distribuïdes amb OpenTelemetry i la correlació entre serveis són la lliçó 04-07; aquí n'hi ha prou que cada petició tingui nom i que aquell nom aparegui a les dues puntes.

  1. Traduir el ZodError

El middleware validar de 03-04 construïa la seva resposta. Ara llança i deixa de saber d'HTTP:

// src/middleware/validacio.js  (modificat)
import { errors } from '../errors/error-api.js';

export function validar(esquema, origen = 'body') {
  return (req, res, next) => {
    const resultat = esquema.safeParse(req[origen]);

    if (!resultat.success) {
      const detalls = aDetalls(resultat.error);
      // El middleware d'errors s'encarrega del format i del registre.
      return next(
        origen === 'body' ? errors.dadesInvalides(detalls) : errors.parametreInvalid(detalls)
      );
    }

    req[origen] = resultat.data;
    next();
  };
}

next(error) amb un argument salta tots els middleware normals i va directe al primer middleware d'errors. És el mecanisme estàndard d'Express per propagar fallades des d'un middleware, i la contrapartida de throw als gestors.

Les funcions aDetalls i traduirCodi es queden on eren: continuen sent responsabilitat de la validació, perquè coneixen l'estructura de Zod. El que canvia és que ja no decideixen el format de la resposta.

El pas 2 de traduir() és una xarxa de seguretat per als ZodError que es llancin fora del middleware —per exemple, un parse() dins d'un servei—. No hauria de passar, i si passa, el resultat continua sent correcte.

  1. Traduir els errors de SQLite

better-sqlite3 llança errors amb un camp code molt informatiu:

// src/middleware/errors.js  (continuació)

/** Errors de SQLite → errors del catàleg. Retorna null si no ho és. */
function traduirSqlite(err) {
  const codi = err?.code;
  if (typeof codi !== 'string' || !codi.startsWith('SQLITE_')) return null;

  switch (codi) {
    case 'SQLITE_CONSTRAINT_UNIQUE':
    case 'SQLITE_CONSTRAINT_PRIMARYKEY':
      // Algú ha intentat duplicar un valor únic (un email, per exemple).
      return new ErrorApi(409, 'recurs_duplicat', 'Ja existeix un recurs amb aquell valor únic.');

    case 'SQLITE_CONSTRAINT_FOREIGNKEY':
      // Es referencia alguna cosa que no existeix: una comanda d'un client inexistent.
      return new ErrorApi(
        409,
        'referencia_invalida',
        "L'operació referencia un recurs que no existeix."
      );

    case 'SQLITE_CONSTRAINT_CHECK':
      // Un CHECK de l'esquema (torrefacció invàlida, estoc negatiu). Si arriba
      // aquí és que la validació de 03-04 té un forat: es registra
      // com a 5xx perquè l'equip ho vegi, encara que la culpa sembli del client.
      return new ErrorApi(400, 'dades_invalides', 'Les dades no compleixen una restricció del model.');

    case 'SQLITE_BUSY':
      return new ErrorApi(503, 'servei_no_disponible', 'La base de dades està ocupada. Reintenta-ho.');

    default:
      return null;   // desconegut: que sigui un 500 amb el seu log complet
  }
}

Dos advertiments. El primer: el missatge que es retorna no és el de SQLite. L'original diria una cosa com UNIQUE constraint failed: clients.email, revelant el nom de la taula i de la columna; és just el tipus de dada que un atacant fa servir per mapar el teu esquema. El segon: aquesta traducció és una xarxa de seguretat, no la primera línia. L'email duplicat ja es comprova al servei de registre (03-06) i retorna un 409 email_ja_registrat amb un missatge molt més útil. Que a més existeixi la traducció cobreix la cursa entre dos registres simultanis amb el mateix correu, en què la comprovació prèvia pot passar i la base de dades ser l'única que se n'assabenti.

Un apunt per a PostgreSQL: els codis són diferents —23505 per a la unicitat, 23503 per a la clau forana— però l'estructura de la funció seria idèntica.

  1. Traduir els errors d'express.json()

Dos casos freqüents que avui produeixen respostes HTML d'Express:

# JSON mal format: falta una cometa
curl -s -X POST http://localhost:3000/v1/cafes \
  -H "Content-Type: application/json" \
  -d '{"nom": "Kenya, "origen": "Kenya"}' | jq
{
  "error": {
    "codi": "dades_invalides",
    "missatge": "El cos no és JSON vàlid.",
    "detalls": [
      {
        "camp": "(cos)",
        "codi": "json_mal_format",
        "missatge": "Unexpected token o in JSON at position 20"
      }
    ]
  }
}
# Cos de 2 MB contra un límit de 100 kB
curl -s -X POST http://localhost:3000/v1/cafes \
  -H "Content-Type: application/json" \
  --data-binary @gegant.json | jq .error.codi
"cos_massa_gran"

La comprovació err instanceof SyntaxError && 'body' in err mereix explicació: SyntaxError és una classe estàndard de JavaScript i pot venir de qualsevol lloc, però el paquet body-parser que fa servir Express hi afegeix una propietat body amb el text rebut. Aquella propietat és el que distingeix "el client ha enviat JSON trencat" de "hi ha un eval mal escrit al codi".

Sobre el missatge: exposar "Unexpected token o in JSON at position 20" és acceptable perquè descriu l'entrada del client mateix, no el nostre sistema, i li diu exactament on ha de mirar. És l'excepció que confirma la regla de la secció 14.

  1. El 404 de rutes i el 405 amb Allow

El calaix de sastre de 03-02 ara delega:

// src/middleware/no-trobat.js
import { errors } from '../errors/error-api.js';

/** Cap ruta no ha coincidit: 404 ruta_no_trobada. */
export function gestorNoTrobat(req, res, next) {
  next(errors.rutaNoTrobada(req.method, req.originalUrl));
}

El 405 és diferent i més subtil: la URI existeix, però no per a aquell mètode. DELETE /v1/cafes (sobre la col·lecció) no és al mapa d'URIs, però GET i POST sí. Retornar 404 seria mentir; el correcte és 405 amb la capçalera Allow, obligatòria segons el RFC 9110.

// src/middleware/no-trobat.js  (continuació)
import { ErrorApi } from '../errors/error-api.js';

/**
 * Retorna un gestor que respon 405 amb Allow.
 * Es registra amb router.all() al final de cada grup de rutes, així que
 * només s'assoleix si la URI ha coincidit però cap mètode no ho ha fet.
 */
export function metodeNoPermes(...metodesPermesos) {
  return (req, res, next) => {
    const error = new ErrorApi(
      405,
      'metode_no_permes',
      `${req.method} no està permès sobre ${req.baseUrl}${req.path}.`
    );
    // El middleware d'errors llegirà aquesta propietat per posar Allow.
    error.metodesPermesos = [...metodesPermesos, 'OPTIONS'];
    next(error);
  };
}
// src/rutes/cafes.js  (al final del fitxer, després de totes les rutes)
import { metodeNoPermes } from '../middleware/no-trobat.js';

rutesCafes.all('/', metodeNoPermes('GET', 'POST'));
rutesCafes.all('/:id', metodeNoPermes('GET', 'PUT', 'PATCH', 'DELETE'));
curl -i -s -X DELETE http://localhost:3000/v1/cafes | head -5
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST, OPTIONS
Content-Type: application/json; charset=utf-8

{"error":{"codi":"metode_no_permes","missatge":"DELETE no està permès sobre /v1/cafes.","detalls":[]}}

router.all() intercepta qualsevol mètode sobre aquella ruta, i s'ha de declarar després de les rutes específiques: si fos abans, es menjaria també els GET legítims. És la mateixa regla d'ordre de 03-02 aplicada al final de la llista.

  1. L'ordre definitiu de src/app.js

// src/app.js  (versió final del mòdul)
import express from 'express';
import { rutesV1 } from './rutes/index.js';
import { assignarTracaId } from './middleware/traca.js';
import { gestorNoTrobat } from './middleware/no-trobat.js';
import { gestorErrors } from './middleware/errors.js';

export const app = express();

app.disable('x-powered-by');

// --- 1. Identitat de la petició: el PRIMER, perquè tot la pugui fer servir ---
app.use(assignarTracaId);

// --- 2. Registre de peticions (04-07 el substituirà per logs estructurats) ---
app.use((req, res, next) => {
  const inici = Date.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - inici} ms)`);
  });
  next();
});

// --- 3. Analitzadors del cos ---
app.use(
  express.json({
    limit: '100kb',
    type: ['application/json', 'application/merge-patch+json'],
  })
);
app.use(express.urlencoded({ extended: false, limit: '10kb' }));

// --- 4. Salut (fora de /v1: no forma part del contracte) ---
app.get('/salut', (req, res) => {
  res.status(200).json({ estat: 'ok', versio: '1.0.0', moment: new Date().toISOString() });
});

// --- 5. API versionada ---
app.use('/v1', rutesV1);

// --- 6. Cap ruta no ha coincidit ---
app.use(gestorNoTrobat);

// --- 7. Middleware d'errors: SEMPRE L'ÚLTIM ---
app.use(gestorErrors);

Per què el middleware d'errors va l'últim, sense excepcions. Express recorre la cadena en ordre; quan algú crida next(error), busca cap endavant el següent middleware amb quatre arguments. Si el registressis abans de les rutes, un error llançat en una ruta no trobaria cap gestor al davant i cauria al gestor per defecte d'Express: una pàgina HTML amb la pila completa en desenvolupament. Una fallada d'ordre aquí filtra la traça del teu codi a Internet.

  1. Què no exposar mai en un error

No exposis Exemple Per què
Traces de pila at /home/aroma/src/serveis/comandes.js:42 Revela camins del sistema, estructura del projecte i el teu nom d'usuari
Consultes SQL SELECT * FROM clients WHERE email = ... Mapa de l'esquema servit a l'atacant
Noms de taula o columna UNIQUE constraint failed: clients.email Ídem
Versions Express 4.18.2, SQLite 3.45 Permet buscar vulnerabilitats conegudes d'aquella versió exacta
Missatges interns de llibreries ECONNREFUSED 10.0.3.14:5432 Revela topologia de xarxa i IPs internes
Existència de recursos aliens 403 en comptes de 404 Permet enumerar (03-06)
Distingir usuari de contrasenya "Aquell correu no existeix" Permet enumerar comptes

La regla operativa és l'asimetria entre les dues audiències:

Resposta al consumidor Log per a l'equip
Audiència Qualsevol, inclòs un atacant Persones amb accés al sistema
Contingut Codi, missatge, detalls, tracaId Tot: pila, SQL, usuari, capçaleres
Objectiu Que sàpiga què fer Que l'equip ho pugui arreglar

El tracaId és el que fa possible que les dues vistes siguin tan diferents sense perdre'n la connexió.

I un avís sobre el camp depuracio que afegim en desenvolupament: està condicionat a entorn.nodeEnv !== 'produccio'. Que aquella condició estigui ben escrita és crític; si NODE_ENV no està definit en producció, es filtrarien els missatges interns. És un argument més per a la validació de configuració en arrencar que vam muntar a 03-01.

  1. Errors no capturats del procés i apagada ordenada

El middleware només veu el que passa dins d'una petició. Una fallada en un setTimeout, en un gestor d'esdeveniments o en una promesa solta escapa d'Express i arriba al procés:

// src/servidor.js  (versió final del mòdul)
import { app } from './app.js';
import { entorn } from './config/entorn.js';
import { tancarBaseDades } from './config/base-dades.js';

const servidor = app.listen(entorn.port, () => {
  console.log(`API de la Botiga Aroma escoltant a ${entorn.baseUrl}/v1`);
});

let tancant = false;

function tancarOrdenadament(motiu, codiSortida = 0) {
  if (tancant) return;   // dos senyals seguits no han de duplicar el tancament
  tancant = true;

  console.log(`Tancant per: ${motiu}`);

  servidor.close(() => {
    tancarBaseDades();
    console.log('Servidor i base de dades tancats.');
    process.exit(codiSortida);
  });

  // Xarxa de seguretat: si en 10 s no ha acabat, es força la sortida.
  // Sense això, una connexió oberta pot impedir l'apagada per sempre.
  setTimeout(() => {
    console.error("Tancament forçat després del temps d'espera.");
    process.exit(1);
  }, 10000).unref();
}

process.on('SIGINT', () => tancarOrdenadament('SIGINT'));
process.on('SIGTERM', () => tancarOrdenadament('SIGTERM'));

/**
 * Excepció no capturada: el procés està en un estat DESCONEGUT.
 * Es registra i se surt. Intentar continuar és pitjor: pot haver-hi
 * transaccions a mitges, fitxers oberts i estat corrupte.
 */
process.on('uncaughtException', (error) => {
  console.error(
    JSON.stringify({ nivell: 'fatal', tipus: 'uncaughtException', missatge: error.message, pila: error.stack })
  );
  tancarOrdenadament('uncaughtException', 1);
});

/** Promesa rebutjada sense catch. Des de Node 15, també acaba el procés. */
process.on('unhandledRejection', (rao) => {
  console.error(
    JSON.stringify({ nivell: 'fatal', tipus: 'unhandledRejection', missatge: String(rao) })
  );
  tancarOrdenadament('unhandledRejection', 1);
});

Per què sortir en comptes de continuar. És contraintuïtiu: sembla més robust "aguantar". No ho és. Una excepció no capturada significa que el codi ha arribat a un punt que ningú no va preveure, i a partir d'aquí no es pot raonar sobre l'estat del procés: pot haver-hi una transacció sense tancar, un fitxer bloquejat o una variable corrupta que produeixi respostes incorrectes —pitjor que cap resposta—. El correcte és registrar-ho tot, tancar ordenadament i deixar que el supervisor aixequi un procés net. Aquell supervisor (systemd, Docker amb restart: always, Kubernetes) és part del desplegament i es tracta a 05-05.

I per això l'apagada ordenada importa tant: entre SIGTERM i la mort del procés, servidor.close() deixa d'acceptar connexions noves però acaba les que són en curs. Sense això, cada desplegament tallaria a mitja resposta els clients que estiguessin esperant.

  1. Taula de referència: situació → excepció → resposta

Situació Es llança HTTP codi Capçalera extra
Cos amb camps invàlids errors.dadesInvalides(detalls) 400 dades_invalides
Paràmetre de consulta desconegut o fora de rang errors.parametreInvalid(detalls) 400 parametre_invalid
JSON mal format SyntaxError de body-parser 400 dades_invalides
Falta Authorization errors.noAutenticat() 401 no_autenticat WWW-Authenticate
Token caducat errors.tokenCaducat() 401 token_caducat WWW-Authenticate
Rol insuficient errors.permisDenegat([...]) 403 permisos_insuficients
Cafè inexistent errors.noTrobat('cafe', id) 404 cafe_no_trobat
Comanda aliena errors.noTrobat('comanda', id) 404 comanda_no_trobada
URI inexistent errors.rutaNoTrobada(...) 404 ruta_no_trobada
Mètode no admès en aquella URI errors.metodeNoPermes(...) 405 metode_no_permes Allow
Sense estoc errors.conflicte('estoc_insuficient', ...) 409 estoc_insuficient
Segon pagament errors.conflicte('comanda_ja_pagada', ...) 409 comanda_ja_pagada
Versió desfasada errors.conflicte('conflicte_versio', ...) 409 conflicte_versio
Cos de 2 MB entity.too.large de body-parser 413 cos_massa_gran
PATCH amb JSON Patch errors.formatNoSuportat(tipus) 415 format_no_suportat Accept-Patch
Bug del programa TypeError i similars 500 error_intern — (i tracaId al cos)
Base de dades ocupada SQLITE_BUSY 503 servei_no_disponible Retry-After

Aquesta taula és el resum executable del catàleg de 02-04 i la referència que es consulta en afegir un endpoint nou. La columna de la dreta és la que més s'oblida.

  1. problem+json i per què mantenim el format propi

A 02-04 vam comparar el nostre format amb l'estàndard RFC 9457 (application/problem+json), que es veuria així:

{
  "type": "https://api.botigaaroma.example/errors/estoc-insuficient",
  "title": "Estoc insuficient",
  "status": 409,
  "detail": "Només queden 3 unitats d'‘Etiòpia Yirgacheffe’ i se'n demanen 5.",
  "instance": "/v1/comandes"
}
problem+json Format de la Botiga Aroma
Estàndard , RFC 9457 No
Identificador de màquina URI a type codi en snake_case
Llista de fallades de validació Extensió pròpia detalls de sèrie
Content-Type application/problem+json application/json
Suport als clients Creixent Requereix llegir la documentació

La Botiga Aroma manté el seu format, i les raons continuen sent les de 02-04: codi en snake_case és més còmode per a un switch que comparar URIs llargues; detalls com a camp de primera classe encaixa amb la decisió de retornar totes les fallades de validació alhora; i fer servir application/json evita que els clients hagin de negociar un tipus diferent. Ara s'hi afegeix un argument d'implementació: canviar de format seria trivial —es toca únicament src/middleware/errors.js—, i aquesta és precisament la prova que centralitzar va valer la pena. Si demà un soci exigeix problem+json, es pot fins i tot emetre segons l'Accept, amb Vary: Accept, sense tocar ni un servei.

El que no és opcional, facis servir el format que facis servir: codi HTTP correcte, un identificador estable per a màquines, un missatge útil per a persones, i mai filtrar l'interior del sistema.

Errors Comuns i Consells

1. Declarar el middleware d'errors amb tres arguments. Express el tracta com un middleware normal i mai no s'executa. Els quatre paràmetres són obligatoris, encara que next no es faci servir.

2. Registrar-lo abans de les rutes. No captura res i els errors cauen al gestor per defecte d'Express, que en desenvolupament retorna la pila en HTML.

3. Oblidar asincron() en un controlador async. La petició es queda penjada sense resposta ni error visible. Embolcalla tots els gestors.

4. Fer servir throw dins d'un setTimeout o d'un callback. No el captura ni Express ni l'embolcall: acaba a uncaughtException. Dins de callbacks, propaga amb next(error).

5. Retornar err.message sense filtrar. Publica camins, SQL, IPs i versions. Només els ErrorApi porten missatge propi; la resta, missatge genèric.

6. Respondre i a més cridar next(). ERR_HTTP_HEADERS_SENT. Un camí o l'altre, mai tots dos.

7. Empassar-se errors desconeguts. Un catch que retorna null converteix una fallada greu en dades absents. El que no sàpigues traduir, torna'l a llançar.

8. Posar tracaId als 4xx. El contracte el reserva per als 5xx, que són els únics que requereixen investigació per part nostra.

9. No tancar el procés després d'un uncaughtException. L'estat és desconegut; continuar servint peticions pot produir respostes incorrectes, cosa que és pitjor que no respondre.

Consell: provoca un error a propòsit de tant en tant —un throw new Error('prova') temporal en un controlador— i comprova que la resposta és un 500 net amb tracaId i que el log conté la pila completa. És l'única manera de saber que el camí d'error funciona; ningú no el prova fins que el necessita.

Exercicis

Exercici 1

Implementa el middleware exigirClauIdempotencia que el contracte de 02-03 exigeix a POST /v1/comandes i POST /v1/comandes/{id}/pagament: si falta la capçalera Idempotency-Key, ha de produir 400 clau_idempotencia_requerida; si la clau ja es va fer servir amb un cos diferent, 422 clau_idempotencia_reutilitzada. Fes servir ErrorApi i explica per què el segon cas és 422 i no 409.

Exercici 2

Un company escriu aquest controlador i en producció apareixen peticions que no reben mai resposta. Diagnostica'n els tres problemes i escriu la versió correcta.

rutesComandes.post('/', autenticar, async (req, res) => {
  try {
    const comanda = await serveiComandes.crear(req.body);
    res.status(201).json(comandaARepresentacio(comanda));
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

Exercici 3

Dissenya la resposta completa —codi, capçaleres i cos— per a aquestes quatre situacions, indicant quina fàbrica d'errors la produeix i què es registra al log:

  1. PATCH /v1/cafes/caf_001 amb Content-Type: application/json-patch+json.
  2. GET /v1/comandes/com_5001 amb el token d'un client que no n'és el propietari.
  3. POST /v1/cafes amb un token vàlid de rol client.
  4. Un TypeError dins de cafeARepresentacio perquè un cafè sembrat té notes_tast a NULL.

Solucions

Solució 1

// src/middleware/idempotencia.js
import { createHash } from 'node:crypto';
import { ErrorApi, errors } from '../errors/error-api.js';
import { baseDades } from '../config/base-dades.js';

// Taula necessària (migració 003):
//   CREATE TABLE claus_idempotencia (
//     clau TEXT PRIMARY KEY, empremta TEXT NOT NULL,
//     resposta TEXT, data TEXT NOT NULL
//   );

const buscarClau = baseDades.prepare('SELECT * FROM claus_idempotencia WHERE clau = ?');
const desarClau = baseDades.prepare(
  'INSERT INTO claus_idempotencia (clau, empremta, data) VALUES (?, ?, ?)'
);

export function exigirClauIdempotencia(req, res, next) {
  const clau = req.get('Idempotency-Key');

  if (!clau) {
    return next(
      new ErrorApi(
        400,
        'clau_idempotencia_requerida',
        'Aquesta operació requereix la capçalera Idempotency-Key.'
      )
    );
  }

  // Empremta del cos: identifica "la mateixa petició".
  const empremta = createHash('sha256').update(JSON.stringify(req.body ?? {})).digest('hex');
  const registre = buscarClau.get(clau);

  if (registre) {
    if (registre.empremta !== empremta) {
      // Mateixa clau, cos diferent: contradicció del client.
      return next(
        new ErrorApi(
          422,
          'clau_idempotencia_reutilitzada',
          "Aquesta Idempotency-Key ja es va fer servir amb un cos diferent."
        )
      );
    }
    if (registre.resposta) {
      // Reintent legítim: es retorna la resposta original.
      return res.status(200).json(JSON.parse(registre.resposta));
    }
    return next(
      new ErrorApi(409, 'operacio_en_curs', "Una petició idèntica s'està processant.")
    );
  }

  desarClau.run(clau, empremta, new Date().toISOString());
  req.clauIdempotencia = clau;
  next();
}
rutesComandes.post(
  '/',
  autenticar,
  exigirRol('client', 'empleat', 'administrador'),
  exigirClauIdempotencia,
  validar(esquemaCrearComanda),
  asincron(controladorComandes.crear)
);

Per què 422 i no 409: tots dos codis indiquen que la petició no es pot processar, però assenyalen coses diferents. El 409 Conflict diu que la petició era correcta i xoca amb l'estat actual del recurs: no hi ha estoc, la comanda ja estava pagada. El 422 Unprocessable Content diu que la petició està sintàcticament ben formada però és semànticament contradictòria en si mateixa: el client afirma amb la clau "aquesta és la mateixa petició que abans" i alhora envia un cos diferent. No hi ha cap recurs en conflicte; la contradicció és dins de la petició mateixa. Per això el contracte de 02-04 va reservar el 422 exclusivament per a aquest cas, i tota la resta va per 400 o 409.

Solució 2

# Problema Conseqüència
1 Tot error es converteix en 500 Un estoc insuficient, que és 409 estoc_insuficient, es reporta com a fallada del servidor. El client ho reintenta creient que és temporal, la monitorització s'omple de falsos 5xx i l'ErrorApi que el servei va llançar amb cura es perd
2 Es retorna error.message en cru Fuita d'informació: pot contenir camins del sistema o missatges de SQLite. A més el format és {error: "text"}, no {error: {codi, missatge, detalls}}, així que trenca el contracte
3 Sense asincron() i amb un catch que pot fallar Si comandaARepresentacio llança després de l'await, l'error passa dins del try i entra al catch… però si el mateix res.json ja ha enviat capçaleres, el catch intentarà respondre un altre cop i llançarà ERR_HTTP_HEADERS_SENT fora de qualsevol captura. Aquella segona excepció no la veu ningú: promesa rebutjada, petició penjada

Versió correcta:

// src/controladors/comandes.js
crear: async (req, res) => {
  const comanda = await serveiComandes.crear({
    clientId: req.usuari.id,        // del token, MAI del cos (03-06)
    linies: req.body.linies,
  });
  res.set('Location', `/v1/comandes/${comanda.id}`);
  res.status(201).json(comandaARepresentacio(comanda));
},
// src/rutes/comandes.js
rutesComandes.post(
  '/',
  autenticar,
  exigirClauIdempotencia,
  validar(esquemaCrearComanda),
  asincron(controladorComandes.crear)
);

Sense try/catch. El controlador s'ocupa del camí feliç; asincron() encamina qualsevol rebuig cap a gestorErrors, que ja sap distingir un ErrorApi d'un bug, posar el codi correcte, no filtrar res i registrar el que correspongui. Un try/catch en un controlador només es justifica quan cal traduir un error a un altre de més específic —com fa serveiComandes.crear amb els errors del repositori—, mai per "que no es trenqui".

Solució 3

1. PATCH amb JSON Patch

HTTP/1.1 415 Unsupported Media Type
Accept-Patch: application/merge-patch+json
Aroma-Traca-Id: trz_1a2b3c4d

{"error":{"codi":"format_no_suportat","missatge":"El tipus de contingut 'application/json-patch+json' no s'admet en aquesta operació.","detalls":[]}}

Fàbrica: errors.formatNoSuportat(tipus), llançada des del controlador de PATCH. La capçalera Accept-Patch l'afegeix el middleware d'errors en veure codi === 'format_no_suportat' en un PATCH. Log: avís d'una línia, és un error del client.

2. Comanda aliena

HTTP/1.1 404 Not Found
Aroma-Traca-Id: trz_5e6f7a8b

{"error":{"codi":"comanda_no_trobada","missatge":"No existeix cap comanda amb l'identificador 'com_5001'.","detalls":[]}}

Fàbrica: errors.noTrobat('comanda', id) des de serveiComandes.obtenirPer. 404 i no 403, i amb exactament el mateix cos que si la comanda no existís, per no confirmar-ne l'existència (03-06). Log: avís; convé registrar usuari i l'id demanat, perquè una ratxa d'aquests és un indici d'enumeració i a 04-07 serà una alerta.

3. POST /v1/cafes amb rol client

HTTP/1.1 403 Forbidden
Aroma-Traca-Id: trz_9c0d1e2f

{"error":{"codi":"permisos_insuficients","missatge":"No tens permís. Es requereix un d'aquests rols: empleat, administrador.","detalls":[]}}

Fàbrica: errors.permisDenegat(['empleat', 'administrador']) des d'exigirRol. 403 i no 404, perquè /v1/cafes és públic i la seva existència no és cap secret: aquí ser explícit ajuda l'integrador sense filtrar res. Log: avís.

4. TypeError al mapejador

HTTP/1.1 500 Internal Server Error
Aroma-Traca-Id: trz_3a4b5c6d

{"error":{"codi":"error_intern","missatge":"S'ha produït un error inesperat.","detalls":[],"tracaId":"trz_3a4b5c6d"}}

Fàbrica: cap; és el cas per defecte de traduir(), que retorna errors.errorIntern(). El client no veu el TypeError, ni el nom de la funció, ni la línia. Al log queda tot:

{"nivell":"error","tracaId":"trz_3a4b5c6d","metode":"GET","ruta":"/v1/cafes/caf_007","usuari":null,
 "missatge":"Cannot read properties of null (reading 'map')",
 "pila":"TypeError: ... at cafeARepresentacio (/app/src/serveis/mapejadors.js:31:29) ..."}

I el diagnòstic de fons: la causa real és que la columna notes_tast va admetre NULL malgrat estar declarada NOT NULL DEFAULT '[]', probablement per una inserció manual. La correcció duradora no és un ?? [] al mapejador —això és tapar el símptoma—, sinó assegurar la dada al repositori i comprovar per què es va saltar la restricció. El tracaId és el que permet arribar des de la queixa de l'usuari fins a aquella conclusió.

Conclusió

Ja no hi ha respostes d'error escrites a mà repartides pel projecte. Els serveis llancen ErrorApi dient què ha passat amb un codi del catàleg de 02-04, sense esmentar HTTP ni tocar res; les fàbriques garanteixen que estat i codi lliguen sempre; i un únic middleware de quatre arguments, registrat l'últim, tradueix tot el que hi arriba —ErrorApi, ZodError, errors de SQLite, JSON mal format, cossos massa grans i bugs inesperats— al mateix format {"error": {"codi", "missatge", "detalls"}}, amb tracaId només als 5xx, WWW-Authenticate als 401, Allow als 405 i Accept-Patch als 415. L'embolcall asincron() tanca per fi el forat de les promeses rebutjades que arrossegàvem des de 03-03, i el procés sap morir bé: tancament ordenat davant de SIGTERM, registre complet i sortida davant d'uncaughtException.

El més valuós d'aquesta lliçó no és el codi, sinó l'asimetria que estableix: al consumidor se li dona un codi estable, un missatge útil i un identificador amb què reclamar; a l'equip se li dona la pila completa, la consulta, l'usuari i la ruta. Mai a l'inrevés. I com que tot passa per un sol fitxer, demà es pot afegir un camp a tots els errors, emetre problem+json segons l'Accept o enviar els 5xx a un sistema d'alertes canviant un sol lloc.

Amb això, l'API de la Botiga Aroma està completa: rutes, representacions, validació, persistència, autenticació i errors. Falta l'única cosa que converteix "funciona a la meva màquina" en "compleix el contracte": comprovar-ho. A 03-08, Proves i validació, tanquem el mòdul amb la piràmide de proves aplicada a aquesta API: proves unitàries dels serveis i del mapejador de cèntims a euros fent servir el repositori en memòria com a doble —possible gràcies a la separació per capes de 03-03—, proves d'integració amb Supertest sobre l'objecte app sense obrir port —possible gràcies a la separació de 03-02—, verificació de codis, capçaleres Location, Link i Allow i forma exacta del cos, generació de tokens vàlids a les mateixes proves, aïllament amb una base SQLite temporal, cobertura amb el runner natiu, i una llista de verificació del contracte abans de donar l'API per bona.

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats