Головна / Статті / Створення механізмів обробки помилок промислового рівня у додатках Node.js

Створення механізмів обробки помилок промислового рівня у додатках Node.js

Дізнайтеся, як класифікувати помилки Node.js, створювати власну ієрархію помилок, централізовано обробляти асинхронні помилки та зберігати стек-трейси для підвищення надійності в продакшні.

1840 слів

На ранній стадії створення стійкої додатку 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, закриває з’єднання з базою даних та чисто завершує роботу, щоб менеджер процесів міг його перезапустити?
  • Пов’язана література

  • Дев’ять шаблонів Promise для надійного асинхронного JavaScript у продакшені — Дізнайтеся про практичні шаблони Promise: паралельні запити, таймаути, повторні спроби, обмеження конкурентності та скасування — для створення стійкого асинхронного JavaScript промислового рівня.
  • Виправлення помилок обробки помилок Async/Await у коді Node.js для продакшену — Дізнайтеся про п’ять поширених помилок у обробці помилок async/await у JavaScript та Node.js, які спричиняють беззвучні збої та ситуації конкуренції, а також про конкретні способи їх виправлення.
  • Знаходження справжнього вузького місця у повільному кінцевому пункті Node.js — Дізнайтеся про систематичний метод відстеження затримок на бекенді по шляху запиту — від коду Node.js до запитів до бази даних — з використанням функцій вимірювання часу та EXPLAIN ANALYZE.
  • Стратегія оновлення токенів для систем автентифікації Node.js — Дізнайтеся, як проектувати, оновлювати, скасовувати та безпечно зберігати токени оновлення в Node.js, щоб крадіжка токенів та вихід з системи функціонували саме так, як очікується.
  • Структуроване логування в Node.js: перетворення хаосу під час налагодження в продакшні на ясність — Дізнайтеся, чому console.log не працює в додатках Node.js у продакшні, та як структуроване логування, рівні логів та ідентифікатори кореляції допомагають швидко вирішувати складні проблеми.