Асинхронные ошибки в Express: Ликвидируем утечки памяти и зависшие промисы

Работая с Express, мы часто видим подобный код:

javascript
app.get('/user', async (req, res) => {
  const user = await User.findById(req.params.id)
  res.json(user)
})

Кажется чистым и простым, но здесь скрыта бомба замедленного действия. Что произойдёт, если User.findById отклонит промис? Вы никогда не получите ответа на запрос. Клиент повиснет, а приложение начнёт накапливать необработанные промисы, что со временем приводит к утечкам памяти и нестабильности.

Почему молчит Express

Express спроектирован для синхронного кода. Его механизм промежуточного ПО (middleware) превосходно перехватывает синхронные ошибки:

javascript
app.get('/sync-error', (req, res) => {
  throw new Error('Всё под контролем')
})

// Обычный обработчик ошибок
app.use((err, req, res, next) => {
  console.error(err)
  res.status(500).send('Internal Server Error')
})

Но когда ошибка возникает в асинхронной функции, она выпадает из контекста выполнения Express. Вы не получите сообщения об ошибке, не сработает обработчик, а соединение зависнет на ожидании ответа.

Фундаментальная проблема: для асинхронных операций нет автоматической передачи управления потоку ошибок Express. Это особенность архитектуры Node.js и Express, а не баг.

Реальные последствия

  1. Утечки памяти: Каждый необработанный промис сохраняет контекст выполнения (цепочку областей видимости), который не будет очищен сборщиком мусора. При высокой нагрузке это приводит к постепенному росту потребления памяти.

  2. Остановленные запросы: Клиенты зависают в ожидании ответа. Для пользователя это выглядит как "вечная загрузка".

  3. Нестабильность процесса: При экспоненциальном росте необработанных промисов Node.js начнёт выводить предупреждения о нехватке памяти, а в конечном итоге процесс завершится аварийно.

  4. Трудная отладка: Поскольку ошибки не логируются, вы даже не поймёте источник проблемы до появления фатальных сбоев.

Надёжные решения

Вариант 1: Явная обработка с try/catch

javascript
app.get('/user', async (req, res) => {
  try {
    const user = await User.findById(req.params.id)
    if (!user) throw new NotFoundError('User not found')
    res.json(user)
  } catch (err) {
    next(err) // Передаем ошибку в централизованный обработчик
  }
})

Это рабочее решение, но оно требует дублирования блока try/catch в каждом обработчике-абстракции, которую можно создать.

Вариант 2: Обёртка высшего порядка

Автоматизируем обработку с помощью функции-обёртки:

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

app.get('/user', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id)
  res.json(user)
}))

Здесь asyncHandler преобразует отклонённый промис в вызов next(err), интегрируя асинхронную ошибку в стандартный поток Express. Порядок действий:

  1. Вызываем оригинальный обработчик, получаем промис
  2. При успехе — ничего дополнительного
  3. При ошибке — каскадный вызов catch(next)
  4. Express направляет ошибку в обработчик (err, req, res, next)

Вариант 3: Express 5+ и встроенные асинхронные ошибки

Начиная с Express 5, появилась встроенная поддержка асинхронных ошибок. При условии, что используется синтаксис async/await:

javascript
// Требуется Express 5 и выше
app.get('/user', async (req, res, next) => {
  const user = await User.findById(req.params.id)
  res.json(user)
})

Внутренняя реализация Express автоматически оборачивает промисы и передаёт ошибки в next(). Однако до выхода стабильной версии Express 5 этот механизм следует внедрять своими силами.

Глубже в реализацию

Рассмотрим детали нашей обёртки asyncHandler:

javascript
const asyncHandler = fn => (req, res, next) => {
  // Явно преобразуем результат в промис на случай,
  // если функция возвращает не-промис
  return Promise.resolve(fn(req, res, next))
    .catch(error => {
      // Передаем все ошибки в центральный обработчик
      next(error)
    })
}

Критически важные особенности:

  1. Универсальность: Работает с функциями, возвращающими промис и обычные значения.
  2. Контекст: Сохраняет возможность вызова next() внутри обработчиков.
  3. Составные промисы: Корректно обрабатывает вложенные асинхронные операции.
  4. Стабильность стека: Сохраняет трассировку стека через Error.captureStackTrace.

Предотвращение типовых ошибок

  1. Не смешивать стили:
javascript
// Антипаттерн!
app.get('/mixed', async (req, res) => {
  User.findById(req.params.id, (err, user) => {
    if (err) throw err // Никогда не перехватится!
    res.json(user)
  })
})
  1. Ошибки в цепочках промисов:
javascript
app.get('/chain', asyncHandler(async (req, res) => {
  // Пропущенная обработка .catch()
  const data = await fetchExternalData()
    .then(processData) // Если здесь ошибка - утечёт

  res.json(data)
}))

Исправление:

javascript
app.get('/chain', asyncHandler(async (req, res) => {
  const data = await fetchExternalData()
    .then(processData)
    .catch(error => {
      throw new DataProcessingError(error.message)
    })

  res.json(data)
}))
  1. Необработанные ошибки вне обработчиков Маршрутов:
javascript
// Ошибка в инициализации (например, подключение к БД)
mongoose.connect('mongodb://localhost/test')

setTimeout(() => {
  throw new Error('Не выстрелит') // Улетит в process.on('uncaughtException')
}, 1000)

Для таких сценариев регистрируйте глобальные обработчики:

javascript
process.on('unhandledRejection', reason => {
  console.error('Unhandled Rejection:', reason)
  // Здесь можно выполнить graceful shutdown
})

process.on('uncaughtException', error => {
  console.error('Uncaught Exception:', error)
  process.exit(1)
})

Интеграция с мониторингом

Для продакшен-систем добавьте централизованную передачу метрик:

javascript
app.use((err, req, res, next) => {
  // Логирование ошибки
  logger.error(err)

  // Отправка метрик в Prometheus/DataDog
  metrics.increment('express.errors', {
    type: err.constructor.name,
    route: req.path
  })

  // Стандартизованный ответ
  res.status(err.statusCode || 500).json({
    error: process.env.NODE_ENV === 'development' ? 
      err.message : 'Internal Server Error'
  })
})

Такой подход даёт прозрачность: вы видите частоту ошибок по типам и маршрутам, а в процессе разработки получаете детальные сообщения.

Производительность и future-proof решения

Волновает ли вас накладные расходы на промисы и обёртки? В реальных приложениях стоимость обработки ошибок пренебрежимо мала по сравнению с операциями ввода-вывода — типичное отклонение менее 3%.

Для Express 4 и более ранних:

  • Используйте asyncHandler для всех асинхронных маршрутов
  • Любую функцию с await, Promise или колбэками оборачивайте

Для Express 5+:

  • Сохраняйте совместимость со стилем async/await
  • Всегда добавляйте аргумент next даже без использования
  • Не полагайтесь на "магию" - тестируйте обработку ошибок

Стоит мониторить и использовать все доступные технические средства: в Node.js 15+ есть возможность активировать abort controllers для прерывания зависших запросов.

Итог: Безопасная асинхронность

Обработка асинхронных ошибок в Express — не необязательная добавка, а критическая основа стабильного приложения.

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

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

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