Обработка ошибок в JavaScript: try/catch и не только: продвинутый уровень
Обработка ошибок — это не просто защита от падения приложения, а фундаментальный аспект построения надежных и отказоустойчивых систем на JavaScript и Node.js. Базовое использование try/catch знакомо каждому разработчику, но в реальных проектах, особенно на серверной стороне, возникает множество нюансов: асинхронные потоки, цепочки промисов, глобальные сбои и необходимость в структурированных кастомных ошибках. В этой статье мы разберем продвинутые техники обработки ошибок, которые помогут вам писать более чистый и предсказуемый код на Node.js.
1. Глубокое погружение в try/catch и асинхронность
Классический try/catch синхронен. Он прекрасно ловит ошибки внутри обычных функций, но пасует перед асинхронными колбэками или промисами без await. В Node.js, где 90% кода асинхронно, это часто становится причиной "проглоченных" исключений.
Ошибка: try/catch не ловит ошибку в setTimeout
function dangerousAsync() {
setTimeout(() => {
throw new Error('Сбой в таймере!');
}, 1000);
}
try {
dangerousAsync();
} catch (e) {
console.log('Поймано:', e.message); // Этот код НЕ выполнится
}
В этом примере ошибка выбрасывается вне контекста try, и процесс Node.js аварийно завершится. Правильный подход — использовать промисы или async/await.
Решение: оборачиваем асинхронный код в Promise
function safeAsync() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Сбой в таймере!'));
}, 1000);
});
}
(async () => {
try {
await safeAsync();
} catch (e) {
console.log('Поймано:', e.message); // Выполнится через 1 секунду
}
})();
Главное правило: любую асинхронную операцию оборачивайте в промис, и используйте await внутри try/catch. Это гарантирует, что ошибка будет перехвачена в нужном контексте.
2. Кастомные классы ошибок: семантика и контекст
Вместо того чтобы выбрасывать new Error('Что-то пошло не так'), создавайте иерархию собственных классов ошибок. Это позволяет на уровне обработчика понимать тип сбоя и реагировать соответствующим образом (например, логировать, отправлять в мониторинг или возвращать пользователю определенный HTTP-статус).
class AppError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400);
}
}
class DatabaseError extends AppError {
constructor(message, originalError) {
super(message, 503);
this.originalError = originalError; // Сохраняем исходную ошибку для отладки
}
}
// Использование
function findUser(id) {
if (!id) throw new ValidationError('ID пользователя обязателен');
// ... логика, которая может выбросить DatabaseError
}
Преимущества такого подхода:
- Читаемость: имя класса сразу говорит о природе проблемы.
- Маршрутизация: в глобальном обработчике вы можете проверять
instanceofи выбирать стратегию ответа. - Дополнительные поля: вы можете добавлять любые данные (
statusCode,originalError,userIdи т.д.).
3. Глобальные обработчики ошибок в Node.js
Даже при самом тщательном контроле, необработанные ошибки могут просочиться наружу. В Node.js есть два ключевых события для их перехвата: uncaughtException и unhandledRejection.
uncaughtException — ловит синхронные ошибки, которые не были пойманы ни в одном try/catch. Важно: после вызова этого обработчика приложение находится в нестабильном состоянии. Рекомендуется залогировать ошибку и завершить процесс, а затем перезапустить его с помощью менеджера процессов (PM2, Docker).
unhandledRejection — ловит отклоненные промисы, у которых нет обработчика .catch(). В современных версиях Node.js это приводит к завершению процесса, поэтому обработчик обязателен.
process.on('uncaughtException', (err) => {
console.error('Необработанная ошибка:', err);
// Логируем в файл или внешнюю систему (Sentry, Datadog)
// Завершаем процесс, так как состояние приложения неопределенно
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Необработанный rejection промиса:', reason);
// Логируем и завершаем процесс
process.exit(1);
});
Также, если вы используете Express.js, обязательно добавьте глобальный middleware для ошибок с четырьмя параметрами (err, req, res, next):
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500;
const message = err.message || 'Внутренняя ошибка сервера';
// Логируем полный стек для разработчика
console.error(err.stack);
res.status(statusCode).json({
status: 'error',
statusCode,
message: process.env.NODE_ENV === 'production' ? 'Что-то пошло не так' : message,
});
});
4. Паттерны для работы с промисами и цепочками .catch
При работе с цепочками промисов (.then().catch()) часто возникает соблазн поставить один .catch() в конце. Это работает, но скрывает контекст ошибки. Более продвинутый подход — локализовать обработку ошибок и использовать фабрики обработчиков.
Пример: функция-обертка для асинхронных middleware в Express
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);