Обработка ошибок: 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: закрыть сервер, дождаться запросов, отпустить ресурсы, выйти — со страховочным тайм-аутом.
Проверьте себя
1. Что произойдёт в Node 15+ при необработанном отклонении промиса (unhandledRejection) по умолчанию?
AНичего, промис просто молча игнорируется
BВыводится предупреждение, процесс продолжает работу
CПроцесс завершается с ненулевым кодом выхода
DNode автоматически повторяет операцию
2. Почему обработчик uncaughtException не стоит использовать для логики «поймать и продолжить работу»?
AПотому что после необработанного исключения процесс в неопределённом состоянии, и продолжать небезопасно
BПотому что uncaughtException срабатывает слишком медленно
CПотому что внутри него нельзя писать логи
DПотому что он перехватывает только синтаксические ошибки
3. Что должен сделать сервер, получив сигнал SIGTERM, чтобы завершиться корректно (graceful shutdown)?
AНемедленно вызвать process.exit(0)
BПерестать принимать новые соединения, доделать текущие запросы, закрыть ресурсы и только потом выйти
CПроигнорировать сигнал и продолжить работу
DПерезапустить event loop