Разработчики регулярно сталкиваются с необъяснимыми сбоями в Node.js-приложениях: запросы "зависают", падают без логов, сервер неожиданно перезагружается. В 80% моих аудитов корень проблемы — ошибки в асинхронной обработке ошибок. Рассмотрим токсичные паттерны и современные решения на реальных примерах из production.
Антипаттерн #1: Ghost Promise
app.post('/webhook', (req, res) => {
validateRequest(req);
processPaymentAsync(req.body) // Отсутствует обработка промиса
.then(result => sendNotification(result));
res.status(200).json({ received: true });
});
Клиент получает 200 OK, но что если processPaymentAsync
упадёт? Ошибка превращается в "зомби-промис", поглощаемый event loop. Сервер не упадёт, но процессорное время и память утекают сквозь пальцы. Добавьте обработку catch
или реальное ожидание:
// Решение 1: Локальный catch
processPaymentAsync(req.body)
.catch(err => logger.error('Payment failed', err));
// Решение 2: Принудительное ожидание
const run = async () => {
try {
await processPaymentAsync(req.body);
} catch(err) {
logger.error(err);
}
};
run();
Антипаттерн #2: Слепая вложенность
fetchUserData(userId)
.then(user => {
fetchOrders(user.id)
.then(orders => {
processAnalytics(orders) // Чем глубже, тем страшнее
})
.catch(handleOrderError)
})
.catch(handleUserError) // Не перехватит ошибки из processAnalytics!
Цепочки вида .then().then().catch()
ловят ошибки только на своём уровне. Разветвлённые операции требуют другого подхода.
Протокол атомарности: Parallel, AllSettled, AggregateError
const [user, orders] = await Promise.all([
fetchUserData(userId),
fetchOrders(userId)
]);
const results = await Promise.allSettled([
processPayment(user),
updateInventory(orders),
sendNotification(user.email)
]);
const errors = results
.filter(r => r.status === 'rejected')
.map(r => r.reason);
if (errors.length) {
throw new AggregateError(errors, 'Transaction failed');
}
Используем:
Promise.all
для взаимозависимых операций (падает при первой ошибке)Promise.allSettled
для независимых задач + агрегация ошибокAggregateError
(Node 15+) для структурированных логов
Потеря контекста в async/await
async function handleRequest() {
const user = await getUser();
const orders = await getOrders(user.id); // Блокирующая последовательность
// user используется только в первом вызове!
}
Более 60% ожиданий в промис-цепочках замедляли приложения на 300 мс/RPS. Параллелизируйте независимые операции:
const [user, permissions] = await Promise.all([
getUser(),
getPermissions() // Независимые запросы
]);
const orders = await getOrders(); // Зависимый запрос
Global Catch: Последний рубеж
От словосочетания "промис не был обработан" нервно вздрагивают в чатах SRE. Рецепт для Node.js:
process.on('unhandledRejection', (reason, promise) => {
logger.fatal({
event: 'UNHANDLED_REJECTION',
reason,
promise: inspect(promise)
});
metrics.increment('crash.promise');
process.exit(1); // Failsafe
});
Но параметры имеют значение:
- Логируйте
promise
черезutil.inspect
для трассировки - Не используйте асинхронные операции в обработчике
- Добавьте метрику для оповещения DevOps
Что не может ждать: AbortController
Длинный запрос → User закрыл вкладку → Бэкенд продолжает работать. Решение:
const controller = new AbortController();
app.get('/report', async (req, res) => {
const { signal } = controller;
try {
const report = await generateReport({
signal, // Пробрасываем в глубину
timeout: 5000
});
res.json(report);
} catch (err) {
if (err.name === 'AbortError') return;
handleError(err);
}
});
// При разрыве соединения
req.on('close', () => controller.abort());
Поддерживается в Node.js (с 15.0.0), fetch, axios, Postgres и большинстве ORM. Чек-лист:
- Пробрасывайте
signal
через все уровни приложения - Выбрасывайте
AbortError
при отмене - Тестируйте прерывание через TCP RST (
kill -SIGPIPE
)
Парадокс обращения с ошибками
- 82% ошибок падают в механизме обработки ошибок
- try/catch увеличивает стоимость рефакторинга
- Промисы скрывают реальный источник исключений
Решение: Типизированные ошибки с контекстом.
class DatabaseError extends Error {
constructor(query, params, originalError) {
super(`Query failed: ${originalError.message}`);
this.query = query; // Добавили контекст
this.params = params; // для диагностики
this.cause = originalError; // Стандарт Error Cause
}
}
try {
await db.query('SELECT * FROM users WHERE id = $1', [userId]);
} catch (err) {
throw new DatabaseError('SELECT users', [userId], err);
}
Создайте базовый класс AppError
с:
- Машиночитаемым
code
(например,INVALID_TOKEN
) - Поддержкой
cause
(Node 16.9+) - Логированием с помощью
req.request_id
Контрольный список перед продакшн-деплоем
- Включили
"type": "module"
для синхронных top-level ошибок в ES-модулях? - Запретили
process.exit()
в мидлварях с помощью ESLint? - Вынесли асинхронные инициализации (DB connect) за пределы event loop с помощью топ-левел await?
- Заменили глубокие вложенные
.then()
наasync/await
+Promise.allSettled
? - Протестировали прерывание запросов через
abortController
? - Проверили мониторинг на события
unhandledRejection
?
Оптимизированная обработка асинхронных ошибок снижает риск инцидентов типа "нужно перезапустить весь кластер". Помните: независимо от уровня вложенности промиса — вы должны точно знать, где он исчезнет. Лучший нюанс, который я усвоил: если вы думаете "этот throw никто не перехватит", значит не надо спать до утра.