Рассмотрим сценарий: ваше Node.js-приложение падает в продакшене с ошибкой UnhandledPromiseRejection
. Логи показывают, что кто-то не обработал отклоненный промис, но в коде повсюду расставлены try/catch
и .catch()
. Знакомо? Современный JavaScript дает мощные инструменты для работы с асинхронностью, но ошибки в их использовании остаются одними из самых коварных.
Почему промисы «протекают»
Распространенная иллюзия безопасности возникает при цепочечном синтаксисе:
fetchData()
.then(validate)
.then(process)
.catch(logError); // Магический спасатель
Но что если validate
выбросит синхронную ошибку? В классических промисах это приводит к неотловленному исключению. Современные движки автоматически превращают синхронные ошибки в отклоненные промисы, но только в теле промис-колбэка. Рассмотрим опасный фрагмент:
const unstablePromise = new Promise(() => {
throw new Error('Синхронная бомба');
});
// Сработает .catch?
unstablePromise.catch(console.error); // Не сработает: промис сразу rejected
Здесь .catch()
регистрируется уже после отклонения промиса. Решение — всегда оборачивать инициализацию промисов в try/catch
или использовать async
функции, которые автоматически оборачивают код в промис.
Асинхронные пробелы в async/await
Переход на async/await
создает ложное чувство защищенности. Типичная ловушка:
async function processOrder(orderId) {
try {
const order = await fetchOrder(orderId);
sendConfirmation(order.user.email); // Синхронная функция
} catch (e) {
// Поймает ли ошибку из sendConfirmation?
}
}
Если sendConfirmation
выбросит синхронную ошибку, catch
ее перехватит — await
отсутствует, но весь код внутри async
функции выполняется в промисном контексте. Однако другая ситуация возникает при параллельных операциях:
async function updateDashboard() {
try {
const [user, orders] = await Promise.all([
fetchUser(), // resolved
fetchOrders() // rejected
]);
renderUI(user); // Не выполнится
} catch (e) {
// Поймает ошибку из fetchOrders
}
}
При использовании Promise.all()
первая ошибка в массиве промисов приводит к немедленному отклонению всей группы. Но что если нужно обработать частичные результаты?
Паттерн обработки для сложных сценариев
Для задач, где критически важна обработка всех подопераций, даже с ошибками, используйте Promise.allSettled
с фильтрацией:
async function robustDataLoader() {
const results = await Promise.allSettled([
fetchMetrics(),
fetchUserData(),
fetchThirdPartyService()
]);
const errors = results
.filter(r => r.status === 'rejected')
.map(r => r.reason);
if (errors.length > 0) {
await logToSentry(errors); // Отправка всех ошибок
}
return results.map(r =>
r.status === 'fulfilled' ? r.value : fallbackData()
);
}
Этот подход гарантирует, что:
- Все операции завершаются (успешно или нет)
- Ошибки централизованно логируются
- Клиент получает данные или fallback-значения
Контекстные ошибки: Больше чем message
Стандартный Error
часто недостаточен для отладки. Создавайте доменно-специфичные классы:
class DatabaseError extends Error {
constructor(query, params, originalError) {
super(`DB failure: ${originalError.message}`);
this.query = query;
this.params = params; // Чувствительные данные должны быть обезличены
this.code = originalError.code;
}
}
async function queryDB(sql, params) {
try {
return await pool.query(sql, params);
} catch (e) {
throw new DatabaseError(sql, params, e);
}
}
В обработчике ошибок верхнего уровня:
process.on('unhandledRejection', (reason) => {
if (reason instanceof DatabaseError) {
metrics.increment('db.failure');
logger.error('Database failure', {
query: redactSensitive(reason.query),
code: reason.code
});
}
process.exit(1);
});
Неочевидные источники утечек
Обработка ошибок — это не только try/catch
. Рассмотрим пример из Express:
app.get('/api/data', async (req, res) => {
const data = await fetchData();
res.json(data);
}); // Нет next, нет catch
Любая ошибка в fetchData
приведет к необработанному отклонению промиса. Решение — middleware для обертки:
function asyncHandler(fn) {
return (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
}
app.get('/api/data', asyncHandler(async (req, res) => {
const data = await fetchData();
res.json(data);
}));
Инструменты принудительной дисциплины
- ESLint правилами:
{
"rules": {
"no-floating-promises": "error",
"require-await": "warn"
}
}
- Node.js флаги:
node --unhandled-rejections=strict app.js
- Нативные модули: Использование
async_hooks
для трейсинга незавершенных операций.
Главная мысль: в асинхронном JavaScript обработка ошибок не должна быть догадкой. Она требует явной архитектуры — от проектирования отдельных функций до настройки глобальных обработчиков. Паттерн, который работает: всегда предполагайте, что любая асинхронная операция может завершиться ошибкой, и предусматривайте пути восстановления, а не только логирование.