Обработка ошибок: uncaughtException, промисы
Урок о том, как Node-приложение должно вести себя, когда что-то идёт не так, — от try/catch в async-функциях до корректного завершения по сигналу.
Необработанная ошибка — это исключение или отклонённый промис, который никто не поймал; в Node он по умолчанию обрушивает весь процесс.
В коротком скрипте упавшая программа — мелочь: перезапустил и забыл. В сервере, который держит сотни соединений, та же ошибка означает оборванные запросы, потерянные данные и алёрты среди ночи. Поэтому в продакшене обработка ошибок — не «обвязка по краям», а часть архитектуры. Цель урока — научиться ловить ошибки там, где их можно осмысленно обработать, и спокойно завершать процесс там, где нельзя.
Зачем это нужно на практике
Node однопоточен в части вашего JS-кода. Одна непойманная ошибка в обработчике запроса не должна валить процесс целиком, но и «проглатывать» её молча нельзя — иначе вы получите сервис, который отвечает 200 OK на сломанные запросы и тихо теряет заказы. Хорошая стратегия делит ошибки на два класса: операционные (ожидаемые: нет сети, невалидный ввод, БД недоступна — их обрабатываем и отвечаем клиенту) и программные баги (разыменование undefined, опечатка — их логируем и, как правило, перезапускаем процесс).
try/catch в async-функциях
В синхронном коде try/catch очевиден. В асинхронном важно помнить: try/catch ловит ошибку только если вы await-ите проблемный вызов внутри блока. Забыли await — отклонение промиса «утечёт» мимо.
async function loadUser(id) {
try {
const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
if (!row) {
// операционная ошибка: бросаем осмысленно
throw new Error(`User ${id} not found`);
}
return row;
} catch (err) {
// здесь решаем: пробросить выше или обработать
logger.error({ err, id }, 'loadUser failed');
throw err; // пробрасываем — пусть слой выше решит, что вернуть клиенту
}
}
Типичная ошибка — обернуть в try вызов без await:
// НЕВЕРНО: промис не дождались, catch ничего не поймает
try {
loadUser(id); // забыли await
} catch (err) {
// сюда не попадём, даже если loadUser упадёт
}
unhandledRejection и uncaughtException
Это два «последних рубежа» процесса. uncaughtException срабатывает, когда синхронное исключение долетело до верха стека и его никто не поймал. unhandledRejection — когда промис отклонён, а .catch() или try/catch с await к нему так и не подвесили.
process.on('uncaughtException', (err, origin) => {
logger.fatal({ err, origin }, 'uncaughtException');
// состояние процесса уже неизвестно — корректно гасим и выходим
shutdown(1);
});
process.on('unhandledRejection', (reason) => {
logger.fatal({ reason }, 'unhandledRejection');
shutdown(1);
});
Ключевая мысль: эти обработчики — для логирования и аккуратного выхода, а не для «продолжить как ни в чём не бывало». После uncaughtException приложение в неопределённом состоянии: ресурсы могли остаться захваченными, переменные — в полупустом виде. Правильный путь — записать ошибку, закрыть соединения и завершиться, отдав перезапуск менеджеру процессов (об этом — в уроке про pm2).
С Node 15+ необработанное отклонение промиса по умолчанию тоже роняет процесс (раньше было лишь предупреждение). Это правильно: тихие отклонения опаснее громкого падения.
Почему нельзя «глотать» ошибки
«Проглотить» ошибку — это пустой catch {} или .catch(() => {}), который ничего не делает. Соблазн понятен: лог чище, тесты «зелёные». Но цена огромна:
- Скрытая порча данных. Запись в БД упала, но код пошёл дальше, будто всё хорошо.
- Невозможность отладки. Прод «иногда глючит», а в логах пусто — концов не найти.
- Ложная доступность. Сервис отвечает успехом на запросы, которые на деле не выполнились.
Минимально допустимо — залогировать и пробросить. Если ошибку действительно можно обработать (например, повторить запрос или вернуть значение по умолчанию) — обрабатывайте явно и оставляйте след в логе.
// Плохо: ошибка исчезает бесследно
await sendEmail(user).catch(() => {});
// Лучше: задеградировали осознанно и оставили след
await sendEmail(user).catch((err) => {
logger.warn({ err, userId: user.id }, 'email send failed, will retry later');
queue.enqueueRetry('email', user.id);
});
Graceful shutdown по SIGTERM
Когда оркестратор (Docker, systemd, Kubernetes) хочет остановить ваш процесс, он шлёт SIGTERM и ждёт несколько секунд, прежде чем добить SIGKILL. Грамотный сервис использует эту паузу: перестаёт принимать новые соединения, доводит текущие запросы до конца, закрывает пул БД — и только потом выходит. Иначе пользователи получат оборванные ответы при каждом деплое.
const server = app.listen(PORT);
async function shutdown(code = 0) {
logger.info('shutdown started');
// 1) перестаём принимать новые подключения
server.close(async () => {
try {
// 2) отпускаем внешние ресурсы
await db.end();
await redis.quit();
logger.info('shutdown complete');
process.exit(code);
} catch (err) {
logger.error({ err }, 'error during shutdown');
process.exit(1);
}
});
// 3) страховка: если зависли — выходим принудительно
setTimeout(() => {
logger.error('forced shutdown by timeout');
process.exit(1);
}, 10000).unref();
}
process.on('SIGTERM', () => shutdown(0));
process.on('SIGINT', () => shutdown(0)); // Ctrl+C локально
Вызов .unref() у таймера-страховки нужен, чтобы сам таймер не держал процесс живым, если всё уже закрылось штатно.
Как это работает под капотом
В основе Node — событийный цикл (event loop) поверх libuv. Ваши промисы и колбэки — задачи в очередях этого цикла. Когда промис отклоняется, движок V8 ищет привязанный обработчик отклонения; если к концу текущего «тика» его не нашлось, Node эмитит событие unhandledRejection. Синхронное исключение всплывает по стеку до C++-границы libuv — там его перехватывает Node и эмитит uncaughtException. Оба события — это сигнал «дальше работать небезопасно», поэтому платформа по умолчанию завершает процесс с ненулевым кодом. Понимание этого объясняет, почему «поймать и продолжить» в uncaughtException — антипаттерн: вы перехватываете аварию уже на C++-границе, а не там, где знаете контекст ошибки.
Сигналы (SIGTERM, SIGINT) приходят от ОС и тоже превращаются Node в события на process. Пока вы не повесили обработчик, действует поведение по умолчанию: SIGTERM завершает процесс немедленно. Подписавшись, вы берёте управление завершением на себя — и обязаны сами вызвать process.exit().
Частые ошибки
- Пустой
catch. Ошибка исчезает, баг становится невидимым. Минимум — лог и проброс. - Логика «починить и продолжить» в
uncaughtException. Процесс уже в неизвестном состоянии — нужно гасить и перезапускать, а не лечить. - Забытый
await.try/catchвокруг невызванногоawaitбесполезен; отклонение утечёт вunhandledRejection. - Жёсткий
process.exit()без graceful shutdown. Текущие запросы и запись в БД обрываются на полуслове. - Отсутствие тайм-аута на shutdown. Зависшее соединение не даёт процессу выйти, и оркестратор всё равно прибьёт его
SIGKILL— но грязно.
Итоги
- Делите ошибки на операционные (обрабатываем и отвечаем) и баги (логируем и перезапускаемся).
try/catchв async ловит только то, что выawait-ите внутри блока.uncaughtExceptionиunhandledRejection— последний рубеж: залогировать и аккуратно выйти, не «продолжать».- Глотать ошибки (
catch {}) запрещено — минимум лог и проброс. - На
SIGTERMделайте graceful shutdown: закрыть сервер, дождаться запросов, отпустить ресурсы, выйти — со страховочным тайм-аутом.