Owiń wywołanie fetch() w try/catch, trafiasz na endpoint, który odpowiada 404, a blok catch nigdy się nie uruchamia. Promise się rozwiązuje. res.json() parsuje to, co serwer odesłał, często payload błędu w stylu {"error": "user not found"}, a reszta funkcji traktuje to jak żądany rekord. Żadnego wyjątku, żadnego ostrzeżenia w konsoli. Złe dane płyną dalej, aż coś inne się na nich wywala.

1
2
3
4
5
6
7
8
9
async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    const user = await res.json();
    return user; // przy 404 to jest { error: "user not found" }
  } catch (err) {
    console.error('Failed to load user:', err);
  }
}

Ten catch odpala się tylko dla żądania, które nigdy się nie kończy. Wszystko inne wymaga sprawdzenia, które musisz napisać samodzielnie.

Czemu fetch() traktuje 404 i 500 jako sukces?

fetch() odrzuca promise, gdy samo żądanie zawodzi: nie działa rozwiązywanie DNS, połączenie zrywa się w połowie transferu, polityka CORS blokuje odpowiedź przed dotarciem do twojego kodu, sieć użytkownika przestaje działać. W każdym z tych przypadków żadna odpowiedź serwera nigdy do ciebie nie dotarła.

404 albo 500 to serwer, który odpowiada. Połączenie zadziałało i wróciła odpowiedź z nagłówkami i treścią. Wymiana zakończyła się; zakończyła się złą wiadomością. Specyfikacja Fetch liczy to jako udany fetch i zostawia tobie sprawdzenie kodu statusu. response.ok jest true dla każdego statusu w zakresie 200–299 i false dla wszystkiego innego, w tym odpowiedzi 4xx/5xx i przekierowań, za którymi fetch nie poszedł.

To jest ten szczegół, który łapie każdego, kto przychodzi z Axios. Tam domyślnie każdy status inny niż 2xx odrzuca promise. Żadne z tych dwóch zachowań nie jest błędne. Fetch API odrzuca promise tylko przy błędach na poziomie transportu, z założeniem, że 404 w sieci to nic wyjątkowego. Ale jeśli oczekujesz od fetch() semantyki Axios, wypuszczasz powyższy bug na produkcję.

Jak sprawdzić response.ok i rzucić własny błąd?

Jedno if, umieszczone przed dotknięciem treści:

1
2
3
4
5
6
7
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    throw new Error(`GET /api/users/${id} failed with ${res.status}`);
  }
  return res.json();
}

Do skryptu to wystarczy. Gdy miejsc wywołania jest więcej niż jedno, powtarzanie tych linii w każdym z nich kończy się tak samo: wcześniej czy później jedno zostanie pominięte. Owiń to raz:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class HttpError extends Error {
  constructor(response, body) {
    super(`${response.status} ${response.statusText}`);
    this.name = 'HttpError';
    this.status = response.status;
    this.body = body;
  }
}

async function fetchJSON(url, options) {
  const res = await fetch(url, options);
  if (!res.ok) {
    const body = await res.text().catch(() => '');
    throw new HttpError(res, body);
  }
  return res.json();
}

Teraz każdy wywołujący dostaje prawdziwy wyjątek przy złym statusie, z kodem statusu i treścią odpowiedzi serwera w komplecie.

Jak czytać treść błędu, gdy nie jest to JSON?

500 z twojego własnego API prawdopodobnie wraca jako JSON. 502 z reverse proxy albo strona blokady z WAF zwykle wraca jako HTML. Wywołaj na tym res.json() i dostajesz SyntaxError („Unexpected token ‘<’”), który nie ma nic wspólnego z rzeczywistą usterką i wysyła cię debugować coś innego.

Powyższe fetchJSON tego unika, czytając treść błędu przez res.text(), niezależnie od tego, czy zawartość jest poprawnym JSON-em. Jeśli chcesz sparsowany obiekt, gdy faktycznie jest to JSON, sprawdź wcześniej content type:

1
2
3
4
5
6
7
if (!res.ok) {
  const contentType = res.headers.get('content-type') ?? '';
  const body = contentType.includes('application/json')
    ? await res.json().catch(() => null)
    : await res.text();
  throw new HttpError(res, body);
}

Treść Response można odczytać tylko raz. Jeśli potrzebujesz surowego tekstu do logów i sparsowanego JSON-a dla wywołującego, wywołaj najpierw res.clone() i przeczytaj każdą kopię osobno. Odczytanie oryginału dwa razy rzuca „body stream already read.”

Jak odróżnić błąd sieci od błędu HTTP?

Gdy fetchJSON rzuca wyjątek w obu przypadkach, wywołujący musi sam sprawdzić, który z dwóch to był, żeby zareagować inaczej:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
try {
  const user = await fetchJSON(`/api/users/${id}`);
  render(user);
} catch (err) {
  if (err instanceof HttpError && err.status === 404) {
    showNotFound();
  } else {
    showConnectionError(); // błąd DNS, zerwane połączenie, blokada CORS
  }
}

Błąd sieci objawia się jako prosty TypeError bez kodu statusu, bo nigdy nie było odpowiedzi, z której dałoby się go odczytać. Treść komunikatu różni się między przeglądarkami: „Failed to fetch” w Chrome, „NetworkError when attempting to fetch resource” w Firefoksie. Rozróżniaj więc na podstawie typu, nie treści komunikatu.

Jest jeden przypadek, który wygląda jak błąd sieci, ale nim nie jest. Jeśli żądanie cross-origin zawodzi z tym TypeError, a logi serwera pokazują, że dotarło i odpowiedziało 200, przeglądarka zablokowała odpowiedź, zanim twój kod ją zobaczył. Poprawka leży w nagłówkach CORS serwera, nie w obsłudze błędów klienta. /pl/posts/web/czym-jest-cors-jak-naprawic-bledy-cors/ wyjaśnia, co te nagłówki muszą mówić.

Jak dodać timeout do fetch()?

fetch() nie ma timeoutu. Czeka tak długo, jak połączenie zostaje otwarte, więc backend, który się zawiesza zamiast zwrócić błąd, trzyma żądanie otwarte bez końca. AbortSignal.timeout() je przerywa:

1
2
3
4
5
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const res = await fetch(url, { ...options, signal: AbortSignal.timeout(timeoutMs) });
  if (!res.ok) throw new HttpError(res, await res.text());
  return res.json();
}

Po 5 sekundach żądanie zostaje przerwane i promise jest odrzucana z TimeoutError, innym niż AbortError, który dostajesz przy ręcznym anulowaniu żądania:

1
2
3
4
5
6
7
try {
  await fetchWithTimeout('/api/slow-report');
} catch (err) {
  if (err.name === 'TimeoutError') {
    // żądanie wciąż trwało w 5s
  }
}

AbortSignal.timeout() osiągnęło dostępność Baseline, czyli wsparcie we wszystkich obecnych głównych przeglądarkach, w 2024 roku. Dla starszej przeglądarki lub środowiska zbuduj to samo z setTimeout i AbortController:

1
2
3
4
5
function timeoutSignal(ms) {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), ms);
  return controller.signal;
}

Ten fallback odrzuca z AbortError, nie TimeoutError, więc sprawdzenie jak powyżej musi uwzględniać obie nazwy.

Jeśli wywołujący musi też anulować z własnych powodów (użytkownik zamknął okno dialogowe, nowsze żądanie zastąpiło to), połącz oba sygnały przez AbortSignal.any([ownSignal, timeoutSignal]), zamiast wybierać jeden. AbortSignal.any() jest nowsze niż timeout(), więc sprawdź, czy twoje docelowe środowiska je wspierają.

fetch vs Axios: kto rzuca błąd przy 404?

fetch()Axios
Odpowiedź 4xx / 5xxRozwiązuje promise; sprawdź response.okOdrzuca promise; trafia w catch
Treść odpowiedziWywołaj .json() / .text() samodzielnieParsowana automatycznie do response.data
Timeout żądaniaBrak wbudowanego; użyj AbortSignal.timeout()Opcja konfiguracyjna timeout
Błąd sieci / CORSOdrzuca z TypeErrorOdrzuca z AxiosError

fetch() jest wbudowane w każde współczesne środowisko, więc nie ma czego instalować. Axios daje błąd przy złym statusie i automatyczne parsowanie JSON od razu, kosztem zależności. Jeśli kod już używa Axios dla interceptorów albo anulowania, korzystaj z jego domyślnych ustawień. Jeśli nie, powyższy wrapper fetchJSON daje to samo zachowanie w jakichś dwudziestu liniach.

Jakie pomyłki pozostawiają błędy fetch() niewyłapane?

  • Sprawdzanie response.ok po wywołaniu .json(). W tym momencie treść już została skonsumowana, a na stronie błędu HTML parsowanie już rzuciło wyjątek. Najpierw sprawdź ok, dopiero potem czytaj treść.
  • Wywołanie funkcji asynchronicznej z event handlera bez await albo .catch(). onClick={() => fetchJSON(url)} gubi odrzucenie: żaden blok catch się nie uruchamia, a w zależności od środowiska dostajesz ostrzeżenie o unhandled rejection albo nic. /pl/posts/javascript/async-await-i-event-loop-javascript/ wyjaśnia, gdzie taka odrzucona promise znika.
  • catch, który loguje i zwraca undefined. catch (err) { console.error(err); } przełyka błąd. Wywołujący dostaje undefined, gdzie oczekiwał danych, i wywala się gdzie indziej, dalej od rzeczywistej przyczyny.
  • Zakładanie, że każda treść inna niż 2xx to JSON. To zwykła przyczyna „mój error handler rzucił inny błąd”. Czytaj przez .text() albo najpierw sprawdź content-type.

Gdzie umieścić sprawdzanie response.ok w kodzie?

W jednym module. fetch() sprawia, że cisza jest zachowaniem domyślnym, więc każde wywołanie bez wyraźnego sprawdzenia statusu odtwarza błąd z początku tego artykułu. Napisz fetchJSON i HttpError raz, dodaj obsługę timeoutu i content-type opisaną wyżej i zrób z tego jedyny sposób, w jaki twoja aplikacja rozmawia z serwerem. Reguła lintera albo zwyczaj w code review, który wyłapuje gołe wywołania fetch() poza tym modułem, utrzymuje tę dyscyplinę.

Powiązane artykuły