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.
| |
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 :
| |
Ç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 :
| |
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 :
| |
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 :
| |
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 :
| |
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 :
| |
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 :
| |
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 / 5xx | Se résout ; vérifiez response.ok | Est rejetée ; atterrit dans le catch |
| Corps de la réponse | Appelez .json() / .text() vous-même | Analysé automatiquement dans response.data |
| Timeout de la requête | Aucun intégré ; utilisez AbortSignal.timeout() | Option de configuration timeout |
| Échec réseau / CORS | Rejetée avec un TypeError | Rejeté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.okaprè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érifiezokd’abord, lisez le corps ensuite. - Appeler une fonction asynchrone depuis un event handler sans
awaitni.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
catchqui logue et renvoieundefined.catch (err) { console.error(err); }avale l’erreur. L’appelant reçoitundefinedlà 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 lecontent-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.