Ошибку обрабатывают там, где о ней есть что сказать: низкоуровневый код бросает конкретную ошибку своего класса и сохраняет исходную в cause, а граница — обработчик запроса, экран, команда — ловит её, пишет подробности в лог и показывает пользователю короткое сообщение о том, что делать. В async-коде каждый промис должен быть дождан через await или иметь .catch(): в Node.js необработанное отклонение по умолчанию завершает процесс. Пустой catch, который глотает ошибку, хуже падения.
После материала вы сможете
- обработать синхронную и асинхронную ошибку через try/catch/finally и перебросить ту, которую код не умеет обработать
- создать свои классы ошибок и обернуть исходную ошибку через Error.cause, не теряя её
- объяснить, почему «висящий» промис роняет процесс Node.js, и найти такой промис по выводу
- разделить сообщение пользователю и запись в лог так, чтобы технические детали не уходили наружу
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)JavaScript (ES2022+: Error.cause)
Сверено с документацией: 11 октября 2026 г.
Содержание
Ошибка как значение и как исключение
В JavaScript есть два способа сообщить, что что-то пошло не так. Исключение (exception) прерывает выполнение: throw выбрасывает значение, и оно «всплывает» по стеку вызовов, пока его не поймает ближайший catch. Если не поймает никто, программа падает — в уроке об отладке мы видели, как это выглядит. Второй способ — вернуть ошибку как обычное значение, например объект { ok: false, error }, и оставить решение вызывающему коду.
export function safeParseJson(text) {
try {
return { ok: true, value: JSON.parse(text) };
} catch (error) {
return { ok: false, error };
}
}
const results = ['{"qty": 2}', "{qty: 2}"].map(safeParseJson);
for (const result of results) {
if (result.ok) console.log("value:", result.value);
else console.log("invalid JSON:", result.error.message);
}$ node safe-parse.mjs
value: { qty: 2 }
invalid JSON: Expected property name or '}' in JSON at position 1 (line 1 column 2)Вернуть значение { ok, error }
- Когда подходит
- Ошибка — ожидаемый исход: пользователь ввёл неверные данные, файла может не быть
- Риск
- Результат забыли проверить, и ошибка прошла дальше
Бросить исключение
- Когда подходит
- Функция не может выполнить свою работу, и решать должен кто-то выше по стеку
- Риск
- Исключение никто не поймал, и программа упала
Встроенные API JavaScript и Node.js в основном бросают исключения, поэтому дальше речь в основном о них. Бросать стоит объект Error или его наследника, а не строку: только у Error есть stack, name и cause.
throw, try/catch/finally
try содержит код, который может бросить исключение; catch получает брошенное значение; finally выполняется всегда — после успешного try, после catch и даже если внутри был return. В JavaScript нет типизированных catch, как в Java, поэтому тип проверяют сами через instanceof и перебрасывают всё, что не умеют обработать:
function parseQty(input) {
const qty = Number(input);
if (!Number.isInteger(qty) || qty < 1) {
throw new RangeError(`qty must be a positive integer, got "${input}"`);
}
return qty;
}
for (const input of ["2", "0", "abc"]) {
try {
console.log("try:", parseQty(input));
} catch (error) {
if (!(error instanceof RangeError)) throw error;
console.log("catch:", error.name, "-", error.message);
} finally {
console.log("finally: input", JSON.stringify(input), "checked");
}
}$ node basics.mjs
try: 2
finally: input "2" checked
catch: RangeError - qty must be a positive integer, got "0"
finally: input "0" checked
catch: RangeError - qty must be a positive integer, got "abc"
finally: input "abc" checkedfinally— место для уборки: закрыть файл или соединение, выключить индикатор загрузки, снять блокировку кнопки.- Не пишите
returnилиthrowвнутриfinally: MDN предупреждает, что они перекрывают результатtryиcatch, и брошенная ошибка может молча исчезнуть. - Если значение ошибки не нужно, используйте
catch { … }без скобок — но только когда вы действительно знаете, что произошло (как вsafeParseJson). - Пойманная ошибка обрабатывается один раз — ближайшим
catch. Чтобы её увидел и внешний обработчик, бросьте её снова:throw error.
Свои классы ошибок и Error.cause
Встроенные TypeError или SyntaxError говорят, что сломалось технически, но не говорят, что это значит для приложения. Свои классы ошибок дают обработчику опору для решения: «нет связи — предложить повторить», «сервер ответил 500 — показать заглушку». MDN рекомендует наследоваться от Error и проверять тип через instanceof. Строка this.name = new.target.name записывает в name имя реального класса — оно попадёт в лог и в stack trace.
export class AppError extends Error {
constructor(message, options) {
super(message, options);
this.name = new.target.name;
}
}
export class NetworkError extends AppError {}
export class HttpError extends AppError {
constructor(status, url, options) {
super(`HTTP ${status} for ${url}`, options);
this.status = status;
}
}
export class DataError extends AppError {}Второй аргумент конструктора Error — объект опций с полем cause. Он нужен, когда вы ловите низкоуровневую ошибку и бросаете вместо неё понятную: исходная не теряется и остаётся доступна в error.cause. Так устроена функция загрузки меню кофейни — каждый способ сломаться превращается в свой класс:
import { DataError, HttpError, NetworkError } from "./errors.mjs";
export async function loadMenu(url) {
let res;
try {
res = await fetch(url, { signal: AbortSignal.timeout(3000) });
} catch (error) {
throw new NetworkError(`Request to ${url} failed`, { cause: error });
}
if (!res.ok) throw new HttpError(res.status, url);
let data;
try {
data = await res.json();
} catch (error) {
throw new DataError(`Response from ${url} is not JSON`, { cause: error });
}
if (!Array.isArray(data.items)) {
throw new DataError(`Response from ${url} has no "items" array`);
}
return data.items;
}Цепочку причин можно пройти циклом. Запрос к порту, на котором никто не слушает, даёт три уровня: наша NetworkError, TypeError: fetch failed из fetch и системная ошибка с кодом ECONNREFUSED:
import { loadMenu } from "./load-menu.mjs";
try {
await loadMenu("http://127.0.0.1:8082/menu");
} catch (error) {
let current = error;
while (current) {
console.log(`${current.name}: ${current.message}${current.code ? ` (${current.code})` : ""}`);
current = current.cause;
}
}$ node cause-chain.mjs
NetworkError: Request to http://127.0.0.1:8082/menu failed
TypeError: fetch failed
Error: connect ECONNREFUSED 127.0.0.1:8082 (ECONNREFUSED)Ошибки в промисах и async/await
Внутри async-функции throw превращается в отклонённый промис (rejected promise). await разворачивает его обратно в исключение, поэтому обычный try/catch вокруг await ловит асинхронные ошибки так же, как синхронные. Проблемы начинаются, когда промис никто не ждёт. Функция ниже запускает загрузку и забывает про неё — промис остаётся «висящим» (floating):
import { loadMenu } from "./load-menu.mjs";
function refreshMenu() {
loadMenu("http://127.0.0.1:8082/menu");
console.log("refresh started");
}
refreshMenu();
setTimeout(() => console.log("next refresh in 1 s"), 1000);$ node floating.mjs
refresh started
file:///…/load-menu.mjs:8
throw new NetworkError(`Request to ${url} failed`, { cause: error });
^
NetworkError: Request to http://127.0.0.1:8082/menu failed
at loadMenu (file:///…/load-menu.mjs:8:11)
at process.processTicksAndRejections (node:internal/process/task_queues:104:5) {
[cause]: [TypeError: fetch failed] {
[cause]: Error: connect ECONNREFUSED 127.0.0.1:8082
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1706:16) {
errno: -4078,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 8082
}
}
}
Node.js v24.18.0Три вещи в этом выводе. Первая: refresh started напечаталось — функция «успешно» завершилась раньше, чем запрос упал. Вторая: строка next refresh in 1 s не появилась — процесс завершился с кодом 1. С Node.js 15 режим --unhandled-rejections по умолчанию равен throw: необработанное отклонение превращается в неперехваченное исключение. Третья: Node.js печатает всю цепочку [cause] — вот зачем её сохранять. Обратите внимание, что в стеке нет refreshMenu: после await стек показывает только асинхронную часть, поэтому хорошее сообщение ошибки (с URL) ценнее длинного стека.
import { loadMenu } from "./load-menu.mjs";
import { toUserMessage } from "./user-message.mjs";
async function refreshMenu() {
try {
const items = await loadMenu("http://127.0.0.1:8082/menu");
console.log("menu:", items.join(", "));
} catch (error) {
console.error("[log]", error.name, error.message);
console.log("User sees:", toUserMessage(error));
}
}
await refreshMenu();
setTimeout(() => console.log("next refresh in 1 s"), 1000);$ node fixed-refresh.mjs
[log] NetworkError Request to http://127.0.0.1:8082/menu failed
User sees: No connection to the server. Check your internet and try again.
next refresh in 1 sПоследняя страховка — глобальный обработчик process.on("unhandledRejection") в Node.js или событие unhandledrejection в браузере. Он нужен, чтобы записать в лог то, что пропустили, а не чтобы «продолжить как ни в чём не бывало»:
process.on("unhandledRejection", (reason) => {
console.error("[fatal] unhandled rejection:", reason?.name, reason?.message);
console.error("[fatal] cause:", reason?.cause?.message ?? "-");
process.exitCode = 1;
});
await import("./floating.mjs");$ node safety-net.mjs; echo "exit=$?"
refresh started
[fatal] unhandled rejection: NetworkError Request to http://127.0.0.1:8082/menu failed
[fatal] cause: fetch failed
next refresh in 1 s
exit=1Где ловить: на границе, а не везде
Частая ошибка — оборачивать в try/catch каждую функцию. Код раздувается, а ошибки всё равно теряются или логируются по пять раз. Работает другое правило: ловить там, где есть что сделать с ошибкой. Внутренние функции бросают понятные ошибки и не решают, что показать пользователю. Решение принимает граница — место, где код встречается с внешним миром: обработчик HTTP-запроса, обработчик кнопки, точка входа команды или фоновой задачи.
Соберём всё вместе. Тестовый сервер отдаёт три ответа — нормальное меню, ошибку 500 и HTML вместо JSON, — а четвёртый адрес указывает на порт, где никого нет. Граница — цикл в main.mjs: один try/catch/finally на каждую загрузку.
import { createServer } from "node:http";
export function startTestServer(port) {
const server = createServer((req, res) => {
if (req.url === "/menu") {
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ items: ["latte", "flat white"] }));
}
if (req.url === "/broken") {
res.writeHead(500, { "Content-Type": "text/plain" });
return res.end("Internal Server Error");
}
if (req.url === "/html") {
res.writeHead(200, { "Content-Type": "text/html" });
return res.end("<!doctype html><h1>Maintenance</h1>");
}
res.writeHead(404).end();
});
return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
}import { loadMenu } from "./load-menu.mjs";
import { toUserMessage } from "./user-message.mjs";
import { startTestServer } from "./test-server.mjs";
const server = await startTestServer(8081);
const urls = [
"http://127.0.0.1:8081/menu",
"http://127.0.0.1:8081/broken",
"http://127.0.0.1:8081/html",
"http://127.0.0.1:8082/menu",
];
for (const url of urls) {
console.log("→", url, "(spinner on)");
try {
const items = await loadMenu(url);
console.log(" OK:", items.join(", "));
} catch (error) {
console.error(" [log]", error.name, error.message, "| cause:", error.cause?.message ?? "-");
console.log(" User sees:", toUserMessage(error));
} finally {
console.log(" spinner off");
}
}
server.close();$ node main.mjs
→ http://127.0.0.1:8081/menu (spinner on)
OK: latte, flat white
spinner off
→ http://127.0.0.1:8081/broken (spinner on)
[log] HttpError HTTP 500 for http://127.0.0.1:8081/broken | cause: -
User sees: The menu is temporarily unavailable. Please try again in a minute.
spinner off
→ http://127.0.0.1:8081/html (spinner on)
[log] DataError Response from http://127.0.0.1:8081/html is not JSON | cause: Unexpected token '<', "<!doctype "... is not valid JSON
User sees: The menu could not be displayed. We are already looking into it.
spinner off
→ http://127.0.0.1:8082/menu (spinner on)
[log] NetworkError Request to http://127.0.0.1:8082/menu failed | cause: fetch failed
User sees: No connection to the server. Check your internet and try again.
spinner offСообщения пользователю против логов разработчика
У одной ошибки два читателя с разными задачами. Разработчику нужны подробности: класс, текст, URL, цепочка cause, stack trace. Пользователю нужно понять, что случилось с его задачей и что делать дальше. OWASP в Error Handling Cheat Sheet рекомендует не отдавать наружу детали реализации — стеки, SQL, пути к файлам, версии библиотек помогают атакующему, — а писать их в лог на сервере. Перевод технической ошибки в человеческие слова удобно держать в одной функции:
import { DataError, HttpError, NetworkError } from "./errors.mjs";
export function toUserMessage(error) {
if (error instanceof NetworkError) {
return "No connection to the server. Check your internet and try again.";
}
if (error instanceof HttpError && error.status >= 500) {
return "The menu is temporarily unavailable. Please try again in a minute.";
}
if (error instanceof HttpError && error.status === 404) {
return "Menu not found. Please refresh the page.";
}
if (error instanceof DataError) {
return "The menu could not be displayed. We are already looking into it.";
}
return "Something went wrong. Please try again later.";
}Содержание
- Лог разработчика
- Класс, текст, URL, статус, цепочка cause, stack trace
- Сообщение пользователю
- Что случилось с задачей пользователя и что сделать
Язык
- Лог разработчика
- Технический, на английском — удобно искать
- Сообщение пользователю
- Язык интерфейса, без терминов
Детали реализации
- Лог разработчика
- Да, полностью
- Сообщение пользователю
- Нет: ни стека, ни SQL, ни путей к файлам
Пример
- Лог разработчика
HttpError HTTP 500 for http://…/broken- Сообщение пользователю
- «The menu is temporarily unavailable…» — в примере сообщения на английском, в реальном интерфейсе они на языке пользователя
Хорошее сообщение пользователю отвечает на три вопроса: что не получилось, по чьей вине (его или нашей) и что делать — повторить, проверить интернет, обновить страницу. Если ошибка в данных пользователя, покажите её рядом с полем. Для HTTP API формат ответа с ошибкой стандартизирован в RFC 9457 (Problem Details): тип, заголовок, статус и описание — без внутренностей сервера.
Антипаттерны и упражнения
Самый опасный антипаттерн — пустой catch. Программа не падает, и это выглядит как надёжность, но на деле ошибка исчезает вместе с данными:
async function saveOrder(order) {
throw new Error(`DB is read-only, order ${order.id} not saved`);
}
const order = { id: 42 };
try {
await saveOrder(order);
} catch {}
console.log("Order accepted!");$ node antipattern.mjs
Order accepted!- Нет пустых
catchиcatch, который только пишетconsole.log(error)и продолжает работу как ни в чём не бывало - Бросаются объекты
Errorи их наследники, а не строки - При переупаковке исходная ошибка сохраняется в
cause - Ошибка логируется один раз — на границе, а не на каждом уровне
- Каждый промис дождан через
await, возвращён или имеет.catch() - Ответ
fetchпроверяется черезres.ok— 404 и 500 не бросают исключение сами - В
finallyнетreturnиthrow - Пользователь видит понятное сообщение без стека и деталей реализации
Как проверено7 · пройденосверено 11 октября 2026 г.
- запущено, работает
safe-parse.mjs, basics.mjs
node на Node.js 24.18.0 (Windows 11), вывод вставлен реальный
- запущено, работает
errors.mjs + load-menu.mjs + user-message.mjs + test-server.mjs + main.mjs
node main.mjs: тестовый сервер 127.0.0.1:8081 (200 / 500 / HTML), четвёртый адрес — свободный порт 8082 (ECONNREFUSED); вывод реальный
- запущено, работает
cause-chain.mjs
реальная цепочка NetworkError → TypeError: fetch failed → ECONNREFUSED
- запущено, работает
floating.mjs (необработанное отклонение)
процесс упал с кодом 1, таймер не сработал; вывод реальный, путь к папке заменён на «…»
- запущено, работает
safety-net.mjs, fixed-refresh.mjs
реальный вывод; safety-net — exit=1 с продолжением работы, fixed-refresh — exit=0
- запущено, работает
antipattern.mjs
реальный вывод «Order accepted!», exit=0
- запущено, работает
Упражнения: 404-сценарий, сообщение при таймауте, loadConfig
запуск в копии проекта: /missing → HttpError 404; сервер без ответа → TimeoutError в cause и новое сообщение, отказ соединения — прежнее; loadConfig на good/bad/missing.json — вывод реальный
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.