Часть 1: Обработка ошибок в Node.js - Фундамент и архитектура
Вы когда-нибудь просыпались в 3 часа ночи от сообщения "Сервер упал"? Я - да. И каждый раз причиной была одна и та же история: необработанная ошибка.
Эта статья - не теория из учебников. Это выжимка из реальных продакшен-проектов: от стартапов до крупного e-commerce. В первой части мы разберем фундамент обработки ошибок, без которого весь ваш код - просто пороховая бочка.
В части 1 вы узнаете:
Как разделять операционные и программистские ошибки
Как создать иерархию классов ошибок с поддержкой
error.cause(Node.js 16+)Как настроить централизованный обработчик ошибок в Express/Fastify
Почему
try/catch- это ещё не всёКак правильно работать с ресурсами через
finally
1. Разделяйте операционные и программистские ошибки
Это самое важное различие в Node.js. Если вы его не понимаете - вы будете гадать, почему сервер падает.
Тип ошибки | Пример | Действие |
|---|---|---|
Операционные (Operational Errors) | - Файл не найден - База данных не отвечает - Невалидный пользовательский ввод - Таймаут сети | Можно обработать и продолжать работу |
Программистские (Programmer Errors) | - Cannot read property of undefined - TypeError: x is not a function - ReferenceError: variable is not defined - UnhandledPromiseRejection | Немедленный краш и перезапуск |
// ❌ ПЛОХО: обрабатываем всё одинаково
try {
const user = await getUserById(id);
const profile = await getProfile(user.id); // если user null - ошибка программиста
} catch (err) {
console.log('Что-то пошло не так', err);
// Но user === null - это наша ошибка кодинга, мы НЕ должны её игнорировать!
}
// ✅ ХОРОШО: разделяем типы
try {
const user = await getUserById(id);
if (!user) {
throw new OperationalError('USER_NOT_FOUND', 'Пользователь не найден');
}
const profile = await getProfile(user.id);
} catch (err) {
if (err instanceof OperationalError) {
// Можно обработать: вернуть 404, показать уведомление
return res.status(404).json({ error: err.message });
}
// Программистская ошибка - падаем и пишем в лог
logger.fatal(err, 'Программистская ошибка');
process.exit(1); // или делегируем process.on('uncaughtException')
}Почему это важно:
Вы не будете падать на том, что можно было обработать
Вы не будете игнорировать то, что критично
Ваш мониторинг перестанет флудить ложными срабатываниями
2. Создайте иерархию классов ошибок с поддержкой error.cause
Централизованные классы ошибок - это must-have. Без них вы будете разбираться в хаосе { status, message, code }.
// errors.js
class AppError extends Error {
constructor(message, code, statusCode = 500, options = {}) {
super(message, { cause: options.cause }); // 🔥 error.cause (Node.js 16+)
this.code = code;
this.statusCode = statusCode;
this.isOperational = true;
this.correlationId = options.correlationId || null;
this.timestamp = new Date().toISOString();
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message, field, options = {}) {
super(message, 'VALIDATION_ERROR', 400, options);
this.field = field;
this.errors = options.errors || [];
}
}
class AuthenticationError extends AppError {
constructor(message = 'Необходима авторизация', options = {}) {
super(message, 'UNAUTHORIZED', 401, options);
}
}
class AuthorizationError extends AppError {
constructor(message = 'Доступ запрещён', options = {}) {
super(message, 'FORBIDDEN', 403, options);
}
}
class NotFoundError extends AppError {
constructor(resource = 'Ресурс', options = {}) {
super(`${resource} не найден`, 'NOT_FOUND', 404, options);
}
}
class DatabaseError extends AppError {
constructor(message, options = {}) {
super(message, 'DATABASE_ERROR', 503, options);
}
}
class RateLimitError extends AppError {
constructor(message = 'Превышен лимит запросов', options = {}) {
super(message, 'RATE_LIMIT', 429, options);
}
}
class ConfigError extends AppError {
constructor(message, options = {}) {
super(message, 'CONFIG_ERROR', 500, options);
this.isOperational = true;
}
}
class ExternalApiError extends AppError {
constructor(message, options = {}) {
super(message, 'EXTERNAL_API_ERROR', 503, options);
}
}
// Использование с error.cause
try {
const user = await findUser(id);
if (!user) {
throw new NotFoundError('Пользователь');
}
} catch (err) {
// Сохраняем оригинальную ошибку в cause
throw new DatabaseError('Ошибка запроса к БД', {
cause: err,
correlationId: req.correlationId
});
}Преимущества:
Все ошибки имеют единую структуру
Оригинальная ошибка сохраняется в
causeЛегко расширять новыми типами
3. Используйте централизованный обработчик ошибок
Один обработчик на всё приложение - это ключ к чистоте.
// Express
app.use((err, req, res, next) => {
// 1. Логируем ошибку с контекстом запроса
logger.error({
error: err.message,
stack: err.stack,
code: err.code,
method: req.method,
url: req.url,
ip: req.ip,
userId: req.user?.id,
correlationId: req.correlationId || err.correlationId,
body: sanitizeLog(req.body),
query: req.query,
params: req.params,
});
// 2. Если ошибка операционная - отправляем клиенту понятный ответ
if (err.isOperational) {
const response = {
success: false,
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.message,
...(err.field && { field: err.field }),
...(err.errors && { details: err.errors }),
},
timestamp: err.timestamp || new Date().toISOString(),
correlationId: req.correlationId || err.correlationId,
};
return res.status(err.statusCode || 500).json(response);
}
// 3. Если программистская - отправляем общий ответ
res.status(500).json({
success: false,
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'Внутренняя ошибка сервера',
correlationId: req.correlationId,
},
timestamp: new Date().toISOString(),
});
// 4. В программистских ошибках - падаем
// но лучше это делать через process.on('uncaughtException')
});
// Fastify
fastify.setErrorHandler((error, request, reply) => {
logger.error({
error: error.message,
stack: error.stack,
code: error.code,
method: request.method,
url: request.url,
ip: request.ip,
correlationId: request.correlationId,
});
if (error.isOperational) {
reply.status(error.statusCode || 500).send({
success: false,
error: {
code: error.code,
message: error.message,
...(error.field && { field: error.field }),
},
correlationId: request.correlationId,
timestamp: new Date().toISOString(),
});
} else {
reply.status(500).send({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Internal Server Error'
},
correlationId: request.correlationId,
timestamp: new Date().toISOString(),
});
}
});4. Никогда не скрывайте ошибки в catch без обработки
Грех 90% разработчиков.
// ❌ ПЛОХО: ошибка проглочена
try {
await criticalOperation();
} catch (err) {
// Тихо умираем... никто не узнает
}
// ❌ ПЛОХО: просто логируем и идём дальше
try {
await criticalOperation();
} catch (err) {
console.log('Ошибка, но мы идём дальше');
// А вдруг criticalOperation нужна для дальнейшего кода?
}
// ✅ ХОРОШО: либо пробрасываем, либо обрабатываем осознанно
try {
await criticalOperation();
} catch (err) {
// Вариант 1: пробрасываем выше с обогащённым контекстом
throw new DatabaseError(`Не удалось выполнить операцию: ${err.message}`, {
cause: err
});
// Вариант 2: если операция опциональна - обрабатываем и продолжаем
logger.warn('Опциональная операция не удалась, но мы продолжаем', err);
// Но тогда убедитесь, что дальнейший код может работать без неё
}5. Используйте finally для очистки ресурсов
Ресурсы должны освобождаться всегда, даже при ошибке.
let connection = null;
try {
connection = await db.connect();
const data = await connection.query(sql);
return data;
} catch (err) {
throw new DatabaseError(err.message, { cause: err });
} finally {
// Всегда закрываем, даже если ошибка
if (connection) {
await connection.close();
logger.debug('Соединение закрыто');
}
}6. Настройте глобальные обработчики uncaughtException и unhandledRejection
Это ваш последний рубеж.
// app.js
// Обработчик необработанных ошибок (синхронных)
process.on('uncaughtException', (err) => {
console.error('🔴 UNCAUGHT EXCEPTION:', err);
console.error('Stack:', err.stack);
logger.fatal({
msg: 'Uncaught Exception',
error: err.message,
stack: err.stack,
correlationId: err.correlationId || null,
});
// Даём время на закрытие соединений, затем перезапускаемся
setTimeout(() => {
process.exit(1);
}, 1000);
});
// Обработчик необработанных ошибок в промисах
process.on('unhandledRejection', (reason, promise) => {
console.error('🔴 UNHANDLED REJECTION:', reason);
console.error('Promise:', promise);
logger.fatal({
msg: 'Unhandled Rejection',
error: reason?.message || String(reason),
stack: reason?.stack,
});
// В новых версиях Node.js это будет приводить к crash
// Лучше не полагаться на это и обрабатывать всё на уровне кода
});
// Graceful shutdown при exit
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down gracefully...');
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down gracefully...');
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});7. Всегда обрабатывайте ошибки в промисах
Никогда не оставляйте промис без .catch().
// ❌ ПЛОХО: ошибка приведёт к unhandledRejection
getUser(id).then((user) => {
console.log(user);
});
// ✅ ХОРОШО: всегда есть catch
getUser(id)
.then((user) => console.log(user))
.catch((err) => {
logger.error('Ошибка получения пользователя', err);
// Можно пробросить или обработать
});
// ✅ ЕЩЁ ЛУЧШЕ: в async/await с try/catch
const user = await getUser(id).catch((err) => {
logger.error(err);
throw new NotFoundError('Пользователь', { cause: err });
});8. Используйте Promise.allSettled вместо Promise.all
// ❌ ПЛОХО: одна ошибка убивает всё
const [users, products, orders] = await Promise.all([
fetchUsers(),
fetchProducts(),
fetchOrders(),
]);
// ✅ ХОРОШО: получаем результаты всех, даже с ошибками
const results = await Promise.allSettled([
fetchUsers(),
fetchProducts(),
fetchOrders(),
]);
const users = results[0].status === 'fulfilled' ? results[0].value : [];
const products = results[1].status === 'fulfilled' ? results[1].value : [];
const orders = results[2].status === 'fulfilled' ? results[2].value : [];
// Логируем ошибки частичных неудач
results.forEach((result, index) => {
if (result.status === 'rejected') {
logger.error(`Запрос #${index} упал:`, result.reason);
}
});9. Делайте таймауты для Promise
Бесконечные промисы - это плохо.
function promiseWithTimeout(promise, ms = 5000, errorMessage = 'Таймаут') {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${errorMessage} (${ms}ms)`)), ms)
),
]);
}
// Использование
try {
const data = await promiseWithTimeout(
fetch('/api/data'),
3000,
'Долгий запрос к API'
);
} catch (err) {
if (err.message.includes('Таймаут')) {
logger.warn('Запрос занял больше 3 секунд');
}
}
// Для библиотек с нативной поддержкой
const response = await fetch('/api/data', {
signal: AbortSignal.timeout(5000),
});10. Используйте util.promisify с умом
const util = require('util');
const fs = require('fs');
// ❌ ПЛОХО: потеряли контекст ошибки
const readFile = util.promisify(fs.readFile);
// ✅ ХОРОШО: обёртка с контекстом
async function safeReadFile(path) {
try {
const content = await fs.promises.readFile(path, 'utf8');
return content;
} catch (err) {
throw new OperationalError(
`Не удалось прочитать файл ${path}`,
'FILE_READ_ERROR',
500,
{ cause: err }
);
}
}Итог части 1
Ключевые выводы:
Разделяйте операционные и программистские ошибки - фундамент всей системы
Создайте иерархию классов ошибок с поддержкой
error.causeНастройте централизованный обработчик ошибок в Express/Fastify
Никогда не глотайте ошибки в
catchбез обработкиОсвобождайте ресурсы через
finallyНастройте глобальные обработчики
uncaughtExceptionиunhandledRejectionВсегда обрабатывайте ошибки в промисах
Используйте
Promise.allSettledдля частичной устойчивостиДобавляйте таймауты для промисов
Оборачивайте старые API с сохранением контекста
Комментарии
Чтобы оставить комментарий, войдите в аккаунт.