Creación de un manejo de errores de nivel profesional en aplicaciones Node.js
Aprenda a clasificar los errores de Node.js, diseñar una jerarquía personalizada de errores, centralizar el manejo asincrónico de errores y proteger las trazas de pila para garantizar la resiliencia en entornos de producción.
En una etapa temprana de desarrollo de una aplicación Node.js resiliente, probablemente organizaron su base de código en módulos orientados al dominio y mantuvieron las cuestiones relacionadas con el transporte separadas de la lógica empresarial. Esa disciplina estructural es importante, pero no los salvará si una excepción no capturada o una promesa rechazada en silencio hace que el proceso falle. Dado que Node.js ejecuta el código de la aplicación en un bucle de eventos de un solo hilo, un fallo no manejado puede hacer colapsar todo el proceso o dejarlo funcionando en un estado corrupto e impredecible. Esta sección se centra en crear mecanismos de manejo de errores y resiliencia asíncrona adecuados para sistemas en producción.
1. Errores operativos vs. errores de programación: La distinción fundamental
Antes de escribir cualquier lógica de manejo de errores, necesitan un modelo mental claro que divida los errores en dos categorías:
┌────────────────────────┐
│ Application │
│ Encountered Error │
└───────────┬────────────┘
│
┌────────────────┴────────────────┐
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Operational Error │ │ Programmer Error │
├───────────────────────┤ ├───────────────────────┤
│ • Invalid Input │ │ • Syntax/Logic Bugs │
│ • Resource Not Found │ │ • Cannot read null │
│ • DB Connection Timeout│ │ • Out of memory │
│ • External API Down │ │ • Broken invariants │
└───────────┬───────────┘ └───────────┬───────────┘
│ │
▼ ▼
Handle Gracefully Log Stack Trace, Stop
(Return HTTP 4xx/5xx) Process & Let Orchestrator
(PM2/K8s) Restart Node
Errores operativos
Los errores operativos son las fallas que una aplicación funcional espera encontrar de vez en cuando. Se trata de eventos no fatales provocados por cosas como entradas de usuario incorrectas, una API secundaria que se desconecta o un registro que simplemente no está en la base de datos.
- Qué hacer: capturarlos, convertirlos en códigos de estado o respuestas a nivel de dominio adecuados, registrarlos con el nivel de gravedad correspondiente y permitir que el proceso siga atendiendo la solicitud de tráfico.
Errores del programador
Los errores del programador son fallos reales: acceder a una propiedad de undefined, pasar el tipo de dato incorrecto a una función o perder memoria con el tiempo.
- Qué hacer: capture la traza completa de la pila, avise a su sistema de monitoreo (Sentry, Datadog, etc.), cierre el proceso de manera limpia y confíe en un orquestador como Kubernetes, Docker Swarm o PM2 para iniciar una instancia de reemplazo. Nunca debe intentar seguir atendiendo solicitudes después de este tipo de error, ya que el estado en memoria del proceso ya no es fiable.
2. Construir una jerarquía de errores personalizados estandarizada
Las instancias simples de Error en JavaScript no contienen los metadatos necesarios: ni código de estado HTTP, ni flag operativo, ni código de error específico del dominio. Utilizar cadenas de texto sin procesar o llamadas genéricas como new Error('Algo falló') hace que el manejo de errores sea frágil y difícil de entender.
La solución: AppError base y subclases especializadas
En su lugar, defina una clase base extensible AppError que registre el contexto de ejecución y preserve la traza de pila original de V8 mediante Error.captureStackTrace.
// shared/errors/AppError.js
/**
* Base Application Error
* All custom domain errors extend this class.
*/
class AppError extends Error {
constructor(message, statusCode = 500, errorCode = 'INTERNAL_ERROR', isOperational = true) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.errorCode = errorCode;
this.isOperational = isOperational;
// Retain clean stack trace in V8 engine (Node.js)
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message = 'Invalid request payload', details = []) {
super(message, 400, 'VALIDATION_ERROR', true);
this.details = details;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} was not found`, 404, 'NOT_FOUND', true);
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED', true);
}
}
class SystemBugError extends AppError {
constructor(message = 'Critical system error encountered') {
// Programmer errors are marked as non-operational (isOperational = false)
super(message, 500, 'CRITICAL_BUG', false);
}
}
module.exports = {
AppError,
ValidationError,
NotFoundError,
UnauthorizedError,
SystemBugError
};
Por qué es importante en la lógica de dominio
Con esta jerarquía en su lugar, sus servicios empresariales pueden lanzar errores claros y con significado semántico sin necesidad de conocer nada sobre HTTP o frameworks web:
// components/orders/orders.service.js
const { NotFoundError, ValidationError } = require('../../shared/errors/AppError');
async function cancelOrder({ orderId, userId }) {
const order = await orderRepo.findById(orderId);
if (!order) {
throw new NotFoundError('Order');
}
if (order.userId !== userId) {
throw new ValidationError('You do not have permission to cancel this order.');
}
if (order.status === 'SHIPPED') {
throw new ValidationError('Cannot cancel an order that has already shipped.');
}
return await orderRepo.updateStatus(orderId, 'CANCELLED');
}
3. Envoltura centralizada de errores en controladores y middleware
Escribir try { ... } catch (err) { next(err); } dentro de cada manejador de ruta genera mucho ruido repetitivo, y es fácil olvidar algún bloque catch en alguna parte.
La forma incorrecta: código genérico verboso de try-catch
// orders.controller.js
async function getOrder(req, res, next) {
try {
const order = await orderService.getOrder(req.params.id);
return res.json(order);
} catch (err) {
// Repeated in every single handler!
next(err);
}
}
La forma correcta: manejador asíncrono de orden superior
En su lugar, envuelva sus controladores en una pequeña utilidad asíncrona, o aproveche el soporte nativo para rutas asíncronas disponible en Express 5 y versiones posteriores:
// shared/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
// orders.controller.js
const asyncHandler = require('../../shared/utils/asyncHandler');
const orderService = require('./orders.service');
// Clean, zero try/catch boilerplate
const getOrder = asyncHandler(async (req, res) => {
const order = await orderService.getOrder(req.params.id);
res.status(200).json({ status: 'success', data: order });
});
module.exports = { getOrder };
Intermedio global centralizado para errores
Envíe cada error operativo no capturado a través de un intermedio único y centralizado para el manejo de errores, que convierte dichos errores en un formato de respuesta JSON consistente para su API.
// shared/middleware/errorHandler.js
const { logger } = require('../logger');
const { AppError } = require('../errors/AppError');
function globalErrorHandler(err, req, res, next) {
err.statusCode = err.statusCode || 500;
err.errorCode = err.errorCode || 'INTERNAL_SERVER_ERROR';
// Log all errors internally
if (err.isOperational) {
logger.warn(`[Operational Error] ${err.name} (${err.errorCode}): ${err.message}`);
} else {
logger.error(`[CRITICAL PROGRAMMER ERROR] ${err.stack}`);
}
// Response for Operational Errors
if (err.isOperational) {
return res.status(err.statusCode).json({
status: 'error',
code: err.errorCode,
message: err.message,
...(err.details && { details: err.details })
});
}
// Generic Response for Programmer/System Failures (Hide internal stacks in production)
return res.status(500).json({
status: 'error',
code: 'INTERNAL_SERVER_ERROR',
message: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred on our server.'
: err.message
});
}
module.exports = globalErrorHandler;
4. Conservación de las trazas de pila más allá de los límites asíncronos
Uno de los errores más sutiles que afectan tanto la depuración como el rendimiento en Node.js es la pérdida de la traza de pila, que ocurre cuando se devuelve una promesa dentro de un bloque try/catch o en una función asíncrona sin esperarla primero.
El problema de la traza de pila: return vs return await
En JavaScript puro, al devolver una promesa directamente desde dentro de una función async—sin esperarla—se omite el contexto de llamada async de dicha función si la promesa finalmente rechaza más adelante en la cadena.
Peligroso: Devolver promesas no esperadas en Try/Catch
// order.repository.js
async function findOrderById(id) {
try {
// ❌ BAD: Returning promise directly inside try block.
// If db.query fails, the catch block in THIS function will NOT execute!
return db.query('SELECT * FROM orders WHERE id = $1', [id]);
} catch (err) {
logger.error('Failed to query order database', err);
throw new CustomDatabaseError(err.message);
}
}
¿Por qué esto causa problemas? Porque db.query devuelve una promesa pendiente, findOrderById devuelve de inmediato, pasando esa promesa pendiente a quienquiera que la haya llamado. Para cuando la promesa finalmente rechaza, el bloque catch definido dentro de findOrderById ya no está en la pila de llamadas y nunca se ejecuta.
Correcto: Esperar explícitamente antes de devolver
// order.repository.js
async function findOrderById(id) {
try {
// ✅ GOOD: Awaiting resolves or rejects WITHIN this async frame.
return await db.query('SELECT * FROM orders WHERE id = $1', [id]);
} catch (err) {
logger.error('Failed to query order database', err);
throw new CustomDatabaseError(err.message);
}
}
Como regla general: dentro de cualquier función async que utilice try/catch, siempre escriba return await al llamar a una operación asíncrona anidada. Esto mantiene el marco de pila intacto y garantiza que el código de limpieza o registro local se ejecute realmente cuando ocurre un error.
5. Terminación elegante del proceso y medidas de seguridad a nivel de tiempo de ejecución
Node.js ofrece dos mecanismos a nivel de proceso que le permiten reaccionar cuando algo escapa a todas las demás medidas de protección: uncaughtException y unhandledRejection.
Manejo de fallos a nivel de proceso
// server.js
const app = require('./app');
const { logger } = require('./shared/logger');
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
});
// 1. Intercept Unhandled Promise Rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('UNHANDLED REJECTION! 💥 Shutting down...', reason);
// Trigger graceful shutdown
gracefulShutdown(1);
});
// 2. Intercept Uncaught Exceptions (Programmer Errors)
process.on('uncaughtException', (error) => {
logger.error('UNCAUGHT EXCEPTION! 💥 Shutting down...', error);
// Trigger graceful shutdown immediately
gracefulShutdown(1);
});
// 3. Graceful Shutdown Flow
function gracefulShutdown(exitCode = 0) {
logger.info('Closing HTTP server and cleaning up active connections...');
server.close(async () => {
try {
// Close Database Connections, Redis clients, Message Consumers
await db.disconnect();
await redis.quit();
logger.info('All database connections closed cleanly.');
process.exit(exitCode);
} catch (err) {
logger.error('Error during shutdown:', err);
process.exit(1);
}
});
// Force shutdown after 10 seconds if connections refuse to close
setTimeout(() => {
logger.error('Forced shutdown due to timeout.');
process.exit(1);
}, 10000);
}
Lista de verificación para la resiliencia
Antes de pasar a la estrategia de pruebas, verifique su código contra estos puntos:
- Clasificación de errores: ¿separe claramente los fallos operativos de los errores causados por el programador?
AppError que incluyen códigos de estado y una bandera operativa, en lugar de simples cadenas de texto?return await dentro de bloques try/catch para que los marcos de pila asíncronos permanezcan intactos?uncaughtException y unhandledRejection, cierra las conexiones a la base de datos y se detiene de manera ordenada para que el gestor de procesos pueda reiniciarla?Lecturas relacionadas
- 20 patrones de Node.js que evitan el cese del servidor en producción — Aprenda 20 patrones prácticos de Node.js, desde el manejo de errores hasta el apagado ordenado y la gestión de conexiones en pool, que impiden los fallos antes de que sea necesario reiniciar el servidor.
- Cómo process.nextTick() destruye silenciosamente el bucle de eventos de Node.js — Explica por qué las llamadas recursivas a process.nextTick() bloquean completamente la fase de monitoreo de libuv y cómo solucionar este problema utilizando setImmediate().