A lo largo del módulo han ido quedando cabos sueltos, todos del mismo tipo. cargarEvento propaga EVENTO_NO_ENCONTRADO con siguiente(error). El dominio lanza AFORO_INSUFICIENTE cuando la Sala Bóveda ya no tiene entradas. express.json() rechaza un cuerpo malformado. El middleware validar de 06-06 llama a siguiente(new ErrorDeValidacion(...)), una clase que todavía no existe. Y el middleware 404 de 06-03 propaga RUTA_NO_ENCONTRADA. Nadie recoge nada de eso. En esta lección construimos el destino común: un manejador central que convierte cualquier fallo en una respuesta coherente, y una política clara sobre qué se puede convertir en una respuesta amable y qué no. Es la última pieza del módulo, y la que le da sentido a la tabla ESTADO_POR_CODIGO del módulo 4.

Contenido

  1. El middleware de error y su firma de cuatro argumentos
  2. El manejador por defecto de Express
  3. Errores síncronos, asíncronos y la gran mejora de Express 5
  4. Una jerarquía de errores propia
  5. ESTADO_POR_CODIGO encuentra su sitio
  6. Errores operativos frente a errores de programación
  7. El manejador central completo
  8. El middleware 404
  9. Errores fuera de Express
  10. Lista de comprobación del módulo

  1. El middleware de error y su firma de cuatro argumentos

Un middleware de error es idéntico a los demás salvo por una cosa: tiene cuatro parámetros.

// El primer parametro es el error que llego por siguiente(error) o por un rechazo.
function manejadorDeErrores(error, peticion, respuesta, siguiente) {
  respuesta.status(500).json({ error: { codigo: 'ERROR_INTERNO', estado: 500 } });
}
aplicacion.use(manejadorDeErrores); // registrado el ULTIMO

// MAL: solo tres parametros. Express lo trata como middleware NORMAL,
// asi que nunca recibe errores y ademas se ejecuta en peticiones sanas.
aplicacion.use((error, peticion, respuesta) => respuesta.status(500).json({ error: 'fallo' }));

Express distingue los dos tipos inspeccionando fn.length, la aridad de la función: si vale 4, es manejador de errores; si no, es middleware normal. Los síntomas de la versión mala son desconcertantes: los errores siguen mostrándose con el formato por defecto de Express, y en las peticiones correctas aparece un 500 inexplicable, porque tu función recibe (req, res, next) y trata req como si fuera un error. Si no usas siguiente, decláralo igualmente: configura ESLint para permitir argumentos no usados al final, o llámalo _siguiente, pero no lo quites.

Por qué va el último

Un manejador de errores solo captura lo propagado desde middleware y rutas registrados por encima de él. Registrarlo en medio significa que todo lo declarado después escapa a su control: si escribes aplicacion.use(manejadorDeErrores) y solo después aplicacion.use('/api', crearRutasApi()), los errores de la API no llegan a él. Es el mismo principio del orden de 06-04, aplicado al final de la cadena.

Se pueden encadenar varios

Un manejador de errores puede llamar a siguiente(error) para pasarlo al siguiente, igual que en la cadena normal, lo que sirve para separar responsabilidades: aplicacion.use(registrarError) registra y propaga, aplicacion.use(errorDeValidacion) trata solo un tipo o propaga, y aplicacion.use(manejadorFinal) responde siempre. Lo que no debe ocurrir nunca es que el último no responda.

  1. El manejador por defecto de Express

Si no registras ninguno, Express tiene el suyo, y hace tres cosas sensatas: usa error.status o error.statusCode si existen (si no, responde 500); fija las cabeceras que traiga error.headers; y envía el cuerpo del error, con una diferencia crítica según el entorno:

NODE_ENV Qué envía en el cuerpo
development (o sin definir) El mensaje y la traza completa en HTML
production Solo el texto del estado: Internal Server Error

Ese comportamiento es correcto y merece la pena entender por qué. Una traza de pila revela rutas absolutas del servidor (/home/despliegue/escena-viva/src/...), nombres de módulos internos, versiones de bibliotecas y a veces fragmentos de datos: un mapa gratis para quien esté buscando por dónde entrar. En desarrollo la quieres ver entera; en producción no debe salir del servidor jamás. Nuestro manejador propio mantiene exactamente esa política, pero con nuestro formato JSON en lugar de HTML. Un detalle a menudo ignorado: Express asume NODE_ENV=production para esto, y en Escena Viva usamos produccion en español, así que hay que ser explícitos y no depender de la comparación interna del framework —por eso nuestro manejador consulta configuracion.esProduccion.

  1. Errores síncronos, asíncronos y la gran mejora de Express 5

Los errores síncronos Express siempre los ha capturado: un throw dentro de un manejador síncrono va al manejador de errores, sin más.

Errores asíncronos: el gran cambio

// Express 5: esto FUNCIONA. El rechazo llega al manejador de errores.
aplicacion.get('/api/eventos/:id', async (peticion, respuesta) => {
  const evento = await obtenerEventoPorId(peticion.params.id); // puede rechazar
  respuesta.json(evento.toJSON());
});
// Express 4: el envoltorio que veias en TODOS los proyectos, porque sin el
// la peticion se quedaba colgada para siempre.
const asyncHandler = (fn) => (p, r, s) => Promise.resolve(fn(p, r, s)).catch(s);
aplicacion.get('/api/eventos/:id', asyncHandler(manejadorAsincrono));

En Express 4, el framework llamaba al manejador, este devolvía una promesa que nadie observaba, y el rechazo se convertía en un unhandledRejection sin respuesta al cliente: ni error visible, ni 500, ni nada, solo el cliente esperando. Si te encuentras asyncHandler, express-async-handler, catchAsync o wrapAsync en un proyecto, ya sabes qué son: restos de Express 4. En Express 5 sobran, y quitarlos elimina una capa de ruido de cada ruta.

Express 4 Express 5
throw síncrono Capturado Capturado
Promesa rechazada en manejador, middleware o router.param async Petición colgada Capturada
Error dentro de setTimeout / callback No capturado No capturado (sigue siendo tuyo)

Esa última fila es la excepción que no desaparece. Express solo puede observar la promesa que le devuelves:

// MAL: nadie captura esto. Tumba el proceso entero.
aplicacion.get('/mal', (p, r) => setTimeout(() => { throw new Error('invisible'); }, 100));
// BIEN: promisificado con el util dormir.js del modulo 2.
aplicacion.get('/bien', async () => {
  await dormir(100);
  throw new Error('este si llega al manejador de errores');
});

La regla: si un error puede ocurrir dentro de una devolución de llamada, promisifica esa operación. Es una razón más para preferir fs.promises y las utilidades de los módulos 2 y 3.

  1. Una jerarquía de errores propia

Hasta ahora creabas errores con Object.assign(new Error(...), { codigo }). Funciona, pero no permite distinguir tipos con instanceof, no obliga a nada y se escribe distinto en cada sitio. Vamos a formalizarlo.

// src/errores.js — todo lo que herede del error base se considera
// OPERATIVO: esperable y traducible a una respuesta HTTP amable.
class ErrorDeAplicacion extends Error {
  constructor(mensaje, { codigo, estado, detalles, causa } = {}) {
    super(mensaje, { cause: causa });
    this.name = new.target.name;
    this.codigo = codigo ?? 'ERROR_INTERNO';
    this.estado = estado; // opcional: si falta, lo deduce ESTADO_POR_CODIGO
    this.detalles = detalles;
    this.esOperativo = true; // distingue lo esperable de un bug
    Error.captureStackTrace(this, new.target); // traza limpia
  }
}
/** 400: los datos de entrada no tienen la forma esperada. */
class ErrorDeValidacion extends ErrorDeAplicacion {
  constructor(mensaje, detalles = []) {
    super(mensaje, { codigo: 'DATOS_INVALIDOS', estado: 400, detalles });
  }
}
const CODIGOS_NO_ENCONTRADO = {
  evento: 'EVENTO_NO_ENCONTRADO', sesion: 'SESION_NO_ENCONTRADA',
  pedido: 'PEDIDO_NO_ENCONTRADO', ruta: 'RUTA_NO_ENCONTRADA',
};
/** 404: el recurso solicitado ('evento', 'sesion', 'pedido', 'ruta') no existe. */
class RecursoNoEncontrado extends ErrorDeAplicacion {
  constructor(tipo, identificador) {
    const codigo = CODIGOS_NO_ENCONTRADO[tipo] ?? 'RECURSO_NO_ENCONTRADO';
    super(`No se ha encontrado ${tipo} ${identificador}`, { codigo, estado: 404 });
    this.tipo = tipo;
    this.identificador = identificador;
  }
}
/** 409: la operacion no es valida en el estado actual del recurso. */
class ConflictoDeEstado extends ErrorDeAplicacion {
  constructor(mensaje, codigo = 'ESTADO_INVALIDO', detalles) {
    super(mensaje, { codigo, estado: 409, detalles });
  }
}
module.exports = { ErrorDeAplicacion, ErrorDeValidacion, RecursoNoEncontrado, ConflictoDeEstado };

Decisiones que merecen explicación:

  • this.name = new.target.name: cada subclase se identifica con su propio nombre en los registros, sin repetirlo a mano; y { cause } preserva el error original al envolverlo, con la cadena completa para el registro y sin filtrarla al cliente.
  • this.estado opcional deja que el dominio lance errores con solo un código, sin saber nada de HTTP; Error.captureStackTrace elimina el constructor de la traza, que empieza donde de verdad ocurrió el fallo; y esOperativo = true es la marca central del apartado 6.

Uso en el dominio, que sigue sin conocer HTTP:

// src/dominio/sesion.js — solo el codigo; el 409 lo pone el manejador central.
vender(cantidad) {
  if (this.libres < cantidad) {
    throw new ConflictoDeEstado(
      `La sesion ${this.id} solo tiene ${this.libres} entradas libres`,
      'AFORO_INSUFICIENTE',
      [{ sesionId: this.id, solicitadas: cantidad, libres: this.libres }]
    );
  }
  return (this.vendidas += cantidad);
}

  1. ESTADO_POR_CODIGO encuentra su sitio

En el módulo 4 escribiste src/servidor/errores-http.js con estadoParaError, cuerpoParaError y la tabla ESTADO_POR_CODIGO, que vivía repartida por los manejadores de respuestas.js. Ahora tiene un único cliente: el manejador central.

// src/servidor/errores-http.js — el mismo del modulo 4, ampliado.
const ESTADO_POR_CODIGO = Object.freeze({
  CANTIDAD_INVALIDA: 400, PARAMETRO_INVALIDO: 400, // peticion mal formada
  JSON_INVALIDO: 400, DATOS_INVALIDOS: 400,
  RUTA_NO_PERMITIDA: 403, // prohibido
  EVENTO_NO_ENCONTRADO: 404, SESION_NO_ENCONTRADA: 404, // no existe
  PEDIDO_NO_ENCONTRADO: 404, RUTA_NO_ENCONTRADA: 404,
  METODO_NO_PERMITIDO: 405,
  AFORO_INSUFICIENTE: 409, ESTADO_INVALIDO: 409, // conflicto con el estado actual
  CUERPO_DEMASIADO_GRANDE: 413, TIPO_NO_ACEPTADO: 415, LIMITE_POR_PEDIDO: 422,
  DEMASIADAS_PETICIONES: 429, SERVICIO_EXTERNO_CAIDO: 503,
});

/** Traduce un error a estado HTTP. Desconocido -> 500. */
function estadoParaError(error) {
  if (Number.isInteger(error?.estado)) return error.estado; // 1. nuestra jerarquia
  const porCodigo = ESTADO_POR_CODIGO[error?.codigo]; // 2. codigo de dominio
  if (porCodigo) return porCodigo;
  // 3. Errores de terceros que ya traen estado (express.json, cors, http-errors).
  const deTercero = error?.status ?? error?.statusCode;
  if (Number.isInteger(deTercero) && deTercero >= 400 && deTercero <= 599) return deTercero;
  return 500; // 4. desconocido
}
module.exports = { ESTADO_POR_CODIGO, estadoParaError };

La cascada de cuatro pasos hace que todo encaje en el mismo sitio: nuestra jerarquía, los códigos que el dominio lanza sin saber de HTTP y los errores de terceros. Para estos últimos conviene además normalizar el código, para que el cliente vea siempre el vocabulario de Escena Viva:

// src/middleware/errores.js (fragmento). POR_TIPO son errores de body-parser.
const POR_TIPO = {
  'entity.too.large': 'CUERPO_DEMASIADO_GRANDE',
  'entity.parse.failed': 'JSON_INVALIDO',
  'unsupported.media.type': 'TIPO_NO_ACEPTADO',
};
const POR_ESTADO = { 404: 'RUTA_NO_ENCONTRADA', 405: 'METODO_NO_PERMITIDO', 429: 'DEMASIADAS_PETICIONES' };
/** Traduce errores conocidos de terceros a nuestro vocabulario. */
function codigoParaError(error) {
  const porEstado = POR_ESTADO[error?.status ?? error?.statusCode];
  return error?.codigo ?? POR_TIPO[error?.type] ?? porEstado ?? 'ERROR_INTERNO';
}

  1. Errores operativos frente a errores de programación

Esta distinción es la que decide qué se cuenta al cliente y qué no:

Operativo De programación (bug)
Qué es Situación esperada del mundo real Defecto en tu código
Ejemplos Aforo insuficiente, evento inexistente, JSON malformado, servicio externo caído undefined is not a function, TypeError, variable mal escrita
¿Lo previste? ¿Se arregla desplegando? Sí, está en el diseño; no se arregla No; sí
Respuesta al cliente y registro Mensaje específico y útil; una línea informativa 500 genérico, sin detalles; traza completa, cuerpo, contexto y alerta
¿Puede continuar el proceso? Sí, con normalidad Depende: puede estar en estado inconsistente

Cómo se distinguen en el código:

function esOperativo(error) {
  // 1. Nuestra jerarquia lo marca explicitamente.
  if (error instanceof ErrorDeAplicacion) return true;
  // 2. Codigo de dominio conocido en la tabla.
  if (error?.codigo && ESTADO_POR_CODIGO[error.codigo]) return true;
  // 3. Errores de terceros con estado 4xx: peticion mala, no bug nuestro.
  const estado = error?.status ?? error?.statusCode;
  if (Number.isInteger(estado) && estado >= 400 && estado < 500) return true;
  return false; // todo lo demas es sospechoso de bug
}

Por qué el bug devuelve un 500 mudo. Si un TypeError se filtra al cliente con su mensaje, publicas el nombre de tus variables internas, la línea del fichero y, con la traza, medio árbol de directorios. Además, ese mensaje no le sirve de nada a quien llama: no puede corregir su petición porque el problema no está en ella. Un 500 con el identificador de la petición es más útil para todos: el cliente sabe que el fallo es tuyo y tiene una referencia con la que reclamar, y tú tienes la traza completa en tus registros.

  1. El manejador central completo

// src/middleware/errores.js — codigoParaError y esOperativo: apartados 5 y 6.
// Requiere configuracion (config/index.js), ErrorDeAplicacion (errores.js) y
// ESTADO_POR_CODIGO + estadoParaError (servidor/errores-http.js).
const MENSAJE_GENERICO =
  'Se ha producido un error interno. Indica el identificador de peticion al soporte.';
// Manejador central; se registra el ULTIMO en crearAplicacion().
function manejadorDeErrores(error, peticion, respuesta, siguiente) {
  // 1. Con la respuesta ya empezada (un stream que fallo a mitad) no se pueden
  //    cambiar cabeceras: se delega en Express, que cierra la conexion.
  if (respuesta.headersSent) {
    siguiente(error);
    return;
  }
  const estado = estadoParaError(error);
  const operativo = esOperativo(error);
  const idPeticion = peticion.idPeticion ?? '-';
  const donde = `${idPeticion} ${peticion.method} ${peticion.originalUrl}`;
  // 2. REGISTRO por stderr, completo. Un bug se registra entero: traza y causa.
  if (operativo) {
    console.error(`[error] ${donde} ${estado} ${codigoParaError(error)} ${error.message}`);
  } else {
    console.error(`[BUG] ${donde} ${estado} ${error?.name}`);
    console.error(error?.stack ?? error);
    if (error?.cause) console.error('Causa:', error.cause);
  }
  // 3. RESPUESTA. Solo lo operativo se explica; el bug queda mudo.
  const cuerpo = { error: operativo
    ? { codigo: codigoParaError(error), mensaje: error.message, estado, idPeticion }
    : { codigo: 'ERROR_INTERNO', mensaje: MENSAJE_GENERICO, estado: 500, idPeticion } };
  // Detalles: solo los de nuestra jerarquia (validacion, conflictos).
  if (operativo && error.detalles?.length > 0) cuerpo.error.detalles = error.detalles;
  // 4. La traza SOLO fuera de produccion, igual que hace Express por defecto.
  if (!configuracion.esProduccion && error?.stack) {
    cuerpo.error.traza = error.stack.split('\n').map((linea) => linea.trim());
  }
  // 5. Cabeceras que algunos errores necesitan.
  if (estado === 405 && error?.metodosPermitidos) {
    respuesta.set('Allow', error.metodosPermitidos.join(', '));
  }
  if (estado === 503) respuesta.set('Retry-After', '30');
  respuesta.status(cuerpo.error.estado).json(cuerpo);
}
module.exports = { manejadorDeErrores, codigoParaError, esOperativo };

Dos respuestas reales, una operativa y otra de bug:

{ "error": { "codigo": "AFORO_INSUFICIENTE",
    "mensaje": "La sesion ses-001-1 solo tiene 3 entradas libres", "estado": 409,
    "idPeticion": "9f2a1c48-3c7e-4a1b-9c62-1d0f8b4a77e1",
    "detalles": [{ "sesionId": "ses-001-1", "solicitadas": 6, "libres": 3 }] } }

{ "error": { "codigo": "ERROR_INTERNO", "estado": 500,
    "mensaje": "Se ha producido un error interno. Indica el identificador de peticion al soporte.",
    "idPeticion": "3b71e0a2-55d4-4a7c-8e19-6f0c2b9d1a44" } }

La segunda no dice absolutamente nada del fallo, pero en los registros del servidor está la traza completa asociada a ese mismo idPeticion que el cliente tiene delante. Esa correlación —el identificador de 06-04 apareciendo en la respuesta y en el registro— es lo que convierte un informe de incidencia inútil ("me dio error") en uno accionable ("me dio error, referencia 3b71e0a2").

  1. El middleware 404

// src/middleware/no-encontrado.js — se registra despues de TODAS las rutas y
// antes del manejador de errores. No responde: propaga.
function rutaNoEncontrada(peticion, respuesta, siguiente) {
  siguiente(new RecursoNoEncontrado('ruta', `${peticion.method} ${peticion.originalUrl}`));
}
module.exports = { rutaNoEncontrada };

Por qué va antes del manejador de errores y después de las rutas: es un middleware normal, no de error, y Express lo alcanza cuando ninguna ruta anterior ha respondido. Si lo pusieras antes de las rutas, respondería 404 a todo; si lo pusieras después del manejador de errores, nunca se ejecutaría. Y propaga en lugar de responder para que el 404 pase por el mismo punto que los demás errores y salga con codigo, estado, idPeticion y el mismo formato: curl -s localhost:3000/api/no-existe devuelve {"error":{"codigo":"RUTA_NO_ENCONTRADA","mensaje":"No se ha encontrado ruta GET /api/no-existe",...}}. Un cliente que sabe interpretar tus errores no debería necesitar un caso especial para el 404.

  1. Errores fuera de Express

Express solo ve lo que ocurre dentro de una petición; hay dos sucesos del proceso que se le escapan:

// src/servidor.js — promesa rechazada sin ningun .catch() ni try/catch: la
// convertimos en excepcion para que pase por el manejador de abajo.
process.on('unhandledRejection', (razon) => {
  console.error('[FATAL] unhandledRejection:', razon);
  throw razon instanceof Error ? razon : new Error(String(razon));
});
// Excepcion que no capturo nadie: el proceso esta en estado desconocido.
process.on('uncaughtException', (error) => {
  console.error('[FATAL] uncaughtException:', error?.stack ?? error);
  cerrarYTerminar(1); // politica: registrar y terminar ORDENADAMENTE
});
// cerrarYTerminar deja de aceptar peticiones nuevas (servidorActivo.close +
// closeIdleConnections), espera a las en curso y sale; con un
// setTimeout(...).unref() de 5 s como red de seguridad si algo se atasca.

Por qué NO se sigue como si nada

La tentación es evidente: capturar el error, registrarlo y dejar el servidor en pie para no perder el servicio. Es un error grave, y estas son las razones:

  1. El estado del proceso es desconocido. La excepción se propagó por una pila que no la esperaba: hay funciones a medias, con variables actualizadas parcialmente. En Escena Viva, una excepción en mitad de GestorDeVentas.registrar puede dejar las entradas descontadas del aforo pero el pedido sin crear.
  2. Se pierden recursos. Cada excepción no capturada puede dejar sin cerrar un descriptor de fichero, un socket o un temporizador: el proceso "sobrevive" degradándose hasta agotar la memoria.
  3. Oculta el bug. Un servidor que sigue funcionando mal no genera urgencia, y el fallo se acumula durante semanas. Además, el comportamiento por defecto de uncaughtException sin manejador es terminar, y así debe ser.

La política correcta, y la que aplicamos, es: registrar el error entero → dejar de aceptar peticiones nuevas → dar unos segundos a las peticiones en curso → terminar el proceso → que el supervisor lo reinicie. Quién reinicia es asunto del entorno, y en el módulo 11 lo veremos con nombres propios: PM2 en modo cluster, restart: always en Docker o el reinicio automático de un contenedor gestionado. La aplicación no se reinicia a sí misma: se muere con dignidad y deja que el supervisor haga su trabajo.

Suceso Qué hacer Qué NO hacer
siguiente(error) operativo Responder con 4xx Terminar el proceso
Bug dentro de una petición 500 mudo, traza registrada, proceso sigue Filtrar la traza al cliente
unhandledRejection y uncaughtException Registrar y terminar ordenadamente Ignorarlo o seguir sirviendo peticiones
SIGTERM Apagado ordenado, salida 0 Morir de golpe

  1. Lista de comprobación del módulo

Antes de dar Escena Viva por terminada, repasa punto por punto:

  • [ ] crearAplicacion() en src/app.js no llama a listen y devuelve la aplicación; src/servidor.js crea el servidor http, arranca y registra el apagado ordenado.
  • [ ] src/config/index.js es el único sitio que lee process.env, y valida al arrancar; x-powered-by desactivado y trust proxy acorde al despliegue real.
  • [ ] Routers en src/rutas/ y controladores en src/controladores/, ambos sin lógica de negocio; rutas ordenadas de específica a genérica, con el comodín el último.
  • [ ] Todos los caminos de cada middleware responden o llaman a siguiente, y el orden es el canónico de 06-05: id, seguridad, CORS, registro, compresión, límites, cuerpo, estáticos, rutas, 404, errores.
  • [ ] express.json() con limit; express.static con dotfiles: 'ignore'; helmet activo con la CSP ajustada al front-end en lugar de desactivada.
  • [ ] CORS con lista blanca desde configuración, nunca '*' con credentials; límite de peticiones en POST /api/pedidos.
  • [ ] Toda entrada validada con esquema en el borde (y en req.datosValidados, no reasignando req.query); el dominio conserva sus invariantes.
  • [ ] Jerarquía de errores en src/errores.js y manejador central el último; traza en la respuesta solo fuera de producción, e idPeticion en las respuestas de error y en los registros.
  • [ ] unhandledRejection y uncaughtException registran y terminan ordenadamente; npm audit limpio y dependencias revisadas con el criterio del módulo 5.

Errores Comunes y Consejos

  • Olvidar el cuarto parámetro. Sin siguiente, Express trata tu manejador como middleware normal: nunca recibe errores y rompe las peticiones sanas.
  • Registrarlo antes que las rutas. Solo captura lo declarado por encima.
  • Filtrar la traza en producción o tratar los bugs como errores operativos. Publicas rutas del sistema y versiones, y un TypeError detallado no ayuda al cliente pero a ti te delata; compruébalo con NODE_ENV=produccion antes de desplegar. Y no sigas tras un uncaughtException: el proceso queda en estado desconocido, así que registra y termina.
  • Responder cuando res.headersSent ya es true. Provoca Cannot set headers after they are sent; comprueba la bandera y delega en Express.
  • Perder la causa al envolver errores. Usa new Error(mensaje, { cause: original }); sin ella la traza empieza donde envolviste, no donde falló. Y mantén trivial el propio manejador: si hace JSON.stringify de algo con referencias circulares, fallará en el peor sitio posible.
  • Consejo: prueba tus errores. En el módulo 9, con supertest, verifica que un aforo insuficiente devuelve 409 con AFORO_INSUFICIENTE, que un :id inválido devuelve 400 y que un bug forzado devuelve un 500 sin traza en producción.

Ejercicios

Ejercicio 1: la jerarquía en acción

Implementa src/errores.js completo y haz que el dominio lo use. Comprueba con curl cuatro casos, todos con idPeticion: GET /api/eventos/evt-999 → 404 EVENTO_NO_ENCONTRADO; POST /api/pedidos con cantidad: 99 → 400 DATOS_INVALIDOS con detalles; POST /api/pedidos pidiendo más entradas de las libres → 409 AFORO_INSUFICIENTE; y GET /api/no-existe → 404 RUTA_NO_ENCONTRADA.

Ejercicio 2: el bug que no se filtra

Añade una ruta /api/depuracion/fallo que provoque un TypeError real (leer una propiedad de undefined). Comprueba que con NODE_ENV=desarrollo la respuesta incluye traza, que con NODE_ENV=produccion devuelve el 500 mudo, y que en ambos casos stderr muestra la traza completa con el prefijo [BUG] y el idPeticion.

Ejercicio 3: el envoltorio de Express 4 y por qué sobra

Crea dos rutas async que rechacen, una envuelta en asyncHandler y otra sin envolver, y comprueba en Express 5 que se comportan igual. Después escribe un manejador que lance dentro de un setTimeout y explica por qué ese sí tumba el proceso, y cómo lo arreglarías.

Soluciones

Solución 1

curl -s localhost:3000/api/eventos/evt-999  # 404 EVENTO_NO_ENCONTRADO
curl -s localhost:3000/api/no-existe        # 404 RUTA_NO_ENCONTRADA
# 400 de validacion con detalles, y 409 de conflicto de estado:
curl -s -X POST localhost:3000/api/pedidos -H 'Content-Type: application/json' \
  -d '{"sesionId":"ses-001-1","cantidad":99,"email":"[email protected]"}'
# {"error":{"codigo":"DATOS_INVALIDOS",...,"detalles":[{"campo":"cantidad","tipo":"too_big"}]}}
curl -s -X POST localhost:3000/api/pedidos -H 'Content-Type: application/json' \
  -d '{"sesionId":"ses-003-2","cantidad":6,"email":"[email protected]"}'
# {"error":{"codigo":"AFORO_INSUFICIENTE","mensaje":"...solo 3 entradas libres","estado":409}}

Fíjate en la diferencia entre los dos últimos: cantidad: 99 es un problema de forma (400, lo detecta el esquema sin mirar el estado del sistema); pedir 6 entradas cuando quedan 3 es un problema de estado (409, solo lo puede detectar el dominio en ese instante). Son los dos niveles de 06-06 funcionando.

Solución 2

// src/rutas/index.js — la ruta se registra solo fuera de produccion: una
// puerta para provocar errores no deberia existir en el despliegue real.
if (!configuracion.esProduccion) {
  const sesion = undefined;
  api.get('/depuracion/fallo', (p, r) => r.json({ aforo: sesion.aforo })); // TypeError
}
NODE_ENV=desarrollo node src/servidor.js && curl -s localhost:3000/api/depuracion/fallo
# {"error":{"codigo":"ERROR_INTERNO",...,"traza":["TypeError: Cannot read properties...",...]}}
NODE_ENV=produccion node src/servidor.js && curl -s localhost:3000/api/depuracion/fallo
# {"error":{"codigo":"ERROR_INTERNO","estado":500,"idPeticion":"..."}}   sin traza

En ambos casos, stderr muestra la misma traza completa precedida de [BUG] 3b71e0a2-... GET /api/depuracion/fallo 500 TypeError.

Solución 3

// comparacion.js — el manejador de errores final responde 500 con error.message.
const asyncHandler = (fn) => (p, r, s) => Promise.resolve(fn(p, r, s)).catch(s);
// A envuelta al estilo Express 4; B sin envolver: en Express 5 son identicas.
aplicacion.get('/a', asyncHandler(async () => { throw new Error('fallo en A'); }));
aplicacion.get('/b', async () => { throw new Error('fallo en B'); });
// C: dentro de un setTimeout, Express no puede verlo. D: la version correcta.
aplicacion.get('/c', (p, r) => setTimeout(() => { throw new Error('fallo en C'); }, 50));
aplicacion.get('/d', async () => { await dormir(50); throw new Error('fallo en D'); });

Con curl, /a, /b y /d devuelven un 500 con su mensaje —las dos primeras, idénticas—, mientras que /c mata el proceso con un uncaughtException, porque su throw ocurre en un tic posterior del bucle de eventos (módulo 2), en una pila donde ya no hay nada de Express, y la promesa que el manejador devolvió —ninguna, en este caso— no puede observar ese fallo. El arreglo es /d: promisificar la espera para que el throw ocurra dentro de la cadena async que Express sí observa. Conclusión práctica: asyncHandler es innecesario en Express 5, pero promisificar sigue siendo obligatorio.

Conclusión

Con esta lección se cierra el módulo 6, y Escena Viva es ya una API Express completa. Los errores tienen por fin un destino único. Sabes que el manejador de error se identifica por sus cuatro parámetros y que olvidar el último lo convierte en un middleware normal con síntomas desconcertantes; que va registrado el último porque solo ve lo declarado por encima; y que el manejador por defecto de Express filtra la traza en producción y la muestra en desarrollo, política que tu manejador propio reproduce con tu formato JSON. Has visto la gran mejora de Express 5 —un manejador async que rechaza llega solo al manejador de errores, y asyncHandler pasa a ser una reliquia de Express 4— con su única excepción viva: lo que ocurre dentro de una devolución de llamada sigue siendo cosa tuya, y por eso se promisifica.

Has construido una jerarquía propia en src/errores.js con ErrorDeAplicacion y sus subclases ErrorDeValidacion, RecursoNoEncontrado y ConflictoDeEstado, y la tabla ESTADO_POR_CODIGO que escribiste en el módulo 4 ha encontrado por fin su sitio definitivo: un único punto donde los códigos del dominio, los de tu jerarquía y los de los paquetes de terceros se traducen a estados HTTP. Distingues los errores operativos —esperables, explicados al cliente con detalle— de los errores de programación —registrados enteros, devueltos como un 500 mudo con solo el identificador de petición como referencia—. Y fuera de Express, unhandledRejection y uncaughtException registran y terminan el proceso ordenadamente, sin fingir que no ha pasado nada, dejando el reinicio a un supervisor que llegará en el módulo 11.

Mira ahora el proyecto entero. crearAplicacion() se prueba sin arrancar nada. La configuración se lee y se valida en un solo sitio. Los routers de /api/eventos, /api/sesiones y /api/pedidos viven en ficheros de treinta líneas con sus controladores al lado. Tus middleware propios miden, identifican y controlan la caché; los de terceros añaden cabeceras de seguridad, CORS, registro, compresión y límites de uso. Los esquemas de zod describen exactamente qué acepta cada endpoint. Y cualquier fallo, venga de donde venga, sale con la misma forma y con un identificador que lo enlaza con los registros del servidor. Eso es una API de producción. Pero hay algo que no ha cambiado desde el módulo 3, y ya empieza a incomodar: los datos siguen viviendo en datos/eventos.json. Cada venta reescribe un fichero entero. Dos compradores que pulsan "comprar" a la vez para las últimas entradas de la Sala Bóveda leen ambos el mismo aforo, ambos ven que hay sitio y ambos escriben. El fichero acaba con las ventas del segundo y las del primero desaparecen, o peor: se venden más entradas de las que caben en la sala. Ninguna validación de esquema evita eso, ningún middleware lo detecta y ningún manejador de errores lo puede arreglar, porque no es un error: es una carrera entre dos escrituras.

En el módulo 7 los datos dejan de vivir en un JSON. Empezaremos por entender qué es una base de datos y qué aporta frente a un fichero, modelaremos Escena Viva con MongoDB y Mongoose, escribiremos el CRUD completo, exploraremos relaciones y consultas avanzadas, veremos el mundo SQL con Sequelize y terminaremos con migraciones, semillas y —lo que estabas esperando— transacciones, que son exactamente la respuesta al problema del aforo y la sobreventa que acabamos de describir. El JSON nos ha servido durante siete módulos; ha llegado el momento de jubilarlo.

Curso de Node.js: De Principiante a Avanzado

Módulo 1: Introducción a Node.js

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados