Работая с Express, мы часто видим подобный код:
app.get('/user', async (req, res) => {
const user = await User.findById(req.params.id)
res.json(user)
})
Кажется чистым и простым, но здесь скрыта бомба замедленного действия. Что произойдёт, если User.findById
отклонит промис? Вы никогда не получите ответа на запрос. Клиент повиснет, а приложение начнёт накапливать необработанные промисы, что со временем приводит к утечкам памяти и нестабильности.
Почему молчит Express
Express спроектирован для синхронного кода. Его механизм промежуточного ПО (middleware) превосходно перехватывает синхронные ошибки:
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, а не баг.
Реальные последствия
-
Утечки памяти: Каждый необработанный промис сохраняет контекст выполнения (цепочку областей видимости), который не будет очищен сборщиком мусора. При высокой нагрузке это приводит к постепенному росту потребления памяти.
-
Остановленные запросы: Клиенты зависают в ожидании ответа. Для пользователя это выглядит как "вечная загрузка".
-
Нестабильность процесса: При экспоненциальном росте необработанных промисов Node.js начнёт выводить предупреждения о нехватке памяти, а в конечном итоге процесс завершится аварийно.
-
Трудная отладка: Поскольку ошибки не логируются, вы даже не поймёте источник проблемы до появления фатальных сбоев.
Надёжные решения
Вариант 1: Явная обработка с try/catch
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: Обёртка высшего порядка
Автоматизируем обработку с помощью функции-обёртки:
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. Порядок действий:
- Вызываем оригинальный обработчик, получаем промис
- При успехе — ничего дополнительного
- При ошибке — каскадный вызов
catch(next)
- Express направляет ошибку в обработчик
(err, req, res, next)
Вариант 3: Express 5+ и встроенные асинхронные ошибки
Начиная с Express 5, появилась встроенная поддержка асинхронных ошибок. При условии, что используется синтаксис async/await:
// Требуется 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
:
const asyncHandler = fn => (req, res, next) => {
// Явно преобразуем результат в промис на случай,
// если функция возвращает не-промис
return Promise.resolve(fn(req, res, next))
.catch(error => {
// Передаем все ошибки в центральный обработчик
next(error)
})
}
Критически важные особенности:
- Универсальность: Работает с функциями, возвращающими промис и обычные значения.
- Контекст: Сохраняет возможность вызова
next()
внутри обработчиков. - Составные промисы: Корректно обрабатывает вложенные асинхронные операции.
- Стабильность стека: Сохраняет трассировку стека через
Error.captureStackTrace
.
Предотвращение типовых ошибок
- Не смешивать стили:
// Антипаттерн!
app.get('/mixed', async (req, res) => {
User.findById(req.params.id, (err, user) => {
if (err) throw err // Никогда не перехватится!
res.json(user)
})
})
- Ошибки в цепочках промисов:
app.get('/chain', asyncHandler(async (req, res) => {
// Пропущенная обработка .catch()
const data = await fetchExternalData()
.then(processData) // Если здесь ошибка - утечёт
res.json(data)
}))
Исправление:
app.get('/chain', asyncHandler(async (req, res) => {
const data = await fetchExternalData()
.then(processData)
.catch(error => {
throw new DataProcessingError(error.message)
})
res.json(data)
}))
- Необработанные ошибки вне обработчиков Маршрутов:
// Ошибка в инициализации (например, подключение к БД)
mongoose.connect('mongodb://localhost/test')
setTimeout(() => {
throw new Error('Не выстрелит') // Улетит в process.on('uncaughtException')
}, 1000)
Для таких сценариев регистрируйте глобальные обработчики:
process.on('unhandledRejection', reason => {
console.error('Unhandled Rejection:', reason)
// Здесь можно выполнить graceful shutdown
})
process.on('uncaughtException', error => {
console.error('Uncaught Exception:', error)
process.exit(1)
})
Интеграция с мониторингом
Для продакшен-систем добавьте централизованную передачу метрик:
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
устраняют целый класс проблем до их появления в нагрузочном тестировании или продакшене. Затратив несколько минут на интеграцию этих практик, вы сохраните стабильность вашего приложения при масштабировании.