Головна / Статті / Виправлення помилок обробки помилок Async/Await у продакшн-коді Node.js

Виправлення помилок обробки помилок Async/Await у продакшн-коді Node.js

Дізнайтеся про п’ять поширених помилок у обробці помилок async/await у JavaScript та Node.js, які спричиняють беззвучні збої та ситуації конкуренції, а також про конкретні способи їх виправлення.

1766 слів

Система оплати, впроваджена однією командою минулого кварталу, призводила до подвійного стягнення грошей з клієнтів за один і той самий замовлення двічі на тиждень протягом майже місяця, перш ніж виникла проблема. Основною причиною не був ненадійний платіжний шлюз. Це був блок try/catch, який оточував виклик await та робив саме те, для чого був написаний — приховувати помилку та продовжувати роботу, — тоді як логіка повторних спроб на кількох рівнях вище припускала, що автоматичне вирішення обіцянки означає успіх.

Ніхто навмисно не вводить такий баг. Оскільки синтаксис async/await виглядає як звичайний синхронний код, розробники природно мислять про нього так само. Але основна модель обробки помилок все одно ґрунтується на відхиленні обіцянок, плануванні мікрозавдань та правилах скасування, які не повністю відповідають інтуїції, пов’язаній з try/catch. Навіть досвідчені інженери потрапляють у цю пастку, часто у коді, який вже пройшов перевірку, тому що ці баги проявляються лише під час конкурентності або часткових збоїв — саме в сценаріях, які юніт-тести зазвичай ігнорують.

Нижче наведено п’ять поширених помилок, які зустрічаються у продакшн-коді на JavaScript та Node.js 22/24, разом із рішеннями, які ефективно працюють під реальним навантаженням.

Помилка 1: Обробка помилок та мовчазне продовження роботи

Неправильний підхід:

async function getUserProfile(userId) {
  try {
    const res = await fetch(`/api/users/${userId}`);
    return await res.json();
  } catch (err) {
    console.error('Failed to fetch user', err);
    return null;
  }
}
async function renderDashboard(userId) {
  const profile = await getUserProfile(userId);
  // profile.name throws here if fetch failed — but the stack trace
  // now points at renderDashboard, not at the network call that actually failed
  document.title = `${profile.name}'s Dashboard`;
}

Обгортання операції fetch у блок catch перетворює конкретну, відстежувану проблему (відповідь 500 від /api/users/42) на нечітку (profile is null). До моменту, коли справді з’являється виняток — наприклад, TypeError: Cannot read properties of null — це вже відбувається далеко від справжньої причини, і немає жодних ознак того, що коли-небудь була здійснена мережева запит. У продакшені ця прогалина призводить до різниці між швидким виправленням протягом п’яти хвилин та двогодинним аналізом журналів.

Правильне використання:

async function getUserProfile(userId) {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) {
    throw new Error(`Failed to fetch user ${userId}: ${res.status}`, {
      cause: { status: res.status, userId },
    });
  }
  return res.json();
}
async function renderDashboard(userId) {
  try {
    const profile = await getUserProfile(userId);
    document.title = `${profile.name}'s Dashboard`;
  } catch (err) {
    console.error('Dashboard render failed', err, err.cause);
    showErrorBanner('Could not load your profile. Please retry.');
  }
}

Функція, яка найменше розуміє мережевий запит — функція отримання даних — повинна просто кидати виняток, коли щось йде не так. Рішення про те, що саме означає „невдача“ — чи це відображення банера, повторна спроба запиту чи використання кешованих даних, — залежить від тієї функції, яка має стратегію відновлення. Використання Error.cause (введеного у ES2022 та доступного у всіх сучасних браузерах, а також у Node.js 16.9 та новіших версіях) дозволяє зберегти структурований контекст замість того, щоб перетворювати його на нечітке рядкове повідомлення.

Помилка 2: Використання Promise.all, коли потрібен Promise.allSettled

Неправильний підхід:

async function loadDashboardData(userId) {
  const [profile, orders, recommendations] = await Promise.all([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId), // a third-party service with a 2% error rate
  ]);
  return { profile, orders, recommendations };
}

Promise.all виконується інтенційно швидко при виникненні помилки: як тільки одна з обіцянок відхиляється, весь запит також відхиляється, що призводить до втрати результатів інших запитів, навіть якщо вони вже були успішно виконані. Тож якщо fetchRecommendations досягає тайм-ауту, користувач також втрачає доступ до свого профілю та історії замовлень, незважаючи на те, що обидва ці запити фактично були завершені без помилок. Саме ця модель є причиною значної кількості скарг на „нестабільний панель керування“ щодо коду, який в іншому є абсолютно справним.

Правильне використання:

async function loadDashboardData(userId) {
  const results = await Promise.allSettled([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchRecommendations(userId),
  ]);

const [profile, orders, recommendations] = results.map((r) =>
    r.status === 'fulfilled' ? r.value : null
  );
  results.forEach((r, i) => {
    if (r.status === 'rejected') {
      logNonFatal(['profile', 'orders', 'recommendations'][i], r.reason);
    }
  });
  return { profile, orders, recommendations };
}

Корисне правило: використовуйте Promise.all лише тоді, коли кожна операція справді необхідна, а частковий результат буде беззмістовним, наприклад, три записи, які складають одну атомарну транзакцію. Використовуйте Promise.allSettled у випадках, коли операції є незалежними одна від одної, і частково функціональний інтерфейс кращий за порожній — що на практиці стосується більшості панелей керування, ендпоїнтів об’єднання даних та завдань пакетної обробки.

Помилка 3: Запуск асинхронних завдань без обробки їхніх помилок

Проблемна схема:

function handleClick(event) {
  logAnalyticsEvent(event); // returns a promise, nobody awaits it
  updateUI();
}

Тут функція logAnalyticsEvent оголошена як async, що означає, що вона повертає обіцянку незалежно від того, чи хтось нею скористається. Оскільки ніхто не додав блок .catch, ця обіцянка не має куди потрапити у разі виникнення проблем. Якщо сервіс аналітики виявиться недоступним, це призводить до некерованого відхилення обіцянки — деякі браузери ігнорують це мовчки, але у Node.js це призводить до повної зупинки процесу, адже з Node.js 15 некеровані відхилення за замовчуванням завершують процес. У обробнику запитів це означає, що кожен користувач, який звертається до цього маршруту, отримує помилку 500, і все через фоновий виклик, на який ніхто навіть не чекав.

Безпечніша версія:

function handleClick(event) {
  void logAnalyticsEvent(event).catch((err) => {
    logNonFatal('analytics', err);
  });
  updateUI();
}

Додавання перед викликом ключового слова void повідомляє як майбутніх адміністраторів коду, так і інструменти на кшталт @typescript-eslint/no-floating-promises, що ігнорування оператора await тут є навмисним, а не помилкою. Однак саме блок .catch виконує справжню роботу з запобігання тому, щоб фонова помилка перетворилася на проблему у основному потоці виконання. Якщо ваш код працює під Node.js, також варто додати слухач на найвищому рівні process.on('unhandledRejection', ...) як останній захист — але розглядайте його як запасну сітку, а не основну стратегію. Його завдання — записати проблему та попередити когось, а не компенсувати відсутність написаного блоку .catch.

Помилка 4: Дозволення асинхронним викликам змагатися без скасування тих, що програють

Проблемна схема:

async function search(query) {
  const results = await fetch(`/api/search?q=${query}`).then((r) => r.json());
  renderResults(results);
}

searchInput.addEventListener('input', (e) => search(e.target.value));

Кожен натиск клавіши викликає новий мережевий запит. Оскільки не гарантується, що відповіді надійдуть у тому порядку, в якому були надіслані, якщо пошук "reac" завершиться після пошуку "react", застарілі результати можуть перезаписати правильні на екрані. Це не якийсь рідкісний виняток — при повільному або обмеженому з’єднанні це відбувається постійно, і це одна з найпоширеніших причин скарг на помилку „у полі пошуку відображаються неправильні результати“ у будь-якому додатку з функцією пошуку в реальному часі.

Безпечніша версія:

let activeController = null;

async function search(query) {
  activeController?.abort();
  activeController = new AbortController();
  const { signal } = activeController;

try {
    const res = await fetch(`/api/search?q=${query}`, { signal });
    const results = await res.json();
    renderResults(results);
  } catch (err) {
    if (err.name !== 'AbortError') {
      logNonFatal('search', err);
    }
  }
}
searchInput.addEventListener('input', (e) => search(e.target.value));

AbortController, який є вбудованим у Node.js з версії 15 та підтримується кожною сучасною реалізацією fetch, перетворює неявну конкуренцію на явну, контрольовану: перемагає останній запит, тому що всі попередні активно скасовуються, а не тому, що вони випадково виграли у змаганні за час. Та сама ідея застосовується, коли компонент видаляється під час виконання запиту у React, Angular або Vue — потрібно скасувати запит під час очищення, а не просто сподіватися, що його відповідь ніколи не надійде.

Помилка 5: Залежність від finally замість належного оброблення помилок

Проблемна схема:

async function processOrder(order) {
  let lock;
  try {
    lock = await acquireLock(order.id);
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    releaseLock(lock);
  }
}

На перший погляд це здається безпечним, адже блок finally завжди виконується та має завжди звільнити блокування. Але якщо сам метод acquireLock кидає помилку ще до того, як будь-що було присвоєно, до моменту виконання finally змінна lock залишається undefined. У такому разі виклик releaseLock(undefined) або кидає другу, не пов’язану з початковою проблемою помилку, яка приховує первинну несправність, або нічого не робить — залежно від того, як реалізований механізм блокування — тоді як зовсім інше блокування залишається утримуваним необмежено довго. Блок finally лише гарантує, що його код буде виконаний; він нічого не каже про те, чи є логіка очищення всередині справді ефективною для кожного можливого сценарію, який міг призвести до цього стану.

Безпечніша версія:

async function processOrder(order) {
  const lock = await acquireLock(order.id); // outside try — nothing to release yet
  try {
    await chargeCard(order);
    await updateInventory(order);
  } finally {
    await releaseLock(lock);
  }
}

Лише ті операції, які залежать від фактичного отримання блокування, повинні знаходитися всередині блоку try, який ініціює очищення. Це невелика структурна зміна, але вона розділяє очищення, яке виконується саме тоді, коли це необхідно, від очищення, яке може бути запущене за умов, коли це призведе до пошкодження стану замість його виправлення.

Підсумок

Async/await ніколи не усунув труднощів з обробкою помилок у JavaScript — він лише приховав їх за синтаксисом, схожим на синхронний код. П’ять звичок допомагають вирішити більшість проблем у продакшені, які виникають через це: кидати чітко визначені типи помилок замість їх придушення, використовувати Error.cause для збереження первинної причини помилки; використовувати Promise.allSettled, коли основні операції не залежать одна від одної; ніколи не залишати обіцянку без обробки помилок у блоці .catch; скасовувати застарілі асинхронні операції за допомогою AbortController, замість того щоб покладатися на самостійне вирішення проблем мережею; та обмежувати використання блоків finally лише очищенням стану, який справді було отримано.

Жодна з цих практик не є незвичайною чи складною. Вони відображають розбіжність між асинхронним кодом, який функціонує у реальних мережевих умовах, та асинхронним кодом, який працює лише під час демонстрацій.

Пов’язана література

  • Атаки на ланцюг постачання npm: як вони функціонують та як захистити Node.js — Пояснюється, як працюють атаки на ланцюг постачання npm, такі як захоплення облікових записів, тайпоскуаттинг та плутанина з залежностями, а також наводяться конкретні кроки для зміцнення інсталяцій Node.js.
  • Розробка локальних Azure Functions: усунення поширених проблем — Дізнайтеся, як мають узгоджуватися Core Tools, мовні середовища виконання та Azurite, а також отримайте практичні рішення для файлу local.settings.json, тригерів та виправлення помилок під час дебагування.
  • Вибір між Promise.all, Promise.race та послідовними очікуваннями — Дізнайтеся, коли Promise.all() прискорює роботу API Node.js, чому він швидко зазнає невдачі при будь-якому відхиленні, та яка система прийняття рішень для вибору правильного асинхронного патерну.
  • Пов’язування логів між асинхронними викликами за допомогою AsyncLocalStorage — Дізнайтеся, як Node.js AsyncLocalStorage відстежує контекст кожного запиту, такий як requestId, через межі await, не вимагаючи ручного передавання його через кожну функцію.