Пошук уроків, статей та іншого контенту
Навчитеся проєктувати стратегію обробки помилок із логуванням, відновленням, повторними спробами та безпечним повідомленням користувачу.
Обробка помилок — це не лише try...catch. У реальному застосунку потрібно заздалегідь визначити:
які помилки можуть виникнути;
де їх потрібно перехоплювати;
які помилки можна повторити;
коли потрібно відновити стан або виконати компенсаційну дію;
що записувати в журнал;
яке повідомлення показувати користувачу;
коли помилку потрібно передати на вищий рівень.
Хаотична обробка помилок призводить до типових проблем:
користувач бачить технічні деталі;
одна й та сама помилка логуються багато разів;
тимчасові збої одразу перетворюються на помилки інтерфейсу;
повторна спроба дублює платіж або створює кілька замовлень;
помилки губляться всередині асинхронного коду;
застосунок продовжує працювати в пошкодженому стані.
Надійна стратегія має розділяти кілька відповідальностей:
Виявлення помилки.
Класифікація її типу та серйозності.
Відновлення, якщо це можливо.
Логування технічного контексту.
Безпечне повідомлення користувачу.
Ескалація, якщо помилку не можна обробити на поточному рівні.
Перед написанням обробників потрібно зрозуміти, які категорії помилок існують у системі.
Це ситуації, коли вхідні дані не відповідають правилам застосунку:
порожнє обов’язкове поле;
неправильний формат електронної пошти;
некоректний діапазон дат;
спроба виконати дію без необхідних прав.
Такі помилки зазвичай не потрібно повторювати автоматично. Користувачу потрібно показати зрозуміле повідомлення, а не стек викликів.
Причина може зникнути через короткий час:
тимчасова недоступність сервісу;
перевищення ліміту запитів;
короткочасна мережева помилка;
блокування ресурсу.
Для них часто підходить повторна спроба з затримкою. Але повторення має бути обмеженим і застосовуватися лише до безпечних операцій.
Повторна спроба не змінить результат:
ресурс не існує;
неправильні облікові дані;
порушення бізнес-правила;
помилка програмного коду;
відсутня конфігурація.
Таку помилку потрібно швидко передати на відповідний рівень обробки.
Наприклад:
звернення до неіснуючої властивості через неправильну логіку;
порушення інваріанта;
некоректне перетворення даних;
помилка в алгоритмі.
Їх не слід приховувати повідомленням на кшталт «спробуйте ще раз» без логування та сповіщення команди. Застосунок може продовжити роботу лише якщо це безпечно.
Error містить лише базову інформацію. Для стратегії обробки зазвичай потрібні додаткові властивості:
стабільний код помилки;
HTTP-статус або аналогічний статус;
ознака тимчасовості;
безпечне повідомлення для користувача;
структурований контекст;
початкова помилка в cause.
class AppError extends Error {
constructor(message, options = {}) {
super(message, { cause: options.cause });
this.name = options.name ?? "AppError";
this.code = options.code ?? "INTERNAL_ERROR";
this.status = options.status ?? 500;
this.retryable = options.retryable ?? false;
this.publicMessage =
options.publicMessage ?? "Сталася внутрішня помилка.";
this.details = options.details;
}
}
class ValidationError extends AppError {
constructor(message, details) {
super(message, {
name: "ValidationError",
code: "VALIDATION_ERROR",
status: 400,
retryable: false,
publicMessage: "Перевірте введені дані.",
details,
});
}
}
class ServiceUnavailableError extends AppError {
constructor(message, options = {}) {
super(message, {
name: "ServiceUnavailableError",
code: "SERVICE_UNAVAILABLE",
status: 503,
retryable: true,
publicMessage: "Сервіс тимчасово недоступний.",
...options,
});
}
}Внутрішнє message може містити технічний опис, а publicMessage призначене для клієнта. Не варто використовувати одне поле для обох цілей.
Низькорівнева бібліотека або платформа може викидати власні помилки. Доменний код не повинен залежати від усіх деталей конкретного драйвера.
Наприклад, помилку мережевого клієнта можна перетворити на ServiceUnavailableError:
async function loadUserProfile(userId, userClient) {
try {
return await userClient.get(`/users/${userId}`);
} catch (error) {
if (error?.code === "ECONNRESET" || error?.code === "ETIMEDOUT") {
throw new ServiceUnavailableError(
"Не вдалося отримати профіль користувача",
{ cause: error }
);
}
throw new AppError("Помилка під час читання профілю", {
code: "PROFILE_READ_FAILED",
status: 502,
publicMessage: "Не вдалося завантажити профіль.",
cause: error,
});
}
}Тут початкова помилка зберігається в cause, але назовні передається стабільний тип, який розуміє решта застосунку.
Помилку потрібно перехоплювати там, де є достатньо інформації для корисної дії.
Перехоплюйте помилку локально, якщо можна:
додати контекст;
звільнити ресурс;
виконати компенсаційну дію;
використати альтернативний сценарій;
перетворити технічну помилку на доменну.
async function readCacheOrFallback(key, cache, loadFromDatabase) {
try {
const cachedValue = await cache.get(key);
if (cachedValue !== null) {
return cachedValue;
}
} catch (error) {
// Помилка кешу не повинна блокувати основне джерело даних.
console.warn("Кеш недоступний, використовується база даних", {
key,
errorCode: error?.code,
});
}
return loadFromDatabase(key);
}На цьому рівні зазвичай:
визначають HTTP-статус;
формують відповідь API;
додають ідентифікатор запиту;
логують помилку один раз;
приховують внутрішні деталі.
Глобальний обробник потрібен як останній захист:
для необроблених помилок;
для аварійного логування;
для коректного завершення процесу;
для відображення загального повідомлення.
Глобальний обробник не може надійно «вилікувати» будь-який стан застосунку. Якщо процес перебуває в непередбачуваному стані, безпечніше завершити його та дозволити менеджеру процесів запустити новий екземпляр.
Повторна спроба доречна лише тоді, коли:
помилка тимчасова;
операція справді може бути повторена;
кількість спроб обмежена;
між спробами є затримка;
система не створить небажаних дублікатів.
Якщо багато клієнтів одночасно повторюють запит через однаковий інтервал, вони можуть створити нове пікове навантаження. Тому використовують:
експоненційне збільшення затримки;
випадкове відхилення — jitter.
Формула затримки може виглядати так:
delay = min(maxDelay, baseDelay × 2^attempt) + random(0, jitter)const sleep = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
function isRetryable(error) {
return error instanceof AppError && error.retryable === true;
}
async function withRetry(operation, options = {}) {
const {
retries = 3,
baseDelay = 100,
maxDelay = 2000,
jitter = 100,
onRetry,
} = options;
let attempt = 0;
while (true) {
try {
return await operation(attempt);
} catch (error) {
if (!isRetryable(error) || attempt >= retries) {
throw error;
}
const exponentialDelay = Math.min(
maxDelay,
baseDelay * 2 ** attempt
);
const randomDelay = Math.floor(Math.random() * jitter);
const delay = exponentialDelay + randomDelay;
onRetry?.({
attempt: attempt + 1,
delay,
error,
});
await sleep(delay);
attempt += 1;
}
}
}Важливо: параметр retries = 3 означає три додаткові спроби після першого виклику, тобто загалом може бути чотири виконання операції.
Повторити GET зазвичай безпечніше, ніж POST, але одного HTTP-методу недостатньо для висновку.
Операція є ідемпотентною, якщо повторне виконання дає той самий бажаний результат, що й одне виконання.
Приклад небезпечного сценарію:
клієнт надсилає запит на створення платежу;
сервер створює платіж;
відповідь губиться через мережеву помилку;
клієнт повторює запит;
створюється другий платіж.
Для таких операцій використовують ідемпотентний ключ. Сервер зберігає результат для ключа та повертає його під час повторного запиту.
Повторні спроби не повинні бути єдиним способом підвищення надійності. Потрібно також передбачити:
тайм-аути;
ідемпотентні ключі;
дедуплікацію повідомлень;
транзакції;
контроль стану операції.
Запит без тайм-ауту може зависнути надовго й утримувати ресурси. У сучасному JavaScript для скасування асинхронної операції часто використовують AbortController.
async function fetchWithTimeout(url, options = {}) {
const {
timeout = 5000,
signal: externalSignal,
...fetchOptions
} = options;
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort("Час очікування вичерпано");
}, timeout);
const abortFromOutside = () => {
controller.abort(externalSignal.reason);
};
externalSignal?.addEventListener("abort", abortFromOutside, {
once: true,
});
try {
return await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) {
throw new ServiceUnavailableError(
"Зовнішній сервіс не відповів вчасно",
{
code: "UPSTREAM_TIMEOUT",
cause: error,
publicMessage: "Сервіс не відповів вчасно.",
}
);
}
throw error;
} finally {
clearTimeout(timeoutId);
externalSignal?.removeEventListener("abort", abortFromOutside);
}
}Тайм-аут потрібно відрізняти від безумовної помилки мережі. Обидві ситуації можуть бути тимчасовими, але для діагностики важливо зберегти різні коди.
Іноді помилку недостатньо просто залогувати. Потрібно повернути систему до узгодженого стану.
Наприклад, операція складається з кроків:
зарезервувати товар;
створити платіж;
підтвердити замовлення.
Якщо платіж не створено після резервування товару, потрібно:
скасувати резервування;
або позначити замовлення як таке, що потребує ручної обробки;
або передати подію до черги повторного виконання.
Не всі компенсаційні дії гарантовано завершаться успішно. Тому помилку компенсації потрібно логувати окремо, а стан операції — зберігати явно.
async function createOrder(input, services) {
let reservationId;
try {
reservationId = await services.inventory.reserve(
input.productId,
input.quantity
);
const payment = await services.payments.charge({
amount: input.amount,
idempotencyKey: input.requestId,
});
return await services.orders.confirm({
...input,
reservationId,
paymentId: payment.id,
});
} catch (error) {
if (reservationId) {
try {
await services.inventory.release(reservationId);
} catch (compensationError) {
// Помилка компенсації потребує окремого розслідування.
services.logger.error("Не вдалося скасувати резервування", {
reservationId,
error: serializeError(compensationError),
});
}
}
throw new AppError("Не вдалося створити замовлення", {
code: "ORDER_CREATION_FAILED",
status: 502,
publicMessage: "Замовлення не створено. Спробуйте пізніше.",
cause: error,
});
}
}Для складних процесів краще використовувати явну машину станів або патерн Saga, а не покладатися лише на вкладені try...catch.
Логи потрібні для діагностики, але вони можуть містити конфіденційні дані. Не можна бездумно записувати:
паролі;
токени доступу;
номери платіжних карток;
секретні ключі;
повні персональні дані;
заголовок Authorization;
великі тіла запитів.
Структурований лог краще за довільний текст, оскільки його можна фільтрувати та аналізувати.
function serializeError(error) {
if (!(error instanceof Error)) {
return { value: String(error) };
}
return {
name: error.name,
message: error.message,
code: error.code,
status: error.status,
stack: error.stack,
cause: error.cause
? serializeError(error.cause)
: undefined,
};
}
function redact(value) {
if (!value || typeof value !== "object") {
return value;
}
const secretKeys = new Set([
"password",
"token",
"accessToken",
"refreshToken",
"authorization",
"apiKey",
]);
if (Array.isArray(value)) {
return value.map(redact);
}
return Object.fromEntries(
Object.entries(value).map(([key, currentValue]) => [
key,
secretKeys.has(key) ? "[REDACTED]" : redact(currentValue),
])
);
}
const logger = {
error(message, context = {}) {
console.error(JSON.stringify({
level: "error",
time: new Date().toISOString(),
message,
context: redact(context),
}));
},
};У production-застосунку логер зазвичай також додає:
ідентифікатор запиту;
ідентифікатор користувача без зайвих персональних даних;
версію застосунку;
назву сервісу;
середовище виконання;
тривалість операції.
Якщо нижній рівень залогував помилку, а верхній повторив той самий лог, одна проблема виглядатиме як дві або три.
Зручне правило:
нижній рівень додає контекст і передає помилку далі;
межа запиту або глобальний обробник логують необроблену помилку;
локальне логування використовується, коли виконується відновлення або компенсація.
Користувачеві не потрібні:
стек викликів;
назви таблиць бази даних;
тексти винятків від драйверів;
внутрішні URL;
фрагменти токенів;
службові ідентифікатори.
Замість цього повідомлення має:
пояснювати, що сталося на зрозумілому рівні;
підказувати наступну дію;
не обіцяти те, чого система не гарантує;
бути стабільним і локалізованим.
При цьому користувачу можна показати ідентифікатор звернення:
Не вдалося виконати операцію. Спробуйте пізніше. Код звернення:
req-8f31.
Цей код не замінює логування, але допомагає пов’язати повідомлення користувача із записом у системі.
На межі HTTP API потрібно перетворити виняток на контрольовану відповідь. Приклад для Node.js із вбудованим HTTP-сервером:
import http from "node:http";
import { randomUUID } from "node:crypto";
class AppError extends Error {
constructor(message, options = {}) {
super(message, { cause: options.cause });
this.name = options.name ?? "AppError";
this.code = options.code ?? "INTERNAL_ERROR";
this.status = options.status ?? 500;
this.publicMessage =
options.publicMessage ?? "Сталася внутрішня помилка.";
this.retryable = options.retryable ?? false;
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, {
name: "ValidationError",
code: "VALIDATION_ERROR",
status: 400,
publicMessage: "Перевірте введені дані.",
});
}
}
function serializeError(error) {
return {
name: error?.name,
message: error?.message,
code: error?.code,
stack: error?.stack,
cause: error?.cause
? serializeError(error.cause)
: undefined,
};
}
function writeJson(response, status, body) {
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
});
response.end(JSON.stringify(body));
}
const server = http.createServer(async (request, response) => {
const requestId = request.headers["x-request-id"] ?? randomUUID();
try {
if (request.method !== "GET" || request.url !== "/profile") {
throw new AppError("Маршрут не знайдено", {
code: "NOT_FOUND",
status: 404,
publicMessage: "Ресурс не знайдено.",
});
}
const userId = request.headers["x-user-id"];
if (!userId) {
throw new ValidationError("Відсутній ідентифікатор користувача");
}
writeJson(response, 200, {
requestId,
profile: {
userId,
name: "Ada Lovelace",
},
});
} catch (error) {
const appError =
error instanceof AppError
? error
: new AppError("Неочікувана помилка", {
cause: error,
});
console.error(JSON.stringify({
level: "error",
requestId,
code: appError.code,
status: appError.status,
error: serializeError(appError),
}));
writeJson(response, appError.status, {
error: {
code: appError.code,
message: appError.publicMessage,
requestId,
},
});
}
});
server.listen(3000, () => {
console.log("Сервер запущено на http://localhost:3000");
});Цей приклад можна запустити в середовищі Node.js, яке підтримує ES-модулі:
node server.mjsУ відповіді клієнт отримує стабільний код, безпечне повідомлення та requestId. Технічні деталі залишаються в журналі.
Для необроблених помилок Node.js надає події uncaughtException та unhandledRejection.
process.on("unhandledRejection", (reason) => {
console.error("Необроблене відхилення Promise", reason);
// Після критичної помилки потрібно перейти до контрольованого завершення.
process.exitCode = 1;
});
process.on("uncaughtException", (error) => {
console.error("Необроблений виняток", error);
// Не продовжуємо роботу в потенційно пошкодженому стані.
process.exit(1);
});Такі обробники не повинні замінювати локальний try...catch. Вони потрібні для останнього рубежу та аварійного завершення.
Перед завершенням процесу застосунок може:
припинити приймання нових запитів;
завершити поточні операції в межах тайм-ауту;
закрити підключення;
передати логи системі збору логів;
завершити процес із ненульовим кодом.
У браузері аналогічно можна використовувати window.onerror та unhandledrejection, але вони призначені переважно для збору діагностики. Відновлення стану інтерфейсу має виконуватися на рівні компонентів і сценаріїв.
Автоматичні повтори на клієнті потрібно узгодити з поведінкою API. Для користувача корисно розділити:
автоматичне повторення тимчасового запиту;
кнопку «Спробувати ще раз»;
повідомлення про відсутність з’єднання;
повідомлення про завершення сесії;
повідомлення про відсутність прав.
Не потрібно повторювати запит після кожної помилки. Наприклад, помилка валідації або 401 Unauthorized потребує іншої дії, а не повторення того самого запиту.
Після невдалої операції UI повинен мати визначений стан:
кнопка знову доступна;
індикатор завантаження вимкнений;
частково змінені дані не видаються за збережені;
помилка доступна для повторної спроби або редагування;
дублювання події неможливе.
Помилка без контексту часто майже марна. Порівняйте:
throw new Error("Помилка");і:
throw new AppError("Не вдалося завантажити список замовлень", {
code: "ORDERS_READ_FAILED",
status: 502,
publicMessage: "Список замовлень тимчасово недоступний.",
cause: error,
});Контекст краще додавати структуровано, а не конкатенацією рядків:
logger.error("Помилка завантаження замовлень", {
requestId,
userId,
operation: "orders.list",
error: serializeError(error),
});Не додавайте в контекст необмежені об’єкти. Великі тіла запитів, рекурсивні структури або повні відповіді зовнішніх сервісів можуть:
збільшити обсяг логів;
розкрити секрети;
ускладнити пошук;
спричинити нову помилку під час серіалізації.
Помилки потрібно тестувати так само, як успішні сценарії.
Мінімальний набір перевірок:
помилка валідації повертає правильний код;
тимчасова помилка повторюється обмежену кількість разів;
постійна помилка не повторюється;
після вичерпання спроб повертається початкова причина;
тайм-аут скасовує операцію;
компенсаційна дія виконується після часткового успіху;
секрети не потрапляють у логи;
користувач не бачить стек викликів;
необроблена помилка не залишає інтерфейс у стані завантаження.
Для тестів повторних спроб бажано інжектувати функцію затримки або використовувати фальшивий таймер. Це робить тести швидкими й детермінованими.
catchtry {
await saveData();
} catch {
// Нічого не робимо.
}Так помилка губиться, а система може продовжити роботу без потрібних даних. Якщо помилку навмисно ігнорують, це рішення має бути явним і безпечним.
error.messageconsole.error(error.message);Так можна втратити тип, код, стек і початкову причину. Для діагностики зберігайте структурований контекст.
showError(error.message);message може містити SQL, URL внутрішнього сервісу або секретні дані. Використовуйте окреме безпечне поле або мапу публічних повідомлень.
Повторення будь-якого запиту може створити дублікати або погіршити перевантаження сервісу. Спочатку перевірте тимчасовість та ідемпотентність операції.
Відсутність ліміту повторів утримує ресурси та приховує проблему. Завжди визначайте максимальну кількість спроб і загальний тайм-аут.
catch (error) {
throw new Error("Операція не вдалася");
}Так губиться причина. Використовуйте cause:
catch (error) {
throw new Error("Операція не вдалася", { cause: error });
}Глобальний обробник не знає, як відновити конкретний бізнес-процес. Він має бути останнім рубежем, а не заміною локальній обробці.
Якщо порушено інваріанти або пошкоджено важливий стан, продовження роботи може призвести до нових помилок і некоректних даних. У такій ситуації контрольоване завершення часто безпечніше.
Для кожної потенційної помилки поставте такі запитання:
Чи є ця помилка очікуваною?
До якої категорії вона належить?
Чи можна безпечно повторити операцію?
Скільки повторів дозволено?
Чи потрібен тайм-аут?
Чи потрібно виконати компенсаційну дію?
На якому рівні помилка має бути перехоплена?
Який технічний контекст потрібно залогувати?
Чи містить контекст секрети або персональні дані?
Що має побачити користувач?
Чи потрібно сповістити команду?
Який стан системи після помилки вважається коректним?
Ці питання допомагають перетворити try...catch на системну політику надійності.
Стратегія обробки помилок має охоплювати виявлення, класифікацію, відновлення, логування та повідомлення користувачу.
Використовуйте власні класи помилок зі стабільними кодами, статусами та безпечними повідомленнями.
Зберігайте початкову причину через cause.
Повторюйте лише тимчасові та безпечні для повторення операції.
Обмежуйте кількість спроб, використовуйте експоненційну затримку, jitter і тайм-аути.
Для операцій зі змінами стану продумуйте ідемпотентність і компенсаційні дії.
Логуйте структуровано, але не записуйте секрети та зайві персональні дані.
Технічні деталі призначені для логів, а не для користувача.
Глобальні обробники — це останній рубіж, а не заміна локальній логіці.
Критичні помилки можуть вимагати контрольованого завершення процесу.