Главная / Статьи / Создание механизмов обработки ошибок промышленного уровня в приложениях 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. Создание стандартизированной иерархии пользовательских ошибок

Обычные экземпляры 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, закрывает соединения с базой данных и корректно завершает работу, чтобы менеджер процессов мог его перезапустить?
  • Связанные материалы

  • Девять шаблонов 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 в продакшене, и как структурированное логирование, уровни логов и идентификаторы корреляции помогают быстро устранять сложные ошибки.