Manejo de errores centralizado

Lectura
20 min~8 min lectura
CONCEPTO CLAVE: El manejo de errores centralizado en Express es una estrategia que permite capturar, clasificar y responder a todos los errores de manera uniforme desde un único punto de tu aplicación, mejorando la mantenibilidad, la consistencia de respuestas y la experiencia tanto del desarrollador como del cliente.

¿Por qué necesitas un manejo de errores centralizado?

En aplicaciones Express pequeñas, es tentador manejar errores directamente en cada ruta con bloques try/catch o callbacks de error. Sin embargo, a medida que tu API crece, este enfoque genera múltiples problemas:

  • Inconsistencia: Cada desarrollador puede responder con formatos diferentes
  • Duplicación de código: La misma lógica de manejo se repite en múltiples lugares
  • Difícil mantenimiento: Cambiar el formato de errores requiere modificar múltiples archivos
  • Errores no controlados: Errores inesperados pueden crashear el servidor
📌 Regla de oro: En Express, todo error no capturado eventualmente llega al middleware de errores. Diseña tu sistema para que todos los errores pasen por un punto central.

La arquitectura del sistema de errores

Un sistema de manejo de errores centralizado en Express se compone de tres partes fundamentales:

  1. Clase de error personalizado: Una clase base que extiende Error con propiedades adicionales como código de estado HTTP, código de error interno y metadatos.
  2. Middleware de errores: Una función con la firma de cuatro parámetros (err, req, res, next) que procesa todos los errores de manera uniforme.
  3. Helper functions: Funciones utilitarias para lanzar errores rápidamente en diferentes situaciones.

Creando una clase de error personalizada

El primer paso es crear una clase que extienda Error para tener control total sobre las propiedades del error:

// src/errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode, errorCode = 'INTERNAL_ERROR') {
    super(message);
    this.statusCode = statusCode;
    this.errorCode = errorCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;

    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;
💡 Nota importante: La propiedad isOperational es útil para distinguir entre errores que tú creas intencionalmente (operacionales) y errores de programación o librerías externas. En producción, podrías querer registrar solo los errores no operacionales.

Clases de error específicas

Ahora crea clases específicas para diferentes tipos de errores comunes:

// src/errors/ApiError.js
const AppError = require('./AppError');

class ValidationError extends AppError {
  constructor(message, errors = []) {
    super(message, 400, 'VALIDATION_ERROR');
    this.errors = errors;
  }
}

class NotFoundError extends AppError {
  constructor(resource = 'Recurso') {
    super(`${resource} no encontrado`, 404, 'NOT_FOUND');
  }
}

class UnauthorizedError extends AppError {
  constructor(message = 'No autorizado') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

class ForbiddenError extends AppError {
  constructor(message = 'Acceso denegado') {
    super(message, 403, 'FORBIDDEN');
  }
}

class ConflictError extends AppError {
  constructor(message = 'Conflicto de recursos') {
    super(message, 409, 'CONFLICT');
  }
}

module.exports = {
  AppError,
  ValidationError,
  NotFoundError,
  UnauthorizedError,
  ForbiddenError,
  ConflictError
};
📌 Beneficio clave: Al tener clases específicas, puedes lanzar errores semánticos como throw new NotFoundError('Usuario') en vez de throw new AppError('Usuario no encontrado', 404, 'USER_NOT_FOUND'), haciendo el código más legible.

Middleware de manejo de errores

El middleware de errores es el corazón del sistema. Debe ser el último middleware registrado y manejar todos los errores de manera consistente:

// src/middleware/errorHandler.js
const { AppError } = require('../errors/ApiError');

const sendErrorDev = (err, res) => {
  res.status(err.statusCode).json({
    success: false,
    error: {
      code: err.errorCode,
      message: err.message,
      stack: err.stack,
      ...(err.errors && { details: err.errors })
    }
  });
};

const sendErrorProd = (err, res) => {
  // Error operacional: enviar detalles al cliente
  if (err.isOperational) {
    res.status(err.statusCode).json({
      success: false,
      error: {
        code: err.errorCode,
        message: err.message,
        ...(err.errors && { details: err.errors })
      }
    });
  } else {
    // Error de programación: no enviar detalles
    console.error('ERROR 💥:', err);
    res.status(500).json({
      success: false,
      error: {
        code: 'INTERNAL_ERROR',
        message: 'Algo salió mal. Por favor, intenta más tarde.'
      }
    });
  }
};

const errorHandler = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';
  err.errorCode = err.errorCode || 'INTERNAL_ERROR';

  if (process.env.NODE_ENV === 'development') {
    sendErrorDev(err, res);
  } else {
    sendErrorProd(err, res);
  }
};

module.exports = errorHandler;
⚠️ Importante: El orden de los parámetros en el middleware de errores es crucial: (err, req, res, next). Express identifica un middleware como de manejo de errores por la presencia de cuatro parámetros, siendo el primero el error.

Manejo de errores asíncronos

Express 4 y versiones posteriores manejan automáticamente errores rechazados en funciones async, pero es buena práctica usar un wrapper para capturar todos los errores posibles:

// src/utils/catchAsync.js
const catchAsync = (fn) => {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
};

module.exports = catchAsync;

Uso en tus controladores:

// src/controllers/userController.js
const catchAsync = require('../utils/catchAsync');
const { NotFoundError } = require('../errors/ApiError');
const User = require('../models/User');

exports.getUser = catchAsync(async (req, res, next) => {
  const user = await User.findById(req.params.id);
  
  if (!user) {
    return next(new NotFoundError('Usuario'));
  }
  
  res.status(200).json({
    success: true,
    data: user
  });
});
💡 Detalle técnico: Cuando lanzas un error dentro de una función async envuelta por catchAsync, el método .catch(next) captura la promesa rechazada y la pasa al siguiente middleware de errores automáticamente.

Manejo de errores de Mongoose

Mongoose genera errores específicos cuando hay problemas de validación o cuando no encuentra documentos. Debes transformar estos errores al formato de tu API:

// src/middleware/mongooseErrorHandler.js
const mongoose = require('mongoose');
const { ValidationError, NotFoundError } = require('../errors/ApiError');

const handleMongooseError = (err) => {
  // Error de validación de Mongoose
  if (err instanceof mongoose.Error.ValidationError) {
    const errors = Object.values(err.errors).map(e => ({
      field: e.path,
      message: e.message
    }));
    return new ValidationError('Error de validación', errors);
  }

  // Error de CastError (ID malformado)
  if (err instanceof mongoose.Error.CastError) {
    return new ValidationError(`Valor '${err.value}' inválido para el campo '${err.path}'`);
  }

  // Error de duplicado de MongoDB
  if (err.code === 11000) {
    const field = Object.keys(err.keyValue)[0];
    return new ValidationError(`El campo '${field}' ya existe`);
  }

  return err;
};

module.exports = handleMongooseError;
📌 Integración: Actualiza tu middleware de errores principal para usar este handler:
const errorHandler = (err, req, res, next) => {
  // ... código anterior ...
  
  let error = err;
  
  // Convertir errores de Mongoose
  if (err.name === 'MongoError' || err.name === 'CastError' || err.name === 'ValidationError') {
    error = handleMongooseError(err);
  }
  
  // Continuar con el manejo normal
  sendError(error, res);
};

404 y errores de ruta

No olvides manejar las rutas que no existen:

// src/middleware/notFound.js
const { NotFoundError } = require('../errors/ApiError');

const notFoundHandler = (req, res, next) => {
  next(new NotFoundError(`Ruta ${req.originalUrl} no encontrada`));
};

module.exports = notFoundHandler;

Regístralo después de todas tus rutas:

// src/app.js
const express = require('express');
const app = express();
const errorHandler = require('./middleware/errorHandler');
const notFoundHandler = require('./middleware/notFound');

// ... tus rutas ...

app.use(notFoundHandler);
app.use(errorHandler);

module.exports = app;

Middleware de desarrollo vs producción

Es crucial tener diferentes comportamientos según el entorno:

AspectoDesarrolloProducción
Mensaje de errorDetallado con stack traceGenérico y seguro
LogsConsole.log básicoLogs estructurados con Winston o similar
CORSPermisivoRestrictivo
⚠️ Seguridad en producción: Nunca expongas el stack trace ni mensajes de error internos en producción. Un atacante podría usar esa información para explotar vulnerabilidades.

Logging de errores

Para producción, usa un sistema de logging que no bloquee el event loop:

// src/utils/logger.js
const winston = require('winston');

const logger = winston.createLogger({
  level: 'error',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    new winston.transports.File({ filename: 'logs/combined.log' })
  ]
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }));
}

module.exports = logger;
Ver más: Integración de logging en el handler de errores
// Actualiza errorHandler.js
const logger = require('../utils/logger');

const errorHandler = (err, req, res, next) => {
  // ... código anterior ...
  
  if (process.env.NODE_ENV === 'production' && !err.isOperational) {
    logger.error({
      message: err.message,
      stack: err.stack,
      url: req.originalUrl,
      method: req.method,
      ip: req.ip,
      userAgent: req.get('user-agent')
    });
  }
};

Manejo de errores en operaciones asíncronas批量

Cuando trabajas con múltiples operaciones asíncronas, usa Promise.all y captura el primer error:

exports.processUserData = catchAsync(async (req, res, next) => {
  const userData = req.body;
  
  // Validar datos primero
  if (!userData.email || !userData.name) {
    return next(new ValidationError('Email y nombre son requeridos'));
  }

  // Ejecutar múltiples operaciones
  const [user, profile, settings] = await Promise.all([
    User.create(userData),
    Profile.create({ userId: userData.id }),
    Settings.create({ userId: userData.id })
  ]);

  res.status(201).json({ success: true, data: { user, profile, settings } });
});
"Un sistema de manejo de errores bien diseñado no se trata solo de capturar errores, sino de proporcionar información útil tanto para depurar problemas como para que los clientes de tu API sepan exactamente qué salió mal y cómo resolverlo."

Resumen de la estructura final

src/
├── errors/
│   ├── AppError.js           # Clase base
│   └── ApiError.js           # Clases específicas
├── middleware/
│   ├── errorHandler.js       # Handler principal
│   ├── notFound.js           # 404 handler
│   └── mongooseErrorHandler.js
├── utils/
│   ├── catchAsync.js         # Wrapper async
│   └── logger.js             # Sistema de logs
└── app.js                    # Configuración final
💡 Próximo paso: Una vez que tengas tu sistema de errores centralizado funcionando, considera integrar un servicio de tracking de errores como Sentry o Bugsnag para monitorear errores en producción en tiempo real.
🧠 Quiz: Manejo de errores centralizado

¿Cuál es la firma correcta del middleware de manejo de errores en Express?

  • A) (err, req, res)
  • B) (req, res, next)
  • C) (err, req, res, next)
  • D) (error, request, response)
✅ Respuesta correcta: C) (err, req, res, next). Express identifica un middleware como de manejo de errores por la presencia de cuatro parámetros, siendo el primero el objeto de error.
🧠 Quiz: Clases de error

¿Por qué es importante marcar errores como 'operacionales' vs 'no operacionales'?

  • A) Para que el código sea más rápido
  • B) Para distinguir entre errores del programador y errores esperados, permitiendo diferente tratamiento
  • C) No tiene importancia, solo es un ejemplo decorativo
  • D) Para cumplir con estándares de ECMAScript
✅ Respuesta correcta: B. Los errores operacionales son intencionales y esperados (como un recurso no encontrado), mientras que los no operacionales son bugs del código. En producción, querrás registrar los bugs para repararlos, pero no exponer sus detalles al cliente.
📌 Lleva tu API al siguiente nivel: Un sistema de errores centralizado no solo mejora la experiencia del desarrollador, sino que también facilita el debugging, el monitoreo en producción y proporciona a los clientes de tu API respuestas consistentes y documentadas que les permiten manejar errores de manera elegante.