Skip to content

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.

Academia Web — aprende, copia y construye.