Недопустимое безразличие: мастерство обработки ошибок в Express.js приложении

mermaid
graph TD
    A[Входящий запрос] --> B[Маршрутизатор]
    B --> C[Контроллер]
    C --> D[Сервисный слой]
    D --> E[Сторонний API]
    D --> F[База данных]
    D --> G[Кеш]
    E -->|Ошибка сети| H[Обертка триггера]
    F -->|Ошибка запроса| H
    G -->|Ошибка подключения| H
    H --> I[Ошибка преобразования]
    I --> J[Классификация ошибок]
    J --> K[HTTP код статуса]
    J --> L[Логирование]
    J --> M[Пользовательское сообщение]
    K --> N[Ответ клиенту]

Код без надёжной обработки ошибок похож на мост без парапетов – одна невнимательность, и всё рушится. Особенно в JavaScript-среде, где асинхронная природа создаёт уникальные болевые точки. Разберём практики, которые превратят ваше Express-приложение из неустойчивого прототипа в промышленно-готовое решение.

Почему стандартные подходы терпят фиаско

Типичное решение начинается с app.use((err, req, res, next) => {...}) и внезапно даёт течь при первом же асинхронном контроллере:

javascript
// Смертельная ловушка №1: забытый 'await'
app.get('/users', async (req, res) => {
  const users = db.query('SELECT * FROM users')
  res.json(users) // Unhandled promise rejection
})

// Смертельная ловушка №2: проглатывание ошибок
app.post('/upload', async (req, res) => {
  try {
    await processUpload(req.file)
  } catch (e) {
    console.log(e.message) // Продолжаем как ни в чем не бывало
  }
  res.status(200).end() // Клиент в заблуждении
})

Проблема глубже синтаксиса. Дуэт Node.js-Express представляет три уровня ошибок:

  1. Синхронные исключения (throw new Error()) – ловятся обычным try/catch
  2. Асинхронные исключения в промисах – требуют цепочки .catch() или try/catch с async/await
  3. Ошибки вне промисов – коллбэки вроде fs.readFile, где хэндлинг должен быть вручную

Анатомия неуязвимого обработчика

Стратегия 1. Захватчик асинхронных неудач

Обернём все маршруты middleware-функцией:

javascript
const asyncHandler = fn => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next)
}

app.get('/users', asyncHandler(async (req, res) => {
  const users = await db.query('SELECT * FROM users')
  if (!users.length) throw new EmptyResultError('No users found')
  res.json(users)
}))

Принцип прост – любая асинхронная функция завернута в Promise, а её отказ автоматически передаётся в централизованный хэндлер.

Стратегия 2. Иерархия ошибок с диагностикой

Вместо туманных new Error() создадим конкретные классы:

javascript
class AppError extends Error {
  constructor(message, httpStatusCode) {
    super(message)
    this.httpStatusCode = httpStatusCode || 500
    this.isOperational = true
  }
}

class ValidationError extends AppError {
  constructor(errors) {
    super('Invalid input data', 400)
    this.validationErrors = errors
  }
}

class DBConnectionError extends AppError {
  constructor() {
    super('Database unavailable', 503)
    this.retryAfter = 30 // Секунд до повторного запроса
  }
}

Почему так:

  • Класс AppError гарантирует структуру
  • isOperational отличает провалы бизнес-логики от фатальных
  • Отдельные подклассы содержат релевантные метаданные
  • HTTP-статус привязан к типу ошибки

Стратегия 3. Централизованная интеллектуальная обработка

Финальная middleware – точка монетизации ошибок:

javascript
app.use((err, req, res, _next) => {
  // Логирование с контекстом
  logger.error({
    error: err.stack,
    method: req.method,
    path: req.path,
    params: req.params,
    ip: req.ip
  })

  // Формирование ответа в зависимости от типа
  if (err instanceof ValidationError) {
    return res.status(err.httpStatusCode).json({
      status: 'fail',
      message: err.message,
      errors: err.validationErrors
    })
  }

  // Для неоперациональных ошибок – общий ответ
  if (!err.isOperational) {
    process.exit(1) // Принудительный рестарт с orchestrator
  }

  // Стандартизированный формат для клиента
  res.status(err.httpStatusCode || 500).json({
    status: 'error',
    message: err.message || 'Internal server error'
  })
})

Ключевые моменты:

  • Разные представления для разных типов ошибок
  • Контекстное логирование для диагностики
  • Отдельная политика для критических сбоев
  • Выделение доменных ошибок от инфраструктурных

Реальный кейс: интеграция с payment gateway

Демонстрация всей цепочки на сложном реалистичном случае:

javascript
class PaymentProcessingError extends AppError {
  constructor(gatewayResponse, originalError) {
    super('Payment failed', 402)
    this.gatewayResponse = gatewayResponse
    this.originalError = originalError
  }
}

app.post('/checkout',
  asyncHandler(validateCheckoutSchema), // Айтемизация запроса
  asyncHandler(async (req, res) => {
    const { paymentMethod, amount } = req.body
    
    const paymentResult = await PaymentGateway.charge({
      method: paymentMethod,
      amount,
      currency: 'USD'
    })
    
    if (paymentResult.status === 'declined') {
      throw new PaymentProcessingError(
        paymentResult,
        new Error(`Gateway status: ${paymentResult.code}`)
      )
    }
    
    await OrderService.createOrder(req.user.id, paymentResult)
    res.status(201).json({ success: true })
  })
)

Как это раскрывается в обработчике:

javascript
app.use((err, req, res, _next) => {
  if (err instanceof PaymentProcessingError) {
    // Аудит в блоке fraud detection
    fraudLogger.warn({
      userId: req.user.id,
      gatewayData: err.gatewayResponse
    })

    return res.status(402).json({
      status: 'fail',
      message: 'Payment authorization failed',
      reason: mapToUserMessage(err.gatewayResponse.code)
    })
  }
  // ...остальная обработка
})

Такой подход превращает ошибку в абонентскую услугу. Клиент получает понятную причину отказа, команда – полную информацию в логах, бизнес – защиту от мошенничества.

Мета-программирование защиты

  1. Защита CRON-заданий
    Оберните задания в try/catch с обязательным нотифицированием. Используйте библиотеки типа node-cron с watchdog-таймерами

  2. Graceful shutdown
    Обслуживайте текущие запросы перед остановкой сервера:

javascript
process.on('SIGTERM', () => {
  server.close(() => {
    logger.info('HTTP server closed')
    db.disconnect()
    process.exit(0)
  })
  
  // Принудительное завершение после таймаута
  setTimeout(() => {
    logger.error('Forcing shutdown')
    process.exit(1)
  }, 10_000)
})
  1. Фиксированные форматы ответов
    Используйте объявление res.apiSuccess() и res.apiError() для единообразия:
javascript
express.response.apiSuccess = function (data) {
  this.json({ status: 'success', data })
}

express.response.apiError = function (err) {
  this.status(err.httpStatus || 500)
    .json({ status: 'error', message: err.message })
}

// Тогда в контроллерах
res.apiSuccess({ order: createdOrder })

Инструменты допостановки

  • Winston или Bunyan – контекстное структурированное логирование
  • Helmet – автоматические HTTP-заголовки безопасности
  • Express-validator – встроенная валидация входящих данных
  • Sentry – мониторинг ошибок в реальном времени

Обработка ошибок – не дежурная часть документации, а градостроительный план системы. Каждая ошибка – точка улучшения, каждое исключение – урок проектирования. Вместо побега от исключительных ситуаций, обратите их в инструменты отладки, которые неустанно будут вести ваш сервис к надёжности. Развивайте культуру обработки сбоев как инженеры системы, а не как пассажиры тонущего корабля.