Tema
Middleware de errores
En lugar de llenar cada controlador de try/catch y res.status(500), lanza errores con una clase propia y deja que un solo middleware decida qué responder. Resultado: controladores limpios, respuestas consistentes y nada de stack traces filtrados en producción.
Archivos
js
/** Error "esperado" con código HTTP. Lánzalo desde cualquier parte */
export class AppError extends Error {
constructor(mensaje, status = 400, detalles = undefined) {
super(mensaje);
this.name = 'AppError';
this.status = status;
this.detalles = detalles;
this.esOperacional = true; // diferencia errores previstos de bugs
}
static badRequest(msg = 'Petición no válida', detalles) { return new AppError(msg, 400, detalles); }
static noAutorizado(msg = 'No autorizado') { return new AppError(msg, 401); }
static prohibido(msg = 'Acceso denegado') { return new AppError(msg, 403); }
static noEncontrado(msg = 'Recurso no encontrado') { return new AppError(msg, 404); }
static conflicto(msg = 'El recurso ya existe') { return new AppError(msg, 409); }
static validacion(detalles) { return new AppError('Datos no válidos', 422, detalles); }
}js
// Envuelve controladores async para que sus errores lleguen a next()
// (Express 5 ya lo hace solo; en Express 4 es imprescindible)
export const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);js
import { AppError } from '../utils/AppError.js';
const esProduccion = process.env.NODE_ENV === 'production';
/** 404: se monta después de todas las rutas */
export function noEncontrado(req, res, next) {
next(AppError.noEncontrado(`No existe ${req.method} ${req.originalUrl}`));
}
/** Traduce errores de librerías a AppError */
function normalizar(err) {
if (err instanceof AppError) return err;
// JSON mal formado en el body
if (err.type === 'entity.parse.failed') return AppError.badRequest('El cuerpo no es JSON válido');
if (err.type === 'entity.too.large') return new AppError('El cuerpo es demasiado grande', 413);
// MySQL
if (err.code === 'ER_DUP_ENTRY') return AppError.conflicto('Ya existe un registro con esos datos');
if (err.code === 'ER_NO_REFERENCED_ROW_2') return AppError.badRequest('Referencia a un registro inexistente');
if (err.code === 'ER_ROW_IS_REFERENCED_2') return AppError.conflicto('No se puede eliminar: tiene registros relacionados');
// PostgreSQL
if (err.code === '23505') return AppError.conflicto('Ya existe un registro con esos datos');
if (err.code === '23503') return AppError.badRequest('Referencia a un registro inexistente');
// JWT
if (err.name === 'TokenExpiredError') return AppError.noAutorizado('Token expirado');
if (err.name === 'JsonWebTokenError') return AppError.noAutorizado('Token inválido');
// Zod
if (err.name === 'ZodError') {
return AppError.validacion(err.issues.map((i) => ({ campo: i.path.join('.'), mensaje: i.message })));
}
// Multer
if (err.name === 'MulterError') {
const msg = err.code === 'LIMIT_FILE_SIZE' ? 'El archivo es demasiado grande' : err.message;
return AppError.badRequest(msg);
}
// Cualquier otra cosa es un bug: 500
const e = new AppError('Error interno del servidor', 500);
e.esOperacional = false;
e.original = err;
return e;
}
/** Manejador final (¡debe tener 4 parámetros!) */
// eslint-disable-next-line no-unused-vars
export function manejadorErrores(err, req, res, next) {
const error = normalizar(err);
// Registra los bugs con todo el detalle (aquí podrías enviar a Sentry)
if (!error.esOperacional || error.status >= 500) {
console.error(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`, error.original ?? err);
}
res.status(error.status).json({
error: error.message,
...(error.detalles && { detalles: error.detalles }),
// En desarrollo, el stack ayuda a depurar; en producción jamás
...(!esProduccion && error.status >= 500 && { stack: (error.original ?? err).stack }),
});
}Registrar en app.js
js
import { noEncontrado, manejadorErrores } from './middlewares/errores.js';
// ... middlewares y rutas ...
app.use('/api', rutas);
// SIEMPRE al final y en este orden
app.use(noEncontrado);
app.use(manejadorErrores);Controladores antes y después
js
export async function obtener(req, res) {
try {
const [filas] = await pool.execute('SELECT * FROM productos WHERE id = ?', [req.params.id]);
if (!filas.length) {
return res.status(404).json({ error: 'No encontrado' });
}
res.json(filas[0]);
} catch (err) {
console.log(err);
res.status(500).json({ error: err.message }); // ¡filtra detalles internos!
}
}js
import { asyncHandler } from '../utils/asyncHandler.js';
import { AppError } from '../utils/AppError.js';
export const obtener = asyncHandler(async (req, res) => {
const producto = await Producto.buscarPorId(req.params.id);
if (!producto) throw AppError.noEncontrado('Producto no encontrado');
res.json({ data: producto });
});Respuestas que obtiene el cliente
json
// 404
{ "error": "Producto no encontrado" }
// 422 (zod)
{ "error": "Datos no válidos", "detalles": [{ "campo": "precio", "mensaje": "Debe ser mayor a 0" }] }
// 500 en producción
{ "error": "Error interno del servidor" }Buena práctica
Distingue entre errores operacionales (el usuario mandó datos malos, el recurso no existe) y bugs (undefined is not a function). Los primeros se responden con su mensaje; los segundos se registran a detalle y al cliente sólo se le dice "error interno".
Error común
Declarar el manejador con 3 parámetros (err, req, res). Express identifica los manejadores de error por tener exactamente 4 argumentos; con 3 lo trata como middleware normal y nunca se ejecuta.