Сломанные цепочки промисов, необработанные исключения, утечки конфиденциальной информации в production — все это следствия пренебрежения обработкой ошибок. В экосистеме Node.js, где асинхронность пронизывает каждый слой приложения, отсутствие продуманной стратегии обработки сбоев превращается в рулетку: приложение может работать неделями, но внезапно упасть из-за тривиальной ошибки в третьестепенном модуле.
Рассмотрим типичный антипаттерн в Express-приложении:
app.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id)
res.json(user)
})
При отсутствии пользователя с указанным ID метод findById
возвращает null
, и клиент получает HTTP 200 с пустым телом — семантически некорректно. Более опасный сценарий: необработанное исключение в асинхронном коде приводит к завершению всего процесса Node.js.
Архитектура ошибок: классификация и эскалация
Первичная задача — ввести систему типов ошибок, соответствующую доменной логике. Создайте иерархию классов:
class AppError extends Error {
constructor(message, httpStatusCode = 500) {
super(message)
this.isOperational = true
this.httpStatusCode = httpStatusCode
Error.captureStackTrace(this, this.constructor)
}
}
class ValidationError extends AppError {
constructor(details) {
super('Invalid input data', 400)
this.details = details
}
}
class DatabaseError extends AppError {
constructor(originalError) {
super('Database operation failed', 503)
this.originalError = originalError
}
}
Операционные ошибки (отсутствие прав, некорректный ввод) должны отделяться от программных ошибок (баги в коде). Только операционные можно безопасно возвращать клиенту. В конструкторе AppError
флаг isOperational
и метод captureStackTrace
обеспечивают правильное представление стека.
Централизованный обработчик: последняя линия обороны
Регистрируйте middleware обработки ошибок после всех роутов:
app.use((err, req, res, next) => {
if (err instanceof AppError) {
const response = {
error: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
return res.status(err.httpStatusCode).json(response)
}
console.error('FATAL ERROR:', err)
res.status(500).json({ error: 'Internal server error' })
})
Для критических ошибок (нехватка памяти, необработанные rejection) добавьте обработчики уровня процесса:
process.on('uncaughtException', (error) => {
console.error('UNCAUGHT EXCEPTION! Shutting down...', error)
process.exit(1)
})
process.on('unhandledRejection', (reason) => {
throw new DatabaseError(reason)
})
Асинхронные ловушки: оборачивание колбэков
Вместо try/catch в каждом обработчике роута используйте обертку:
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id)
if (!user) throw new AppError('User not found', 404)
res.json(user)
}))
Этот паттерн перехватывает исключения из асинхронных функций и передает их в цепочку middleware. Для сложных сценариев добавьте обработку специфических ошибок БД:
async function createUser(data) {
try {
return await User.create(data)
} catch (err) {
if (err.code === 11000) {
throw new ValidationError({ email: 'Already exists' })
}
throw new DatabaseError(err)
}
}
Логгирование с контекстом
Подключите структурированное логгирование с трейсингом запросов:
const winston = require('winston')
const { combine, timestamp, json } = winston.format
const logger = winston.createLogger({
format: combine(timestamp(), json()),
transports: [new winston.transports.Console()]
})
app.use((req, res, next) => {
req.requestId = uuid.v4()
logger.info('Request', {
requestId: req.requestId,
method: req.method,
path: req.path
})
next()
})
app.use((err, req, res, next) => {
logger.error('Error', {
requestId: req.requestId,
error: err.message,
stack: err.stack,
httpStatusCode: err.httpStatusCode
})
next(err)
})
При интеграции с системами мониторинга (Sentry, Datadog) добавляйте контекст запроса: идентификатор пользователя, параметры окружения, версию приложения.
Рефакторинг легаси: инкрементальное улучшение
Для уже существующих приложений начните с:
- Добавления middleware обработки uncaught exceptions
- Внедрения базового класса AppError в новых модулях
- Постепенного перевода колбэков на async/await с оберткой asyncHandler
- Ввода структурированного логгирования для критических путей
Избегайте глобальной переделки кода — используйте стратегию обертывания старого кода в контролируемые блоки:
// legacy-callback.js
module.exports = function legacyHandler(callback) {
// старый код с колбэками
}
// новый адаптер
const util = require('util')
const legacyHandler = util.promisify(require('./legacy-callback'))
app.get('/legacy-route', asyncHandler(async (req, res) => {
const data = await legacyHandler()
// ...
}))
Безопасность и прозрачность
Никогда не раскрывайте внутренние детали ошибок в production:
- Обрезайте стектрейсы
- Нормализуйте сообщения об ошибках БД
- Маскируете чувствительные данные в логах
Для валидации входных данных используйте библиотеки типа Zod с кастомными ошибками:
const UserSchema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
app.post('/register', asyncHandler(async (req, res) => {
const result = UserSchema.safeParse(req.body)
if (!result.success) {
throw new ValidationError(result.error.flatten())
}
// ...
}))
Обработка ошибок — не дополнительная функциональность, а основа надежной системы. Инвестиции в эту область уменьшают время восстановления после сбоев, предотвращают потерю данных и упрощают поддержку кода. Не пытайтесь охватить все возможные сценарии сразу — внедряйте практики постепенно, начиная с самых критичных участков, и постоянно анализируйте ошибки из production-окружения через системы мониторинга. Помните: хорошая система ошибок не та, где их полностью избегают, а та, где каждая ошибка становится источником информации для улучшения системы.