Создание механизмов обработки ошибок промышленного уровня в приложениях Node.js
Узнайте, как классифицировать ошибки Node.js, разработать собственную иерархию ошибок, централизовать обработку асинхронных ошибок и сохранять стек-трейсы для обеспечения надежности в производственной среде.
На раннем этапе создания надежного приложения на Node.js вы, скорее всего, структурировали свой кодовый базис в модули, ориентированные на конкретные области бизнеса, и отделяли вопросы передачи данных от бизнес-логики. Такая структурная дисциплина важна, но она не спасет вас, если необработанная исключение или тихо отклоненное обещание приведут к сбою процесса. Поскольку Node.js запускает код приложения с помощью однопоточного цикла событий, одна неразрешенная ошибка может привести к сбою всего процесса или заставить его работать в поврежденном, непредсказуемом состоянии. В этой части рассматривается создание механизмов обработки ошибок и асинхронной надежности, подходящих для производственных систем.
1. Операционные ошибки и ошибки программиста: фундаментальное различие
Прежде чем писать любую логику обработки ошибок, вам необходима четкая концепция, которая делит ошибки на две категории:
┌────────────────────────┐
│ 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
Операционные ошибки
Операционные ошибки — это сбои, с которыми время от времени сталкивается нормально работающее приложение. Это нефатальные события, вызванные такими причинами, как некорректные данные от пользователя, отключение внешнего API или отсутствие записи в базе данных.
- Что делать: ловить их, преобразовывать в соответствующие коды состояния или ответы на уровне домена, фиксировать с записью правильного уровня серьезности и позволять процессу продолжать обслуживание запросов.
Ошибки программиста
Ошибки программиста — это настоящие баги: попытка получить доступ к свойству объекта undefined, передача в функцию неверного типа данных или постепенная утечка памяти.
- Что делать: зафиксируйте полный стек-трейс, уведомите систему мониторинга (Sentry, Datadog и т. д.), корректно остановите процесс и воспользуйтесь оркестратором вроде Kubernetes, Docker Swarm или PM2 для запуска заменяющей инстанции. После возникновения подобных ошибок ни в коем случае не пытайтесь продолжать обрабатывать запросы, поскольку к состоянию процесса в оперативной памяти больше нельзя доверять.
2. Создание стандартизированной иерархии пользовательских ошибок
Обычные экземпляры JavaScript Error не содержат необходимой метаинформации — нет кода состояния HTTP, нет флага работы, нет специфичного для домена кода ошибки. Использование необработанных строк или генерических вызовов new Error('Что-то пошло не так') делает обработку ошибок хрупкой и затрудняет её понимание.
Решение: базовый класс AppError и специализированные подклассы
Вместо этого определите один расширяемый базовый класс AppError, который записывает контекст выполнения и сохраняет исходную трассу стека V8 с помощью 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
};
Почему это важно для логики домена
Благодаря такой иерархии ваши бизнес-сервисы могут выбрасывать чистые, семантически значимые ошибки без необходимости учитывать HTTP или веб-фреймворки:
// 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. Централизованное оборачивание ошибок контроллером и промежуточные компоненты
Написание конструкции try { ... } catch (err) { next(err); } в каждом обработчике маршрута приводит к большому количеству повторяющегося кода, и легко забыть о блоке catch где-то в коде.
Неправильный способ: объемный шаблон кода 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);
}
}
Правильный способ: асинхронный обработчик высшего порядка
Вместо этого оберните ваши контроллеры в небольшой асинхронный утилитарный класс или воспользуйтесь встроенной поддержкой асинхронных маршрутов в Express 5 и новее версиях:
// 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 };
Централизованный глобальный промежуточный компонент обработки ошибок
Отправляйте все непойманные операционные ошибки через единственный централизованный промежуточный компонент обработки ошибок, который преобразует их в единообразный формат JSON-ответа для вашего 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. Сохранение трейсов стека через границы асинхронности
Одной из более скрытых проблем, влияющих как на отладку, так и на производительность в Node.js, является потеря трейса стека из-за возврата обещания внутри блока try/catch или асинхронной функции без предварительного ожидания его выполнения.
Ловушка трейса стека: return против return await
В обычном JavaScript возврат обещания непосредственно изнутри функции async без ожидания его выполнения приводит к тому, что контекст асинхронного вызова этой функции игнорируется, если обещание в конечном итоге отклонится дальше по цепочке обработки.
Опасно: возврат неожиданных обещаний в блоках 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);
}
}
Почему это создаёт проблемы? Потому что db.query возвращает обещание, находящееся в состоянии ожидания, а findOrderById сразу же возвращает его, передавая это обещание тому, кто его вызвал. К моменту отклонения обещания блок catch, определённый внутри findOrderById, уже не находится в стеке вызовов и никогда не выполняется.
Правильно: явное ожидание перед возвратом
// 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);
}
}
Как общее правило: в любой функции async, использующей конструкцию try/catch, при вызове вложенной асинхронной операции всегда используйте return await. Это позволяет сохранить стек-фрейм в целостности и гарантирует, что код для очистки или логирования действительно будет выполнен в случае сбоя.
5. Гладкое завершение процесса и механизмы защиты на уровне выполнения
Node.js предоставляет два хука на уровне процесса, которые позволяют реагировать на ситуации, когда что-то прорывается сквозь все остальные меры защиты: uncaughtException и unhandledRejection.
Обработка сбоев на уровне процесса
// 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);
}
Чек-лист для проверки устойчивости
Прежде чем переходить к стратегии тестирования, проверьте свой кодовый базис по следующим пунктам:
- Классификация ошибок: четко ли вы разделяете операционные сбои и ошибки программистов?
AppError, содержащие коды состояния и флаг работы, вместо обычных строк?return await внутри блоков try/catch, чтобы асинхронные стековые фреймы оставались неповреждёнными?uncaughtException и unhandledRejection, закрывает соединения с базой данных и корректно завершает работу, чтобы менеджер процессов мог его перезапустить?Связанные материалы
- 20 шаблонов Node.js, предотвращающих простои производственных серверов — Узнайте о 20 практических шаблонах Node.js — от обработки ошибок до плавного выключения и пуллинга соединений — которые предотвращают сбои до того, как становится необходимым перезапуск.
- Как функция process.nextTick() тайно блокирует цикл событий Node.js — Объясняется, почему рекурсивные вызовы process.nextTick() полностью блокируют фазу опроса libuv, и как исправить ситуацию с блокировкой цикла событий с помощью функции setImmediate().