Тихие убийцы процессов: стратегии надёжной обработки ошибок в Node.js

Ситуация знакомая: сервер падает без логов, пользователи видят 500 ошибки, а вы часами ищете причину. В Node.js ошибки иногда проглатываются с пугающей тишиной. Проблема не в самих исключениях — они естественны, — а в их скрытом распространении через асинхронный код. Приведу пример классической ловушки:

javascript
app.get('/user/:id', async (req, res) => {
  const user = await User.findById(req.params.id) // Ошибка доступа к базе?
  res.json(user)
})

При сбое базы данных здесь произойдёт:

  1. Promise от User.findById отклонится
  2. Ошибка "провалится" через стек вызовов
  3. Express по умолчанию отправит HTTP 500 без деталей
  4. В консоль ничего не попадёт

Такое поведение — катализатор для сбоев в продакшене. Разберёмся, как его избежать.


Разделяй и властвуй: классификация ошибок

Первое: чёткое разделение ошибок критично для обработки.

Программистские ошибки:

  • Баги в коде (синтаксис, ссылки на undefined)
  • Асинхронные исключения без обработчиков
  • Нарушенные контракты (неверные аргументы)
  • Лечение: исправление кода. Запуск приложения невозможен.

Операционные ошибки:

  • Сбои инфраструктуры (база данных, сеть)
  • Ошибочные входные данные
  • Ресурсные проблемы (таблицы файлов, память)
  • Лечение: логирование, повторные попытки, изящное завершение.

Проблема начинается, когда мы путаем эти типы или позволяем операционным стать программистскими из-за неправильной обработки.


Асинхронный Вайоминг: где теряются ошибки

Главные зоны риска в Node.js:

1. Event Emitters (без обработчика error):

javascript
const fs = require('fs')
const stream = fs.createReadStream('несуществующий-файл')

stream.pipe(res) // Упадёт нативным исключением, убив процесс

Решение: всегда добавляйте обработчики error:

javascript
stream.on('error', err => {
  console.error('Ошибка потока:', err)
  res.status(500).end()
})

2. Промисы без catch и необработанные асинхронные исключения:

javascript
app.post('/data', (req, res) => {
  saveDataAsync() // Unhandled promise rejection!
  res.status(202).json({ status: 'accepted' })
})

Решение: используйте middleware для асинхронных контроллеров:

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

app.post('/data', asyncHandler(async (req, res) => {
  await saveDataAsync()
  res.json({ status: 'ok' })
}))

3. Забытые колбэки:

javascript
function legacyCode(callback) {
  doSomethingAsync((err) => { // Пропущен вызов callback при ошибке
    if (err) console.error(err) // Колбэк никогда не вызывается!
    callback(null, result)
  })
}

Архитектурный щит: централизованная обработка

Создайте middleware-фильтр ошибок. В Express:

javascript
// Последний middleware в цепочке
app.use((err, req, res, next) => {
  if (err instanceof DatabaseError) {
    log.warn('Сбой БД', err)
    return res.status(503).json({ error: 'database_unavailable' })
  }

  if (err instanceof AuthError) {
    return res.status(401).end()
  }

  // Неизвестная ошибка: критично, нужна диагностика
  log.fatal(err, 'Непредвиденное исключение')
  res.status(500).end()
})

Ключевые правила:

  • Всегда передавайте ошибки через next(err)
  • Не обрабатывайте ошибки внутри контроллера дважды
  • Конвертируйте отклонённые промисы в вызовы next()

Досье на сбой: инструменты логирования

Логи без структуры — кирпичи без цемента. Используйте:

  • Форматирование через JSON
  • Контекстные метки (requestId, userId)
  • Уровни серьёзности (fatal, error, warn)
  • Транспорты для отправки в ELK/Sentry/CloudWatch

Пример с Winston:

javascript
import winston from 'winston'

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: 'logs/app-critical.log', level: 'error' })
  ]
})

// Использование с контекстом
logger.error('Ошибка сериализации', { 
  requestId: req.id, 
  userId: req.user.id,
  stack: err.stack 
})

Превентивные меры: как не попадать в ловушки

  1. Стилевой соглашения:

    • Колбэки: первый аргумент всегда err
    • Промисы: возвращайте цепочки из middleware
    • Async/await: оборачивайте в try/catch или используйте .catch()
  2. Контрактное программирование:

    javascript
    function createUser(data) {
      assert.object(data, 'data must be object')
      assert.string(data.email, 'email required')
      // ...
    }
    
  3. Наследование для ошибок:

    javascript
    class DatabaseError extends Error {
      constructor(message, query) {
        super(message)
        this.query = query
        this.name = 'DatabaseError'
      }
    }
    // Бросайте через throw new DatabaseError('Connection reset', { sql: 'SELECT ...' })
    
  4. Инструменты:

    • Используйте node --unhandled-rejections=strict
    • Добавьте обработчики uncaughtException и unhandledRejection

Когда node неумолим: фатальные ошибки

Некоторые ошибки неизбежно вызовут падение процесса:

  • Подвисшая очередь событий (event loop drained) を与えた
  • Двойной callback()
  • Исключения вне асинхронных стеков (таймеры, process.nextTick)

Решение: используйте кластеры для изоляции сбоев и процесс-надсмотрщик (PM2, Kubernetes), с тайм-аутом для изящного завершения:

javascript
process.on('uncaughtException', (err) => {
  logger.fatal(err, 'Неконтролируемое падение')
  setTimeout(() => process.exit(1), 500) // Дать время на логирование
})

Заключение

Грамотная обработка ошибок в Node.js строится на:

  • Категоризации сбоев на операционные/программистские
  • Гарантированном перехвате асинхронных исключений
  • Централизованной обработке через архитектурные шаблоны
  • Детализированном логировании с контекстом

Последний совет: моделируйте отказы. Используйте библиотеку типа node -r ./chaos.js со случайными сбоями БД в dev-среде. Как видите, ошибки — не препятствия, а система тонких сигналов, которые при правильной интерпретации, повышают отказоустойчивость приложения. Учитесь инженерной дисциплине с тыловым прикрытием — тогда не одна ошибка не пройдёт незамеченной.