Packen Sie einen fetch()-Aufruf in ein try/catch, rufen Sie einen Endpunkt auf, der mit 404 antwortet, und der catch-Block läuft nie. Das Promise wird aufgelöst. res.json() parst, was auch immer der Server zurückgeschickt hat, oft ein Fehler-Payload wie {"error": "user not found"}, und der Rest der Funktion behandelt es als den angeforderten Datensatz. Keine Exception, keine Konsolenwarnung. Die falschen Daten wandern weiter, bis etwas anderes daran zerbricht.

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; // bei einem 404 ist das { error: "user not found" }
  } catch (err) {
    console.error('Failed to load user:', err);
  }
}

Dieses catch greift nur bei einer Anfrage, die nie abgeschlossen wird. Alles andere braucht eine Prüfung, die Sie selbst schreiben müssen.

Warum behandelt fetch() 404 und 500 als Erfolg?

fetch() wird abgelehnt, wenn die Anfrage selbst fehlschlägt: die DNS-Auflösung schlägt fehl, die Verbindung bricht mitten in der Übertragung ab, eine CORS-Richtlinie blockiert die Antwort, bevor sie Ihren Code erreicht, das Netzwerk des Nutzers fällt aus. In keinem dieser Fälle kommt bei Ihnen überhaupt eine Serverantwort an.

Ein 404 oder ein 500 bedeutet: Der Server hat geantwortet. Die Verbindung hat funktioniert, eine Antwort mit Headern und Body ist zurückgekommen. Der Austausch ist abgeschlossen, nur mit einer schlechten Nachricht. Die Fetch-Spezifikation wertet das als erfolgreiches fetch und überlässt Ihnen die Prüfung des Statuscodes. response.ok ist true für jeden Status im Bereich 200–299 und false für alles andere, einschließlich 4xx/5xx-Antworten und Redirects, denen fetch nicht gefolgt ist.

Das ist das Detail, das jedem zum Verhängnis wird, der von Axios kommt, das standardmäßig jeden Nicht-2xx-Status ablehnt. Keines von beidem ist falsch. Die Fetch API lehnt nur bei Fehlern auf Transportebene ab, weil ein 404 im Web nichts Außergewöhnliches ist. Erwarten Sie von fetch() aber die Semantik von Axios, bauen Sie sich genau den Bug von oben ein.

Wie prüfen Sie response.ok und werfen Ihren eigenen Fehler?

Ein if, platziert bevor Sie den Body berühren:

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

Für ein Skript reicht das. Bei mehr als einer Aufrufstelle führt das Wiederholen dieser Zeilen überall dazu, dass irgendwann eine davon übersprungen wird. Packen Sie es einmal ein:

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

Jeder Aufrufer bekommt jetzt eine echte Exception bei einem schlechten Status, mit Statuscode und dem Response-Body des Servers im Gepäck.

Wie lesen Sie den Fehler-Body, wenn er kein JSON ist?

Ein 500 von Ihrer eigenen API kommt wahrscheinlich als JSON zurück. Ein 502 von einem Reverse Proxy oder eine Sperrseite von einer WAF kommt meist als HTML zurück. Rufen Sie res.json() darauf auf, und Sie erhalten einen SyntaxError (“Unexpected token ‘<’”), der mit dem eigentlichen Fehler nichts zu tun hat und Sie das Falsche debuggen lässt.

Das fetchJSON von oben vermeidet das, indem es den Fehler-Body mit res.text() liest, unabhängig davon, ob der Inhalt gültiges JSON ist. Wollen Sie das geparste Objekt für den Fall, dass es tatsächlich JSON ist, prüfen Sie zuerst den 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);
}

Der Body einer Response lässt sich nur einmal lesen. Wenn Sie den Rohtext zum Loggen und das geparste JSON für den Aufrufer brauchen, rufen Sie zuerst res.clone() auf und lesen Sie je eine Kopie. Das Original zweimal zu lesen, wirft “body stream already read.”

Wie unterscheiden Sie einen Netzwerkfehler von einem HTTP-Fehler?

Sobald fetchJSON in beiden Fällen wirft, muss ein Aufrufer, der unterschiedlich reagieren will, prüfen, welchen der beiden er erhalten hat:

 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(); // DNS-Fehler, abgebrochene Verbindung, CORS-Blockade
  }
}

Ein Netzwerkfehler erscheint als einfacher TypeError ohne Statuscode, weil es nie eine Antwort gab, aus der sich einer ablesen ließe. Die Meldung variiert je nach Browser: “Failed to fetch” in Chrome, “NetworkError when attempting to fetch resource” in Firefox. Unterscheiden Sie also nach dem Typ, nicht nach der Meldung.

Ein Fall sieht wie ein Netzwerkfehler aus, ist es aber nicht. Wenn eine Cross-Origin-Anfrage mit diesem TypeError fehlschlägt, während die Server-Logs zeigen, dass sie angekommen ist und mit 200 geantwortet hat, hat der Browser die Antwort blockiert, bevor Ihr Code sie gesehen hat. Die Korrektur liegt in den CORS-Headern des Servers, nicht in der Fehlerbehandlung des Clients. Was diese Header sagen müssen, erklärt Was ist CORS und wie behebt man CORS-Fehler.

Wie fügen Sie fetch() einen Timeout hinzu?

fetch() hat keinen Timeout. Es wartet so lange, wie die Verbindung offen bleibt, sodass ein Backend, das hängt statt einen Fehler zurückzugeben, die Anfrage unbegrenzt offen hält. AbortSignal.timeout() schneidet sie ab:

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

Nach 5 Sekunden bricht die Anfrage ab, und das Promise wird mit einem TimeoutError abgelehnt, verschieden vom AbortError, den Sie bei manuellem Abbruch einer Anfrage bekommen:

1
2
3
4
5
6
7
try {
  await fetchWithTimeout('/api/slow-report');
} catch (err) {
  if (err.name === 'TimeoutError') {
    // die Anfrage lief bei 5s noch
  }
}

AbortSignal.timeout() erreichte 2024 Baseline-Verfügbarkeit, also Unterstützung in allen aktuellen gängigen Browsern. Für einen älteren Browser oder eine ältere Laufzeitumgebung bauen Sie dasselbe mit setTimeout und einem AbortController nach:

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

Dieser Fallback lehnt mit AbortError ab, nicht mit TimeoutError, also muss eine Prüfung wie oben beide Namen akzeptieren.

Wenn der Aufrufer aus eigenen Gründen ebenfalls abbrechen muss (der Nutzer hat den Dialog geschlossen, eine neuere Anfrage hat diese ersetzt), kombinieren Sie beide Signale mit AbortSignal.any([ownSignal, timeoutSignal]), statt eines auszuwählen. AbortSignal.any() ist neuer als timeout(), prüfen Sie also, ob Ihre Ziel-Laufzeitumgebungen es unterstützen.

fetch vs Axios: Was wirft bei einem 404?

fetch()Axios
4xx-/5xx-AntwortWird aufgelöst; response.ok prüfenWird abgelehnt; landet im catch
Response-Body.json() / .text() selbst aufrufenAutomatisch in response.data geparst
Request-TimeoutKeiner eingebaut; AbortSignal.timeout() nutzenKonfigurationsoption timeout
Netzwerk-/CORS-FehlerLehnt mit TypeError abLehnt mit einem AxiosError ab

fetch() ist in jede moderne Laufzeitumgebung eingebaut, da gibt es nichts zu installieren. Axios liefert Fehler-bei-schlechtem-Status und automatisches JSON-Parsing von Haus aus, auf Kosten einer Abhängigkeit. Wenn die Codebasis Axios ohnehin schon für Interceptoren oder Abbruch nutzt, verwenden Sie dessen Standardeinstellungen. Wenn nicht, gibt Ihnen der fetchJSON-Wrapper von oben dasselbe Verhalten in etwa zwanzig Zeilen.

Welche Fehler führen dazu, dass fetch()-Fehler unbehandelt bleiben?

  • response.ok nach dem Aufruf von .json() prüfen. Zu diesem Zeitpunkt ist der Body bereits verbraucht, und bei einer HTML-Fehlerseite hat das Parsen schon eine Exception geworfen. Erst ok prüfen, dann den Body lesen.
  • Eine asynchrone Funktion aus einem Event-Handler ohne await oder .catch() aufrufen. onClick={() => fetchJSON(url)} verliert die Ablehnung: Kein catch-Block läuft, und je nach Laufzeitumgebung bekommen Sie eine Unhandled-Rejection-Warnung oder gar nichts. Wohin ein abgelehntes Promise in diesem Fall verschwindet, erklärt Async/Await und die Event Loop in JavaScript.
  • Ein catch, das loggt und undefined zurückgibt. catch (err) { console.error(err); } schluckt den Fehler. Der Aufrufer erhält undefined, wo er Daten erwartet hat, und stürzt woanders ab, weiter weg von der eigentlichen Ursache.
  • Annehmen, dass jeder Nicht-2xx-Body JSON ist. Das ist die übliche Ursache für “mein Error Handler hat einen anderen Fehler geworfen”. Mit .text() lesen oder zuerst content-type prüfen.

Wo gehört die response.ok-Prüfung hin?

In genau einem Modul. fetch() macht Schweigen zum Standardverhalten, also reproduziert jeder Aufruf ohne explizite Statusprüfung den Bug vom Anfang dieses Artikels. Schreiben Sie fetchJSON und HttpError einmal, fügen Sie die Timeout- und Content-Type-Behandlung von oben hinzu, und machen Sie es zum einzigen Weg, über den Ihre App mit einem Server spricht. Eine Lint-Regel oder eine Code-Review-Gewohnheit, die nackte fetch()-Aufrufe außerhalb dieses Moduls meldet, hält diese Disziplin aufrecht.

Verwandte Artikel