Enveloppez un appel fetch() dans un try/catch, ciblez un endpoint qui renvoie un 404, et le bloc catch ne s’exécute jamais. La promise se résout. res.json() analyse le corps renvoyé par le serveur, souvent un payload d’erreur du type {"error": "user not found"}, et le reste de la fonction le traite comme l’enregistrement demandé. Aucune exception, aucun avertissement dans la console. La mauvaise donnée continue son chemin jusqu’à ce qu’autre chose se casse à cause d’elle.

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; // sur un 404, ceci vaut { error: "user not found" }
  } catch (err) {
    console.error('Failed to load user:', err);
  }
}

Ce catch ne se déclenche que pour une requête qui ne se termine jamais. Tout le reste demande une vérification que vous devez écrire vous-même.

Pourquoi fetch() traite-t-il 404 et 500 comme un succès ?

fetch() est rejetée quand la requête elle-même échoue : la résolution DNS échoue, la connexion se coupe en plein transfert, une politique CORS bloque la réponse avant qu’elle n’atteigne votre code, le réseau de l’utilisateur disparaît. Dans chacun de ces cas, aucune réponse du serveur ne vous est jamais parvenue.

Un 404 ou un 500, c’est un serveur qui répond. La connexion a fonctionné et une réponse est revenue avec des en-têtes et un corps. L’échange s’est terminé ; il s’est terminé avec une mauvaise nouvelle. La spécification Fetch considère cela comme un fetch réussi et vous laisse le soin d’inspecter le code de statut. response.ok vaut true pour tout statut compris entre 200 et 299, et false pour tout le reste, y compris les réponses 4xx/5xx et les redirections que fetch n’a pas suivies.

C’est le détail qui piège les habitués d’Axios, qui rejette par défaut tout statut non-2xx. Aucun des deux comportements n’est fautif en soi. La Fetch API ne rejette que pour des échecs au niveau du transport, sur l’idée qu’un 404 n’est pas quelque chose d’exceptionnel sur le web. Mais si vous attendez la sémantique d’Axios de la part de fetch(), vous finissez par livrer le bug ci-dessus.

Comment vérifier response.ok et lever votre propre erreur ?

Un if, placé avant de toucher au corps :

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

Ça suffit pour un script. Avec plus d’un point d’appel, répéter ce bloc partout revient à garantir que l’un d’eux finira par être oublié. Écrivez-le une seule fois, dans un wrapper :

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

Chaque appelant reçoit maintenant une vraie exception sur un mauvais statut, avec le code de statut et le corps de la réponse du serveur attachés.

Comment lire le corps d’erreur quand ce n’est pas du JSON ?

Un 500 venant de votre propre API revient probablement en JSON. Un 502 d’un reverse proxy ou une page de blocage d’un WAF revient généralement en HTML. Appelez res.json() sur ça et vous obtenez une SyntaxError (“Unexpected token ‘<’”) qui n’a rien à voir avec l’échec réel et vous fait déboguer la mauvaise chose.

Le fetchJSON ci-dessus évite ça en lisant le corps d’erreur avec res.text(), qui ne se soucie pas de savoir si le contenu est du JSON valide. Si vous voulez l’objet analysé quand c’est effectivement du JSON, vérifiez d’abord le 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);
}

Le corps d’une Response ne peut être lu qu’une seule fois. Si vous avez besoin du texte brut pour les logs et du JSON analysé pour l’appelant, appelez d’abord res.clone() et lisez une copie chacun. Lire l’original deux fois lève “body stream already read.”

Comment distinguer un échec réseau d’une erreur HTTP ?

Une fois que fetchJSON lève une exception dans les deux cas, un appelant qui veut réagir différemment doit vérifier lequel des deux il a reçu :

 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(); // échec DNS, connexion coupée, blocage CORS
  }
}

Un échec réseau apparaît comme un simple TypeError sans code de statut, parce qu’il n’y a jamais eu de réponse pour en tirer un. Le message varie selon le navigateur : “Failed to fetch” dans Chrome, “NetworkError when attempting to fetch resource” dans Firefox. Distinguez donc selon le type, pas selon le message.

Il y a un cas qui ressemble à un échec réseau mais n’en est pas un. Si une requête cross-origin échoue avec ce TypeError alors que les logs du serveur montrent qu’elle est arrivée et a répondu 200, c’est que le navigateur a bloqué la réponse avant que votre code ne la voie. Le correctif se trouve dans les en-têtes CORS du serveur, pas dans la gestion d’erreurs du client ; /fr/posts/web/quest-ce-que-cors-comment-resoudre-les-erreurs-cors/ explique ce que ces en-têtes doivent dire.

Comment ajouter un timeout à fetch() ?

fetch() n’a pas de timeout. Elle attend aussi longtemps que la connexion reste ouverte, donc un backend qui se bloque au lieu de renvoyer une erreur laisse la requête en suspens indéfiniment. AbortSignal.timeout() la coupe :

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

Après 5 secondes, la requête s’interrompt et la promise est rejetée avec un TimeoutError, distinct de l’AbortError obtenu en annulant une requête manuellement :

1
2
3
4
5
6
7
try {
  await fetchWithTimeout('/api/slow-report');
} catch (err) {
  if (err.name === 'TimeoutError') {
    // la requête était encore en cours à 5s
  }
}

AbortSignal.timeout() a atteint le statut Baseline en 2024, c’est-à-dire qu’il est pris en charge par tous les navigateurs majeurs actuels. Pour un navigateur ou un runtime plus ancien, reconstruisez la même chose avec setTimeout et un AbortController :

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

Ce fallback rejette avec AbortError, pas TimeoutError, donc une vérification comme celle ci-dessus doit accepter les deux noms.

Si l’appelant a aussi besoin d’annuler pour ses propres raisons (l’utilisateur a fermé la boîte de dialogue, une requête plus récente a remplacé celle-ci), combinez les deux signaux avec AbortSignal.any([ownSignal, timeoutSignal]) plutôt que d’en choisir un seul. AbortSignal.any() est plus récent que timeout(), donc vérifiez que vos runtimes cibles le supportent.

fetch vs Axios : lequel lève une erreur sur un 404 ?

fetch()Axios
Réponse 4xx / 5xxSe résout ; vérifiez response.okEst rejetée ; atterrit dans le catch
Corps de la réponseAppelez .json() / .text() vous-mêmeAnalysé automatiquement dans response.data
Timeout de la requêteAucun intégré ; utilisez AbortSignal.timeout()Option de configuration timeout
Échec réseau / CORSRejetée avec un TypeErrorRejetée avec une AxiosError

fetch() est intégrée à tous les runtimes modernes, donc il n’y a rien à installer. Axios offre, dès le départ, le rejet automatique sur mauvais statut et le parsing JSON, au prix d’une dépendance. Si le code utilise déjà Axios pour ses intercepteurs ou l’annulation, utilisez ses réglages par défaut. Sinon, le wrapper fetchJSON ci-dessus vous donne le même comportement en une vingtaine de lignes.

Quelles erreurs laissent les échecs de fetch non gérés ?

  • Vérifier response.ok après avoir appelé .json(). À ce stade, le corps a déjà été consommé, et sur une page d’erreur HTML, le parsing a déjà levé une exception. Vérifiez ok d’abord, lisez le corps ensuite.
  • Appeler une fonction asynchrone depuis un event handler sans await ni .catch(). onClick={() => fetchJSON(url)} perd le rejet : aucun bloc catch ne s’exécute, et selon le runtime vous obtenez un avertissement d’unhandled rejection ou rien du tout. /fr/posts/javascript/async-await-et-event-loop-javascript/ explique où va cette promise rejetée.
  • Un catch qui logue et renvoie undefined. catch (err) { console.error(err); } avale l’erreur. L’appelant reçoit undefined là où il attendait des données et plante ailleurs, plus loin de la cause réelle.
  • Supposer que tout corps non-2xx est du JSON. C’est la cause habituelle de “mon gestionnaire d’erreurs a levé une erreur différente”. Lisez avec .text() ou vérifiez d’abord le content-type.

Où la vérification de response.ok devrait-elle vivre ?

Dans un seul module. Avec fetch(), le silence est le comportement par défaut, donc tout appel sans vérification explicite du statut reproduit le bug du début de cet article. Écrivez fetchJSON et HttpError une seule fois, ajoutez la gestion du timeout et du content-type décrite plus haut, et faites-en la seule façon dont votre application parle à un serveur. Une règle de lint ou une habitude de revue de code qui signale les appels fetch() nus en dehors de ce module maintient cette discipline.

Articles associés