Ситуация знакомая: сервер падает без логов, пользователи видят 500 ошибки, а вы часами ищете причину. В Node.js ошибки иногда проглатываются с пугающей тишиной. Проблема не в самих исключениях — они естественны, — а в их скрытом распространении через асинхронный код. Приведу пример классической ловушки:
app.get('/user/:id', async (req, res) => {
const user = await User.findById(req.params.id) // Ошибка доступа к базе?
res.json(user)
})
При сбое базы данных здесь произойдёт:
- Promise от
User.findById
отклонится - Ошибка "провалится" через стек вызовов
- Express по умолчанию отправит HTTP 500 без деталей
- В консоль ничего не попадёт
Такое поведение — катализатор для сбоев в продакшене. Разберёмся, как его избежать.
Разделяй и властвуй: классификация ошибок
Первое: чёткое разделение ошибок критично для обработки.
Программистские ошибки:
- Баги в коде (синтаксис, ссылки на
undefined
) - Асинхронные исключения без обработчиков
- Нарушенные контракты (неверные аргументы)
- Лечение: исправление кода. Запуск приложения невозможен.
Операционные ошибки:
- Сбои инфраструктуры (база данных, сеть)
- Ошибочные входные данные
- Ресурсные проблемы (таблицы файлов, память)
- Лечение: логирование, повторные попытки, изящное завершение.
Проблема начинается, когда мы путаем эти типы или позволяем операционным стать программистскими из-за неправильной обработки.
Асинхронный Вайоминг: где теряются ошибки
Главные зоны риска в Node.js:
1. Event Emitters (без обработчика error
):
const fs = require('fs')
const stream = fs.createReadStream('несуществующий-файл')
stream.pipe(res) // Упадёт нативным исключением, убив процесс
Решение: всегда добавляйте обработчики error
:
stream.on('error', err => {
console.error('Ошибка потока:', err)
res.status(500).end()
})
2. Промисы без catch
и необработанные асинхронные исключения:
app.post('/data', (req, res) => {
saveDataAsync() // Unhandled promise rejection!
res.status(202).json({ status: 'accepted' })
})
Решение: используйте middleware для асинхронных контроллеров:
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. Забытые колбэки:
function legacyCode(callback) {
doSomethingAsync((err) => { // Пропущен вызов callback при ошибке
if (err) console.error(err) // Колбэк никогда не вызывается!
callback(null, result)
})
}
Архитектурный щит: централизованная обработка
Создайте middleware-фильтр ошибок. В Express:
// Последний 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:
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
})
Превентивные меры: как не попадать в ловушки
-
Стилевой соглашения:
- Колбэки: первый аргумент всегда
err
- Промисы: возвращайте цепочки из middleware
- Async/await: оборачивайте в
try/catch
или используйте.catch()
- Колбэки: первый аргумент всегда
-
Контрактное программирование:
javascriptfunction createUser(data) { assert.object(data, 'data must be object') assert.string(data.email, 'email required') // ... }
-
Наследование для ошибок:
javascriptclass DatabaseError extends Error { constructor(message, query) { super(message) this.query = query this.name = 'DatabaseError' } } // Бросайте через throw new DatabaseError('Connection reset', { sql: 'SELECT ...' })
-
Инструменты:
- Используйте
node --unhandled-rejections=strict
- Добавьте обработчики
uncaughtException
иunhandledRejection
- Используйте
Когда node неумолим: фатальные ошибки
Некоторые ошибки неизбежно вызовут падение процесса:
- Подвисшая очередь событий (event loop drained) を与えた
- Двойной
callback()
- Исключения вне асинхронных стеков (таймеры,
process.nextTick
)
Решение: используйте кластеры для изоляции сбоев и процесс-надсмотрщик (PM2, Kubernetes), с тайм-аутом для изящного завершения:
process.on('uncaughtException', (err) => {
logger.fatal(err, 'Неконтролируемое падение')
setTimeout(() => process.exit(1), 500) // Дать время на логирование
})
Заключение
Грамотная обработка ошибок в Node.js строится на:
- Категоризации сбоев на операционные/программистские
- Гарантированном перехвате асинхронных исключений
- Централизованной обработке через архитектурные шаблоны
- Детализированном логировании с контекстом
Последний совет: моделируйте отказы. Используйте библиотеку типа node -r ./chaos.js
со случайными сбоями БД в dev-среде. Как видите, ошибки — не препятствия, а система тонких сигналов, которые при правильной интерпретации, повышают отказоустойчивость приложения. Учитесь инженерной дисциплине с тыловым прикрытием — тогда не одна ошибка не пройдёт незамеченной.