Strona główna / Artykuły / Poprawa błędów w obsłudze błędów Async/Await w kodzie produkcyjnym Node.js

Poprawa błędów w obsłudze błędów Async/Await w kodzie produkcyjnym Node.js

Dowiedz się o pięciu powszechnych błędach w obsłudze błędów przy użyciu async/await w JavaScript i Node.js, które powodują ciche awarie i sytuacje konkurencyjne, oraz o konkretnych sposobach ich naprawy.

1766 słów

System płatności wdrożony przez jedną z drużyn w poprzednim kwartale powodował podwójne naliczanie opłat klientom za ten sam zamówienie, dwa razy w tygodniu, przez prawie miesiąc, zanim pojawił się problem. Przyczyną nie była niewiarygodna brama płatności, lecz blok try/catch otaczający wywołanie await, który robił dokładnie to, do czego został stworzony — pochłaniał błąd i kontynuował pracę — podczas gdy logika ponawiania prób na wyższych poziomach zakładała, że automatyczne rozwiązanie obietnicy oznacza sukces.

Nikt nie wprowadza takiego błędu celowo. Ponieważ składnia async/await przypomina zwykły kod synchroniczny, programiści naturalnie myślą o nim w ten sam sposób. Jednak podstawowy model obsługi błędów nadal opiera się na odrzuceniu obietnic, planowaniu mikrozadań oraz zasadach anulowania, które nie odpowiadają w pełni intuicji związanej z try/catch. Nawet doświadczeni inżynierowie mogą na to natrafić, często w kodzie, który już przeszedł audyt, ponieważ takie błędy pojawiają się tylko w warunkach równoczesności lub częściowych awarii — dokładnie w tych scenariuszach, które testy jednostkowe zwykle pomijają.

Poniżej przedstawiono pięć często występujących błędów w kodach JavaScript i Node.js 22/24 w środowisku produkcyjnym, wraz z rozwiązaniami, które sprawdzają się przy rzeczywistym ruchu.

Błąd 1: Łapanie błędów i dalsze działanie w milczeniu

Nieprawidłowy sposób:

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`;
}

Otoczenie operacji fetch blokiem catch przekształca konkretny, łatwy do zlokalizowania błąd (odpowiedź 500 z adresu /api/users/42) w niejasny błąd (profile is null). Zanim pojawi się rzeczywista wyjątek — na przykład TypeError: Cannot read properties of null — dzieje się to daleko od prawdziwej przyczyny, bez żadnych wskazówek na to, że w ogóle doszło do żądania sieciowego. W środowisku produkcyjnym ta luka oznacza różnicę między szybkim naprawieniem w ciągu pięciu minut a dwugodzinnym przeglądaniem logów.

Prawidłowe użycie:

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.');
  }
}

Funkcja, która najmniej rozumie żądanie sieciowe — funkcja pobierania danych — powinna po prostu rzucić błąd, gdy coś pójdzie nie tak. Decyzja o tym, co dokładnie oznacza „porażka”, czy to wyświetlenie banera, ponowna próba wysłania żądania, czy użycie danych z pamięci podręcznej, należy do tej funkcji, która posiada strategię naprawczą. Użycie Error.cause (wprowadzone w ES2022 i dostępne we wszystkich aktualnych przeglądarkach oraz w Node.js 16.9 i nowszych) pozwala zachować strukturalny kontekst zamiast zamieniać go w niejasną wiadomość tekstową.

Błąd 2: Używanie Promise.all, gdy potrzebny jest Promise.allSettled

Nieprawidłowy sposób:

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 jest celowo zaprojektowany tak, by szybko zwracać błąd: gdy tylko jedna z obietnic odrzuci się, cała operacja odrzuca się, co skutkuje utratą wyników innych operacji, nawet jeśli te już zostały pomyślnie zrealizowane. Dlatego jeśli fetchRecommendations wygaśnie, użytkownik traci dostęp również do swojego profilu i historii zamówień, mimo że obie te prośby faktycznie zostały zakończone bez błędów. Ten wzorzec stoi u podstaw dużej liczby skarg na „niestabilną panelę kontrolną” w przypadku kodu, który w pozostałych aspektach jest całkowicie poprawny.

Prawidłowe użycie:

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 };
}

Praktyczna zasada: używaj Promise.all tylko wtedy, gdy każda operacja jest rzeczywiście konieczna, a częściowy wynik byłby bezsensowny, np. przy trzech zapisach tworzących jedną atomową transakcję. Zastosuj Promise.allSettled, gdy operacje są od siebie niezależne i częściowo funkcjonalna interfejs jest lepsza od pustego — co w praktyce dotyczy większości paneli kontrolnych, punktów końcowych do agregacji danych oraz zadań przetwarzania zbiorczego.

Błąd 3: Uruchamianie zadań asynchronicznych bez obsługi ich niepowodzeń

Problematyczny wzorzec:

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

Tutaj logAnalyticsEvent jest deklarowany jako async, co oznacza, że zwraca obietnicę (promise), niezależnie od tego, czy ktoś się nią przejmuje. Ponieważ nikt nie dołączył bloku .catch, odrzucenie tej obietnicy nie ma dokąd pójść. Jeśli usługa analityczna okazuje się niedostępna, odrzucenie przekształca się w nierozwiązane odrzucenie obietnicy – co niektóre przeglądarki po prostu ignorują, ale w Node.js powoduje natychmiastowe zatrzymanie procesu, ponieważ od wersji Node.js 15 nierozwiązane odrzucenia domyślnie kończą proces. Wewnątrz obsługi żądania oznacza to, że każdy użytkownik korzystający z tej ścieżki otrzymuje błąd 500, a wszystko to z powodu tła wywołania, na które nikt nawet nie czekał.

Bardziej bezpieczna wersja:

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

Dodanie przed wywołaniem słowa void informuje zarówno przyszłych administratorów kodu, jak i narzędzia takie jak @typescript-eslint/no-floating-promises, że pominięcie instrukcji await jest celowe, a nie błędem. To jednak blok .catch pełni prawdziwą rolę w zapobieganiu temu, by błąd w tle przerodził się w awarię na pierwszym planie. Jeśli twój kod jest uruchamiany w Node.js, warto również dodać słuchacz na najwyższym poziomie process.on('unhandledRejection', ...) jako ostatnią linię obrony — ale traktuj go jako zabezpieczenie dodatkowe, a nie główną strategię. Jego zadaniem jest zapisywanie informacji o problemie i powiadamianie kogoś, a nie kompensowanie braku napisanego bloku .catch.

Błąd 4: Pozwalanie na jednoczesne wykonywanie wywołań asynchronicznych bez anulowania tych, które zawiodą

Problematyczny wzorzec:

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));

Każde naciśnięcie klawisza wywołuje nowe żądanie sieciowe. Ponieważ nie ma gwarancji, że odpowiedzi dotrą w tej samej kolejności, w której zostały wysłane, jeśli wyszukiwanie "reac" zakończy się po wyszukiwaniu "react", przestarzałe wyniki mogą nadpisać te poprawne na ekranie. To nie jest jakiś rzadki przypadek krawędziowy – przy wolnym lub ograniczonym połączeniu dzieje się to cały czas i jest to jedna z najczęstszych przyczyn zgłoszeń błędów typu „pole wyszukiwania pokazuje niewłaściwe wyniki” we wszystkich aplikacjach z polami wyszukiwania w czasie rzeczywistym.

Bardziej bezpieczna wersja:

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, dostępny natywnie w Node.js od wersji 15 i obsługiwany przez każdą nowoczesną implementację fetch, przekształca ukrytą rywalizację w jawną, kontrolowaną: wygrywa najnowsza prośba, ponieważ wszystkie wcześniejsze są aktywnie anulowane, a nie dlatego, że przypadkowo wygrały rywalizację czasową. Ta sama zasada obowiązuje, gdy komponent jest usuwany w trakcie realizacji prośby w React, Angular lub Vue – należy anulować tę prośbę podczas czyszczenia, zamiast po prostu mieć nadzieję, że jej odpowiedź nigdy nie dotrze.

Błąd 5: Poleganie na finally zamiast na właściwym obsługiwanie błędów

Problematyczny wzorzec:

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

Na pierwszy rzut oka wydaje się to bezpieczne, ponieważ blok finally zawsze się wykonywać i powinien zawsze zwolnić blokadę. Jednak jeśli sama funkcja acquireLock rzuci błędem przed przypisaniem jakichkolwiek wartości, zmienna lock pozostaje undefined, gdy blok finally zostanie uruchomiony. Wezwanie releaseLock(undefined) w tym momencie albo powoduje drugi, niezwiązanym z pierwotnym błąd, który maskuje oryginalną awarię, albo nic nie robi – w zależności od sposobu implementacji mechanizmu blokowania – podczas gdy zupełnie inna blokada pozostaje utrzymywana w nieskończoność. Blok finally gwarantuje jedynie, że jego kod się wykona; nie mówi nic o tym, czy logika czyszczenia w jego obrębie jest rzeczywiście skuteczna dla każdej możliwej ścieżki, która mogła do niego doprowadzić.

Bardziej bezpieczna wersja:

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);
  }
}

Tylko te operacje, które zależą od faktycznego uzyskania blokady, powinny znajdować się w bloku try, który uruchamia czyszczenie. Jest to niewielka zmiana strukturalna, ale pozwala oddzielić czyszczenie wykonywane dokładnie wtedy, gdy jest to konieczne, od czyszczenia, które może zostać uruchomione w warunkach, w których zamiast naprawić stan, może go uszkodzić.

Podsumowanie

Async/await nigdy nie wyeliminował trudności związanych z obsługą błędów w JavaScript — jedynie ukrył je za składnią przypominającą kod synchroniczny. Pięć nawyków pomaga rozwiązać większość problemów występujących w produkcji spowodowanych tym ukrywaniem błędów: rzucaj dobrze zdefiniowane typy błędów zamiast je tłumić, używaj Error.cause aby zachować oryginalną przyczynę błędu; stosuj Promise.allSettled, gdy operacje podstawowe nie zależą od siebie; nigdy nie pozostawiaj obietnicy bez obsługi .catch; anuluj przestarzałe zadania asynchroniczne za pomocą AbortController, zamiast polegać na samoczynnym uporządkowaniu się czasu działania sieci; oraz ogranicz bloki finally do czyszczenia stanu faktycznie uzyskanego.

Literatura pokrewna

  • Ataki w łańcuchu dostaw npm: jak działają i jak chronić Node.js — Wyjaśnia, w jaki sposób ataki w łańcuchu dostaw npm, takie jak przejęcie kont, typosquatting i pomyłki w zależnościach, funkcjonują, oraz przedstawia konkretne kroki do wzmocnienia instalacji Node.js.
  • Rozwój lokalnych funkcji Azure: naprawa najczęstszych problemów — Dowiedz się, jak narzędzia podstawowe, środowiska językowe i Azurite muszą być skoordynowane, oraz otrzymaj praktyczne rozwiązania dotyczące pliku local.settings.json, wyzwalaczy i błędów podczas debugowania.
  • Wybór między Promise.all, Promise.race a sekwencyjnymi oczekiwaniami — Dowiedz się, kiedy Promise.all() przyspiesza API Node.js, dlaczego szybko zawodzi przy każdym odrzuceniu oraz jak wybrać odpowiedni wzorzec asynchroniczny.
  • Powiązanie logów między asynchronicznymi wywołaniami za pomocą AsyncLocalStorage — Przeczytaj, jak Node.js AsyncLocalStorage śledzi kontekst każdej prośby, takiego jak requestId, pomiędzy operacjami await bez konieczności ręcznego przenoszenia go przez każdą funkcję.