Menu

Obsługa błędów async w JavaScript: try/catch, Promise i unhandledrejection

Jak naprawdę przepływają błędy w asynchronicznym JavaScript: try/catch z async/await, .catch w obietnicach i pułapki, które po cichu połykają awarie.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Błędy w kodzie async nie działają jak błędy synchroniczne

W synchronicznym JavaScript rzucony błąd wędruje w górę stosu wywołań, aż złapie go jakiś try/catch, albo program się wysypie. Kod asynchroniczny łamie ten model. Zanim żądanie sieciowe się nie powiedzie, funkcja, która je rozpoczęła, dawno już zwróciła wynik. Nie ma stosu wywołań, po którym błąd mógłby się wspinać.

Obietnice (promises) rozwiązują to, dając błędom osobny kanał. Obietnica może zostać spełniona (fulfill) wartością albo odrzucona (reject) z powodem. Odrzucenie to asynchroniczny odpowiednik rzucenia błędu. Cała ta strona dotyczy tego, jak sprawić, by odrzucenia trafiały tam, gdzie masz nad nimi kontrolę, zamiast znikać.

try/catch wykonuje się i kończy bez problemu. Odrzucenie następuje 50 ms później, długo po zakończeniu bloku try. Nic go nie łapie. To właśnie pułapka.

try/catch znów działa z await

W chwili, gdy używasz await na obietnicy, odrzucenie staje się rzuconym błędem wewnątrz funkcji async. Otaczający try/catch łapie go jak każdy synchroniczny wyjątek:

Po ten wzorzec sięgaj najpierw. await przenosi świat asynchroniczny z powrotem do znajomego kształtu try/catch. Umieść w try wywołania await, które mogą się nie powieść, i obsłuż je w catch.

Jeden szczegół wart uwagi: chronione jest tylko wywołanie z await. Jeśli uruchomisz obietnicę bez czekania na nią, błędy nadal uciekają.

Najczęstszy błąd: zapomniany await

Jeśli wywołasz funkcję async bez await (albo bez zwrócenia jej obietnicy), odrzucenia prześlizgują się obok otaczającego try/catch:

Blok try kończy się pomyślnie. Odrzucenie następuje w kolejnym takcie i nic go nie łapie. W konsoli zobaczysz ostrzeżenie „unhandled promise rejection”.

Poprawka jest zawsze ta sama: użyj await przy wywołaniu albo zwróć obietnicę przez return, żeby mógł na nią poczekać kod wywołujący.

.catch() to druga strona tego samego medalu

Odrzucenia możesz obsługiwać bez async/await, dołączając .catch() do łańcucha:

.catch(fn) to skrót od .then(undefined, fn). Obsługuje każde odrzucenie z wcześniejszej części łańcucha. .catch() na końcu łańcucha to asynchroniczny odpowiednik try/catch na najwyższym poziomie: ostatnia linia obrony, zanim odrzucenie stanie się „nieobsłużone”.

Mieszanie obu stylów jest w porządku. Częsty wzorzec to używanie async/await wewnątrz funkcji i pozostawienie .catch() kodowi wywołującemu:

fetch nie odrzuca obietnicy przy błędach HTTP

Na tym każdy przynajmniej raz się złapie. fetch odrzuca obietnicę tylko przy awariach na poziomie sieci: nieudane wyszukiwanie DNS, odrzucone połączenie, przerwane żądanie. Odpowiedź 404 czy 500 uznawana jest za udane pobranie. Obietnica się rozwiązuje, tylko że z odpowiedzią, której ok ma wartość false.

Jeśli chcesz, by błędy HTTP trafiały do bloku catch, sprawdź res.ok i rzuć błąd jawnie:

Ten szablonowy kod warto wydzielić do funkcji pomocniczej, gdy tylko napiszesz go po raz drugi.

Promise.all przerywa od razu, Promise.allSettled nie

Promise.all przyjmuje tablicę obietnic i rozwiązuje się tablicą wyników, chyba że któraś zostanie odrzucona: wtedy od razu odrzuca się z tym błędem. Pozostałe obietnice nadal działają, ale ich wyniki są wyrzucane.

Natychmiastowe przerwanie (fail-fast) to właściwe zachowanie, gdy potrzebujesz każdego wyniku, a jeden błąd pozbawia sensu całą operację. Gdy chcesz poznać każdy wynik niezależnie od tego, np. „spróbuj tych pięciu uploadów i powiedz, które się udały, a które nie”, użyj Promise.allSettled:

allSettled nigdy się nie odrzuca. Każdy wpis to albo {status: "fulfilled", value}, albo {status: "rejected", reason}.

Ponowne rzucanie i wąskie bloki catch

Nie każdy błąd pasuje do tego samego handlera. Częsty wzorzec to złapać błąd, sprawdzić go i ponownie rzucić wszystko, czego się nie spodziewasz:

Połykanie każdego błędu przez gołe catch (err) {} ukrywa prawdziwe bugi. Łap to, co potrafisz sensownie obsłużyć, a resztę rzucaj dalej.

Nieobsłużone odrzucenia to twoja siatka bezpieczeństwa

Nawet przy starannym kodzie coś w końcu się prześlizgnie. Zarówno Node.js, jak i przeglądarki udostępniają globalny hook dla odrzuceń, których nikt nie złapał:

// Browser
window.addEventListener("unhandledrejection", event => {
    console.error("nieobsłużone:", event.reason);
    event.preventDefault(); // wycisza domyślne ostrzeżenie w konsoli
});

// Node.js
process.on("unhandledRejection", reason => {
    console.error("nieobsłużone:", reason);
});

To nie zastępuje właściwej obsługi błędów: to ostatni punkt logowania albo telemetrii. W nowoczesnym Node.js nieobsłużone odrzucenie domyślnie zatrzymuje proces i zwykle właśnie tego chcesz na produkcji. Zaloguj błąd, a potem pozwól procesowi się zakończyć i uruchomić na czysto od nowa.

Praktyczna ściągawka

Gdy funkcja async dotyka czegokolwiek, co może się nie udać, zadaj sobie pytania:

  • Czy każdy ryzykowny await jest wewnątrz try/catch, albo czy zwróconą obietnicę obsługuje kod wywołujący przez .catch()?
  • Czy naprawdę używam await przy wywołaniu, czy przypadkiem wywołuję ją w trybie fire-and-forget?
  • W przypadku fetch: czy sprawdzam res.ok, zanim zaufam odpowiedzi?
  • Przy równoległym uruchamianiu: czy Promise.all to właściwe narzędzie, czy potrzebuję Promise.allSettled?
  • Czy jest .catch() na najwyższym poziomie albo handler unhandledrejection, żeby nic nie znikało po cichu?

Zadbaj o te pięć punktów, a twój kod async przestanie cię zaskakiwać błędami, które znikają w pętli zdarzeń.

Dalej: moduły ES

Obsługa błędów async zamyka rozdział o asynchroniczności. Dalej przechodzimy do tego, jak kod JavaScript dzieli się na pliki: import, export i system modułów, na którym opiera się każdy nowoczesny projekt.

Najczęściej zadawane pytania

Jak obsługiwać błędy w funkcji async?

Umieść wywołania await w bloku try/catch. Każde odrzucenie oczekiwanej obietnicy zamienia się w rzucony błąd, który trafia do catch. Możesz też pozwolić błędowi się propagować i obsłużyć go w miejscu wywołania przez .catch() na zwróconej obietnicy.

Dlaczego mój try/catch nie łapie błędu?

Zwykle dlatego, że błąd pojawia się w kodzie, na który nie czekasz przez await. Jeśli wywołasz funkcję async bez await (albo bez zwrócenia jej obietnicy), każde odrzucenie ucieka poza otaczający try/catch. Zawsze używaj await albo return dla obietnicy, której błędy chcesz złapać.

Co się dzieje, gdy obietnica zostanie odrzucona i nic tego nie złapie?

Dostajesz nieobsłużone odrzucenie. Node.js emituje zdarzenie unhandledRejection i w nowszych wersjach domyślnie zatrzymuje proces. Przeglądarki wywołują window.onunhandledrejection i wypisują ostrzeżenie. W obu przypadkach dodaj .catch() albo obsłuż błąd w try/catch wokół await.

Jak Promise.all obsługuje błędy?

Promise.all odrzuca się, gdy tylko którakolwiek z obietnic wejściowych zostanie odrzucona. Pozostałe obietnice nadal działają, ale ich wyniki są odrzucane. Jeśli chcesz poznać każdy wynik niezależnie od błędów, użyj Promise.allSettled: rozwiązuje się tablicą wpisów {status, value} lub {status, reason}.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ