open atlas
↑ К треку
Node.js с нуля до senior NODE · 03 · 01

Обработка ошибок: throw, reject и не глотать

Ошибка — это объект со stack и code, а не строка. Подбирай throw, reject или error-first callback под стиль вызова, цепляй исходную через cause и никогда не глотай reject — необработанный по умолчанию роняет процесс.

NODE Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Платёжный сервис три недели писал в лог Error: [object Object]. Обработчик делал catch (e) { logger.error("payment failed: " + e) }, а e был зареджекченным fetch, обёрнутым библиотекой, — конкатенация строк сплющила его в ничто: ни стека, ни кода статуса, ни сообщения сверху. Баг, вызывавший сбои, был однострочной ошибкой в настройке таймаута, но он оставался невидимым, потому что ошибку выбросили в первом же catch. Команде не нужен был ещё один инструмент мониторинга. Они уничтожали тот самый объект, который уже нёс ответ. Через несколько минут ты поймёшь, почему именно это происходит, и как это не допустить.

Ошибка — это объект, а не строка

Когда в Node что-то падает, полезный артефакт — это объект Error, и почти каждый баг в обработке ошибок идёт от обращения с ним как со строкой. У Error есть message (человеческая строка), name ("Error", "TypeError", …) и stack — захваченная трасса того, где он создан, самое ценное поле для отладки. Node добавляет ещё одно, которого нет в вебе: системные и библиотечные ошибки несут стабильную строку code вроде ENOENT, ECONNREFUSED или ERR_INVALID_ARG_TYPE. Ветвись по code, а не по message — сообщения это проза и меняются между версиями; коды — это API.

import { readFile } from "node:fs/promises";

try {
  await readFile("/etc/missing.conf");
} catch (err) {
  console.log(err instanceof Error); // true
  console.log(err.code);             // "ENOENT"  ← ветвись по этому
  console.log(err.message);          // "ENOENT: no such file..."  ← логируй, не парси
}

В тот момент, когда ты делаешь "failed: " + err или JSON.stringify(err), ты его теряешь: конкатенация вызывает toString() и даёт "Error: message" без стека, а JSON.stringify возвращает {}, потому что message и stack — non-enumerable. Логируй сам объект (logger.error({ err }) или console.error(err)), чтобы стек уцелел. Это ровно баг из хука: Error был настоящим и полным, а оператор + выбросил всё, кроме бесполезного префикса.

throw vs reject vs error-first callbacks

В Node три пути, которыми движется сбой, и они не взаимозаменяемы — каждый привязан к стилю вызова, и их смешение — главный источник проглоченных ошибок.

Стиль вызоваКак всплывает сбойКак поймать
Синхронныйthrow errtry / catch (тот же tick)
Promise / asyncвозвращает зареджекченный promiseawait в try/catch или .catch()
Callback (старые core API)cb(err, data) — err это арг 1сначала проверь if (err), потом return

Ловушка в том, что try/catch ловит только синхронный throw в том же tick. throw внутри асинхронного колбэка целиком ускользает из обрамляющего try — к моменту его выполнения блок try уже завершился. Точно так же вызов async-функции без await (или .catch) означает, что её reject твой try никогда не увидит. Правило: внутри async-кода await-ай всё, от чего зависишь, чтобы reject стал ловимым throw; в error-first колбэках обрабатывай err на первой строке и делай return, чтобы не провалиться в ветку успеха с data равным undefined.

// throw ускользает — колбэк бежит после выхода из try
try {
  setTimeout(() => { throw new Error("boom"); }, 10); // станет uncaughtException
} catch (e) { /* никогда не выполнится */ }

// reject промиса по await становится ловимым throw
try {
  await fetchUser(id); // reject → ловится здесь
} catch (e) { /* выполнится */ }

// error-first: сначала проверь err, потом return
db.query(sql, (err, rows) => {
  if (err) return cb(err);   // останься здесь при сбое
  cb(null, transform(rows));
});
Почему это работает

EventEmitter (диспетчер событий из node:events) — это четвёртый случай, самый острый. Если эмиттер генерит событие 'error', а слушателя 'error' нет, Node не глотает его молча — он бросает ошибку, которая становится uncaughtException и роняет процесс. Это намеренно: поток или сокет, падающий без слушателя, — это баг. Всегда вешай обработчик 'error' на потоки, сокеты и любой эмиттер, который может упасть (stream.on("error", …)), даже если он только логирует.

cause и кастомные классы ошибок

Поймать ошибку, чтобы добавить контекст, — хорошо; заменить её на более размытую — так теряют стек. С Node 16.9 стандартное решение — опция cause (параметр, позволяющий прицепить исходную ошибку к новой в виде цепочки): бросить новую, более высокоуровневую ошибку, прицепив исходную снизу, чтобы трасса осталась целой.

class ConfigError extends Error {
  constructor(message, options) {
    super(message, options);   // прокидывает { cause }
    this.name = "ConfigError"; // чтобы логи и instanceof читались верно
  }
}

try {
  await readFile(path);
} catch (err) {
  // обернуть с контекстом, сохранив исходный ENOENT и его стек как .cause
  throw new ConfigError(`cannot load config at ${path}`, { cause: err });
}

Здесь живут две сеньорские привычки. Первая — задавай this.name в кастомном классе: без него имя класса теряется в сериализованных логах, а err.name читается как "Error". Вторая — заведи небольшой набор типизированных ошибок (ValidationError, NotFoundError, ConfigError), по которым вызывающий ветвится через instanceof или свойство code, вместо парсинга сообщений. Современный логгер или util.inspect печатает всю цепочку cause автоматически, так что обёртка ничего не стоит и покупает тебе полный путь от симптома до корня.

Выбери лучший вариант

Функция репозитория ловит низкоуровневый ECONNREFUSED от драйвера БД и хочет, чтобы слой API показал чистую ошибку, не потеряв корневую причину. Что она бросает?

Сеть уровня процесса: uncaughtException и unhandledRejection

Некоторые ошибки ускользают от всех локальных обработчиков — throw в случайном колбэке, промис, который никто не заавейтил. Node всплывает их на объекте process, и то, как ты с ними обходишься, отделяет надёжный сервис от зомби.

Необработанный reject промиса — зареджекченный промис без .catch или await где-либо — генерит process.on("unhandledRejection"), и с Node 15 действие по умолчанию — напечатать ошибку и завершить процесс (код выхода 1). Это правильный дефолт: reject, который ты забыл обработать, — это баг, и продолжать в неизвестном состоянии — значит его прятать. Не «чини» это регистрацией обработчика, который просто логирует и глотает, — это пересоздаёт проблему тихого сбоя на глобальном уровне.

Непойманное синхронное исключение генерит process.on("uncaughtException"). Критичное, контринтуитивное правило из доков Node: после uncaughtException процесс в неопределённом состоянии, и нельзя возобновлять нормальную работу. Обработчик — только для последних обрядов: сбросить логи, отправить метрику, может, закрыть соединения — и затем process.exit(1). Дай супервизору (systemd, Kubernetes, PM2) перезапустить чистый процесс. Паттерн ниже — production-форма: тонкая верхнеуровневая сеть, которая фиксирует и выходит, в паре с дисциплинированной локальной обработкой всюду в остальном коде.

process.on("unhandledRejection", (reason) => {
  console.error("unhandledRejection:", reason); // Error или любое значение
  process.exit(1);
});
process.on("uncaughtException", (err) => {
  console.error("uncaughtException:", err);
  // состояние небезопасно — зафиксируй, потом умри. НЕ продолжай обслуживать.
  process.exit(1);
});
Викторина

Почему try { setTimeout(() => { throw new Error('x') }) } catch (e) {} НЕ ловит ошибку?

Викторина

Необработанный reject промиса происходит в современном Node-сервисе без зарегистрированного обработчика unhandledRejection. Что по умолчанию?

Расставь шаги по порядку

Расставь жизненный цикл хорошо обработанного сбоя — от истока до чистого восстановления:

  1. 1 Низкоуровневая операция падает и создаёт Error со стеком и кодом (например, ENOENT)
  2. 2 Ближайший слой, у которого есть контекст, её ловит
  3. 3 Он перебрасывает типизированную ошибку с { cause: original }, добавляя смысл без потери стека
  4. 4 Граница (обработчик запроса / раннер задач) ловит типизированную ошибку и мапит в ответ или ретрай
  5. 5 Всё, что всё равно ускользнуло, попадает в сеть процесса, которая логирует и выходит для перезапуска супервизором
Вспомните перед уходом
  1. 01
    Почему конкатенация ошибки в строку (или JSON.stringify над ней) — это баг, и что делать вместо этого?
  2. 02
    Какая правильная реакция на uncaughtException и почему она отличается от обычного catch?
Итог

В Node единица сбоя — это объект Error, и большинство багов обработки ошибок на самом деле баги уничтожения данных: конкатенация или JSON.stringify над ошибкой выбрасывает stack, code и цепочку cause, поэтому логируй сам объект и ветвись по стабильному code, а не по прозаичному message. Сбой движется тремя путями, привязанными к трём стилям вызова — синхронный throw, ловимый try/catch в том же tick; reject промиса, который надо await (или .catch), чтобы он стал ловимым; и error-first cb(err, data), который проверяешь и return-ишь на первой строке, — плюс событие 'error' у EventEmitter, которое роняет процесс, если никто не слушает. Когда ловишь, чтобы добавить контекст, перебрасывай типизированную ошибку с { cause: original } и задавай this.name, чтобы получить ветвимый тип без потери трассы. И проектируй глобальную сеть намеренно: необработанный reject роняет процесс по умолчанию с Node 15, а после uncaughtException состояние процесса неопределённо — залогируй, потом process.exit(1) и дай супервизору перезапустить чистый, вместо того чтобы проглотить сбой и обслуживать из испорченного состояния. Теперь, когда увидишь [object Object] в логе или зареджекченный промис, который тихо пропал, — ты сразу знаешь, какое из этих правил нарушено и где чинить.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.