Створення механізмів обробки помилок промислового рівня у додатках 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. Створення стандартизованої ієрархії власних помилок
Звичайні об’єкти Error у JavaScript не містять необхідних метаданих — жодного коду статусу 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().