HTTP & fetch : parler au réseau
Chaque écran qui « charge des données » fait la même chose sous le capot : il envoie un message texte à un serveur et attend un message texte en retour. Ce protocole s'appelle HTTP, l'outil du navigateur s'appelle fetch, et tu t'en sers déjà tous les jours sans le voir — Supabase le fait pour toi. Ce module soulève le capot : en entrevue frontend, HTTP est un incontournable absolu.
Le modèle requête/réponse — la conversation la plus simple du monde
HTTP (HyperText Transfer Protocol) repose sur une idée d'une simplicité désarmante : un client (ton navigateur, ton app mobile) envoie une requête à un serveur, qui renvoie une réponse. Une question, une réponse, fin de la conversation. Le client a toujours l'initiative : le serveur ne parle que quand on l'interroge.
Détail crucial, souvent demandé en entrevue : HTTP est sans état
(stateless). Chaque paire requête/réponse est indépendante : la
réponse envoyée, le serveur oublie tout. Imagine un guichet où l'employé perd la mémoire entre
chaque client — pour qu'il sache qui tu es, tu dois re-présenter ta carte à chaque
passage. D'où le jeton (Authorization: Bearer ...) embarqué dans chaque
requête authentifiée.
Ça a l'air d'un défaut — un serveur amnésique ! — mais c'est un choix génial : comme aucune requête ne dépend d'une conversation en cours, n'importe quel serveur peut répondre à n'importe quelle requête. On met dix serveurs identiques derrière la même adresse et on répartit la charge. Un des fondements de la scalabilité du web.
Avant d'envoyer une requête, il faut savoir où : c'est le rôle de l'URL, une adresse postale structurée. Prenons-en une de RendezVous, notre app-exemple :
https://rendezvous.app/api/services?service=bone-scan&lang=fr
└─┬─┘ └─────┬──────┘└─────┬─────┘└───────────┬────────────┘
protocole hôte chemin query string
Quatre morceaux, quatre rôles : comment parler, à qui, quoi demander, et avec quelles précisions.
-
Le protocole (
https://) : la langue de l'échange. Lessignifie que la conversation est chiffrée — personne entre toi et le serveur ne peut la lire. Aujourd'hui, tout est en HTTPS. -
L'hôte (
rendezvous.app) : le nom du serveur, que le DNS — l'annuaire du web — traduit en adresse IP. -
Le chemin (
/api/services) : la ressource demandée sur ce serveur. Un couloir et un numéro de porte dans l'immeuble. -
La query string (
?service=bone-scan&lang=fr) : des paramètres optionnels en pairesclé=valeurséparées par des&, toujours après le?. Ici : « filtre sur bone-scan, en français ».
Quand ton app exécute supabase.from('workouts').select(), le client Supabase
fabrique… une requête HTTP : un GET vers
https://tonprojet.supabase.co/rest/v1/workouts, tes filtres en query string et
ton jeton dans les en-têtes. C'est une jolie couche par-dessus fetch (module
Supabase : le backend). Ce module t'explique donc
quelque chose que tu utilises déjà — tu vas juste enfin le voir.
Anatomie d'une requête et d'une réponse
Une requête et une réponse HTTP sont — littéralement — du texte structuré,
lisible à l'œil nu. Chacune a trois parties, en miroir. La requête : ligne de
commande (méthode + chemin), en-têtes (métadonnées en Clé: valeur),
corps optionnel. La réponse : ligne de statut (le verdict chiffré),
en-têtes, corps (souvent du JSON, parfois du HTML, une image…).
Voici une vraie paire complète, telle qu'elle circule quand RendezVous charge sa liste de services. Lis-la lentement — c'est la Pierre de Rosette de tout le module :
─── REQUÊTE (du navigateur vers le serveur) ───────────────
GET /api/services HTTP/1.1
Host: rendezvous.app
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1...
(pas de corps : un GET ne fait que demander)
─── RÉPONSE (du serveur vers le navigateur) ───────────────
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 182
[
{ "id": "bone-scan", "name": "Scintigraphie osseuse", "durationMin": 45 },
{ "id": "blood-test", "name": "Prise de sang", "durationMin": 10 }
]
En haut : « donne-moi /api/services, je comprends le JSON, voici mon jeton ». En
bas : « 200, tout va bien, le voilà ». Toute l'infrastructure du web transporte des messages
de cette forme.
Remarque la symétrie : la requête ouvre par ce qu'elle veut, la réponse par comment ça s'est passé. Les en-têtes sont des post-its collés sur l'enveloppe : ils décrivent le contenu sans en faire partie. Le corps, séparé par une ligne vide, transporte les données. L'onglet Network des DevTools (dernière section) te présentera exactement ces trois blocs.
Les versions récentes optimisent le transport — plusieurs requêtes en parallèle sur une connexion, compression des en-têtes — mais le modèle ne change pas : méthode, chemin, en-têtes, statut, corps. Tout ce que tu apprends ici vaut pour toutes les versions : c'est la plomberie qui s'améliore, pas la langue.
Les méthodes : le verbe de la phrase
Le premier mot d'une requête est sa méthode — le verbe, qui annonce l'intention : lire, créer, modifier, supprimer ? Le serveur décide quoi faire selon le couple méthode + chemin. Cinq verbes couvrent l'essentiel :
| Méthode | Intention | Corps de requête ? | Analogie |
|---|---|---|---|
GET | Lire, sans rien changer | Non | Consulter le catalogue |
POST | Créer une ressource | Oui | Déposer un nouveau dossier |
PUT | Remplacer entièrement | Oui | Échanger le dossier complet |
PATCH | Modifier partiellement | Oui | Corriger une ligne du dossier |
DELETE | Supprimer | Rarement | Détruire le dossier |
La distinction la plus importante : GET est sans effet
(« safe ») — il ne doit jamais rien modifier côté serveur. C'est un contrat, pas une
contrainte technique, mais tout le web repose dessus : les navigateurs pré-chargent des GET,
les moteurs de recherche les suivent, les proxys les mettent en cache.
Deuxième notion, adorée des recruteurs : l'idempotence. Une opération est
idempotente si la refaire produit le même état que la faire une fois — appuyer cinq
fois sur « éteindre » laisse la lampe éteinte. GET, PUT et
DELETE le sont ; POST, non, puisqu'il crée
du nouveau : envoyer deux fois « réserver ce créneau » risque deux réservations. D'où les « ne
cliquez pas deux fois sur Payer » et la protection anti double-tap de tes boutons dans
Halterofit.
Ces verbes prennent leur sens dans le style d'API le plus répandu, REST : on expose des ressources (des noms au pluriel dans le chemin) et on agit dessus avec les méthodes. Le mapping canonique de RendezVous :
GET /api/appointments → lister mes rendez-vous
GET /api/appointments/42 → lire le rendez-vous n°42
POST /api/appointments → créer un rendez-vous (corps = les détails)
PATCH /api/appointments/42 → modifier le n°42 (ex. changer l'heure)
DELETE /api/appointments/42 → annuler le n°42
Remarque : le chemin ne contient que des noms (les ressources), jamais de
verbes — le verbe, c'est la méthode. Pas de /api/createAppointment : le POST
sur la collection dit déjà « créer ».
Toute l'app tient dans ce vocabulaire : GET /api/services pour l'accueil,
POST /api/appointments à la soumission du formulaire,
GET /api/appointments pour « Mes rendez-vous »,
DELETE /api/appointments/42 pour « Annuler ». Savoir lire un tableau de routes,
c'est connaître le squelette d'une application avant d'avoir vu son code.
L'utilisateur annule son rendez-vous, le métro entre dans un tunnel, il ne voit rien bouger
et retape sur « Annuler ». La deuxième requête revient en 404. Y
a-t-il un bug ? Et la réponse serait-elle la même si le bouton disait
« Réserver » ?
await fetch(`/api/appointments/${id}`, { method: 'DELETE' });
Pas de bug. DELETE est idempotent : rejouer la requête laisse
le serveur dans le même état — le rendez-vous n'existe plus, qu'on ait appuyé une
fois ou cinq. Le 404 du second appel ne dit pas « ça a échoué », il dit
« c'était déjà fait » ; une interface fine le traite donc comme un succès plutôt que comme
une erreur à afficher.
Avec un POST /api/appointments, la même rediffusion produirait
deux rendez-vous : POST crée du neuf à chaque appel. C'est là — et
seulement là — que le bouton désactivé pendant l'envoi est indispensable. D'où la
formulation exacte à retenir : idempotent ne veut pas dire « même réponse », mais
« même état après ».
Les codes de statut : le verdict en trois chiffres
Chaque réponse commence par un nombre à trois chiffres : le code de statut, le verdict du serveur. Le premier chiffre suffit à connaître la famille de l'histoire :
- 2xx — succès. Ta demande a été comprise et exécutée.
- 3xx — redirection. « C'est ailleurs, va voir là-bas » — le navigateur suit tout seul.
- 4xx — erreur du client. Ta requête a un problème : mal formée, pas authentifiée, ressource inexistante.
- 5xx — erreur du serveur. Ta requête était correcte, mais lui a planté.
Retiens la maxime, qui départage 90 % des débogages : 4xx = ta faute, 5xx = la sienne. Un 400 t'invite à corriger ce que tu envoies, un 500 à aller voir les logs du serveur. Le petit panthéon des codes que tu croiseras vraiment, chacun avec sa scène :
| Code | Nom | La scène dans RendezVous |
|---|---|---|
200 | OK | La liste des services arrive, l'écran s'affiche : la journée normale. |
201 | Created | Ta réservation a été créée — la réponse au POST réussi, souvent avec le nouvel objet dans le corps. |
301 | Moved Permanently | /rendez-vous redirige pour toujours vers /appointments ; le navigateur suit sans demander. |
400 | Bad Request | Le formulaire a envoyé une date au mauvais format : le serveur refuse de deviner. |
401 | Unauthorized | Ta session a expiré : « je ne sais pas qui tu es, reconnecte-toi ». |
403 | Forbidden | Tu consultes le rendez-vous d'un autre patient : « je sais qui tu es, et justement, non ». |
404 | Not Found | Le rendez-vous n°42 a été annulé hier ; l'URL en favori ne mène plus nulle part. |
409 | Conflict | Deux patients ont cliqué « Réserver » sur le même créneau : le second reçoit « quelqu'un t'a devancé ». |
500 | Internal Server Error | Le serveur a planté en enregistrant (bug, base injoignable…) : tu n'y peux rien, lui si. |
Note la nuance 401/403, grand classique d'entrevue : 401 = « je ne sais pas qui tu es » (authentification — présente ta carte) ; 403 = « je sais qui tu es, et tu n'as pas le droit » (autorisation — ta carte n'ouvre pas cette porte). Deux réactions différentes côté frontend : le 401 envoie vers l'écran de connexion, le 403 affiche « accès refusé ».
Le corps d'une réponse d'erreur contient souvent des détails précieux :
{ "error": "slot_taken", "message": "Ce créneau vient d'être réservé" }. Un bon
frontend lit ce corps pour afficher un message utile plutôt qu'un « une erreur est survenue »
générique. Le code dit la famille du problème ; le corps dit le problème.
Un patient ouvre par mégarde le lien du rendez-vous de son conjoint. Le serveur répond
403. Décris ce qu'il vit à l'écran dans les trente secondes qui suivent.
try {
const res = await fetch(`/api/appointments/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
render(await res.json());
} catch {
// « une erreur, c'est que la session a expiré »
router.push('/connexion');
}
Il est renvoyé à l'écran de connexion. Il se reconnecte de bonne foi, revient sur le lien…
et retombe sur le même 403, donc sur la même redirection. Une
boucle, et une boucle particulièrement cruelle : elle laisse croire à la
personne que c'est elle qui s'y prend mal.
Le catch a pourtant bien travaillé — c'est justement son mérite d'attraper les
deux familles d'échec au même endroit. Mais l'unification du filet n'autorise pas
l'unification de la réaction : il faut relire le statut avant de décider.
401 = « je ne sais pas qui tu es » → connexion. 403 = « je sais
qui tu es, et non » → « accès refusé », se reconnecter n'y changera rien. Panne réseau
(fetch rejeté, donc aucun statut du tout) → « réessayer ». Un filet, trois
réactions.
Les en-têtes qui comptent (et le JSON comme lingua franca)
Il existe des dizaines d'en-têtes HTTP, mais en tant que frontend, deux reviennent dans quasiment chaque requête que tu écriras :
Content-Type déclare le format du corps ; sans lui, le
destinataire reçoit des octets et doit deviner. application/json,
text/html, image/png… Quand toi tu envoies du JSON dans un
POST, c'est à toi de poser cet en-tête — l'oublier est une cause classique de 400.
Authorization transporte ta preuve d'identité, au format
Bearer <jeton> — une longue chaîne signée (souvent un JWT) obtenue à la
connexion. Bearer signifie « porteur » : qui porte le jeton est considéré comme son
propriétaire, d'où l'importance de ne jamais le faire fuiter. HTTP étant sans état, il part
avec chaque requête — ce que le client Supabase fait silencieusement pour ton
app.
Et le corps ? Dans les API modernes, presque toujours du JSON (JavaScript Object Notation) : du texte décrivant objets et tableaux, de syntaxe presque identique aux littéraux JavaScript (clés entre guillemets doubles, pas de fonctions, pas de commentaires). Deux fonctions font le pont entre le monde JS et le monde texte :
// Objet JS → chaîne JSON : pour ENVOYER (le réseau ne transporte que du texte)
const corps = JSON.stringify({ serviceId: 'bone-scan', date: '2026-08-12' });
// → '{"serviceId":"bone-scan","date":"2026-08-12"}'
// Chaîne JSON → objet JS : pour RECEVOIR (redonner vie au texte)
const objet = JSON.parse('{"id":42,"status":"confirmed"}');
// → { id: 42, status: 'confirmed' } — un vrai objet, avec objet.status
stringify pour émettre, parse pour recevoir. Le réseau ne transporte
que du texte ; ces deux fonctions traduisent dans les deux sens.
fetch en profondeur — et LE piège à connaître
Passons à l'outil. fetch est la fonction intégrée au navigateur (et à Node, et à
React Native) pour envoyer des requêtes HTTP : tu lui donnes une URL et des options, elle te
rend… une Promesse. Tout ce que tu sais de l'asynchrone
(JavaScript moderne & l'asynchrone) s'applique mot
pour mot.
Mais il y a une subtilité que beaucoup de juniors ratent : fetch se
déroule en deux temps, chacun avec sa Promesse. Le premier
await fetch(...) se résout dès que les en-têtes sont arrivés — tu obtiens
un Response qui connaît le statut, mais dont le corps coule peut-être encore dans
le tuyau. Pour lire ce corps, il faut un deuxième await :
response.json() rend elle aussi une Promesse.
// Temps 1 : envoyer la requête, attendre le début de la réponse.
const res = await fetch('/api/services');
// L'objet Response sait déjà tout du VERDICT :
res.status // → 200 (le code de status, un nombre)
res.ok // → true si le status est entre 200 et 299, false sinon
// Temps 2 : lire le corps en entier et le parser comme du JSON.
// C'est une DEUXIÈME Promesse → un deuxième await.
const services = await res.json();
// services est maintenant un vrai tableau d'objets JS
res.json() finit de télécharger le corps, puis lui applique l'équivalent de
JSON.parse. D'où la Promesse : c'est encore de l'attente.
Pour envoyer des données — notre réservation — on passe un deuxième argument à
fetch : un objet d'options avec méthode, en-têtes et corps. Toute la théorie des
sections précédentes s'assemble ici :
const res = await fetch('/api/appointments', {
method: 'POST', // le verbe : créer
headers: {
'Content-Type': 'application/json', // "le corps est du JSON"
'Authorization': `Bearer ${token}`, // la carte, à chaque requête
},
body: JSON.stringify({ // objet JS → texte JSON
serviceId: 'bone-scan',
date: '2026-08-12',
time: '14:30',
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); // on y revient juste après
const appointment = await res.json(); // ex. { id: 57, status: 'confirmed' }
Chaque option de fetch correspond à un morceau du message HTTP de la section
« Anatomie d'une requête et d'une réponse » : method = le verbe,
headers = les post-its, body = le colis.
Venons-en au piège central du module, celui qui justifie la ligne
if (!res.ok). Intuition naturelle : « un 404 ou un 500 est une erreur, donc la
Promesse de fetch est rejetée et mon catch l'attrape ».
Faux. Pour fetch, un 500 est une réussite : la requête est
partie, une réponse est revenue. Le facteur a livré l'enveloppe ; que la lettre dise « non »
n'est pas son problème. fetch ne rejette que si le réseau lui-même
a échoué. C'est donc à toi de vérifier le verdict — la ligne que les juniors
oublient, parce qu'en développement tout renvoie 200.
Le fetch honnête
const res = await fetch('/api/services');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const services = await res.json();
Les trois lignes que tout frontend tape chaque semaine : demander, vérifier le verdict, lire le corps. La deuxième ligne est celle que les juniors oublient — sans elle, un 500 passe pour un succès et le crash arrive plus loin, méconnaissable.
Ce code tourne en développement depuis des semaines sans problème. Un soir, en production, l'API des services tombe en panne et renvoie des 500 :
async function loadServices() {
const res = await fetch('/api/services');
const services = await res.json();
return services.map((s) => s.name);
}
Questions : (1) Qu'est-ce qui manque ? (2) Que se passe-t-il, ligne par ligne, quand le serveur renvoie un 500 dont le corps est la page HTML « Internal Server Error » ? (3) Pourquoi le bug ne s'est-il jamais montré en développement ?
Voir le corrigé
(1) La vérification if (!res.ok) throw new Error(...) —
la deuxième ligne du fetch honnête.
(2) Le await fetch réussit (le serveur a répondu — fetch ne
rejette pas sur un 500). Puis res.json() tente de parser la page HTML comme du
JSON et rejette avec une erreur du genre
Unexpected token '<' — le < de <html>.
L'erreur qui remonte parle donc de JSON invalide, alors que le vrai problème est un serveur
en panne : diagnostic brouillé. Pire : si le serveur avait renvoyé un 500 avec un corps JSON
({ "error": "..." }), res.json() aurait réussi, et le
.map aurait planté encore plus loin, sur un objet qui n'est pas un tableau.
(3) Parce qu'en développement, le serveur répondait toujours 200 : la ligne manquante n'était jamais sollicitée. C'est la signature de ce bug — invisible tant que tout va bien, incompréhensible dès que quelque chose va mal.
Le serveur répond pourtant un 200 parfaitement normal. La console affiche
undefined, puis services.map is not a function. Que manque-t-il, et
comment reconnaître cette signature ailleurs ?
const res = await fetch('/api/services');
const services = res.json();
console.log(services.length);
const names = services.map((s) => s.name);
Il manque un await devant res.json(). Sans lui,
services n'est pas le tableau : c'est la Promesse qui le
fournira plus tard. Une Promesse n'a pas de .length (d'où
undefined, sans erreur) ni de .map (d'où l'exception, une ligne
plus bas).
La cause profonde est le déroulement en deux temps : le premier
await te rend la main dès les en-têtes — statut compris — alors que le corps
coule peut-être encore ; res.json() finit de le télécharger puis le
parse, donc elle attend à son tour, donc elle rend une Promesse. Deux attentes, deux
await. Garde la signature en tête : une valeur qui a l'air « vide » et à
laquelle aucune méthode du type attendu ne répond, c'est presque toujours une Promesse
qu'on a oublié d'attendre.
try/catch autour d'un await fetch : quelles erreurs atterrissent où
Armé du fetch honnête, tu peux tracer la carte complète des échecs. Autour d'un
await fetch dans un try/catch, il n'existe que trois
chemins — savoir lequel produit quoi distingue le frontend qui débogue vite de celui
qui patauge :
| Chemin | Ce que fait fetch | Où ça atterrit | Ce que l'utilisateur doit voir |
|---|---|---|---|
| Panne réseau pas de wifi, hôte injoignable, blocage CORS |
fetch rejette : la requête n'a jamais abouti. |
Le catch, avec un TypeError: Failed to fetch — message du
navigateur, pas de toi. |
« Connexion impossible. Réessayer ? » : l'action est rejouable telle quelle. |
| Réponse d'erreur 404, 409, 500… |
fetch réussit (le serveur a répondu) et
res.ok vaut false. |
Le catch aussi, mais par ton throw — donc avec
ton message, lisible. |
Le message du corps d'erreur s'il existe (« Ce créneau vient d'être réservé »). Un 401 redirige vers la connexion. |
| Succès | On passe le if, res.json() parse. |
La suite du try. |
Les données — et la fin du spinner. |
Deux enseignements. Le catch unifie les deux familles d'échec en
un seul endroit où réagir — c'est précisément pour ça qu'on lance soi-même une erreur sur
!res.ok, pour faire tomber les deux cas dans le même filet. Et une requête a
trois moments — ça charge, ça a marché, ça a raté —
qu'une interface honnête affiche tous les trois :
afficherChargement(); // 1. ça charge
try { /* le fetch honnête, puis afficherListe(services) */ } // 2. ça a marché
catch { afficherErreur('Impossible de charger. Réessayer ?'); } // 3. ça a raté
finally { masquerChargement(); } // toujours : éteindre le spinner
C'est mot pour mot la structure du handleSignUp décortiqué dans
l'asynchrone du guide Halterofit — sablier avant,
try/catch autour, finally pour éteindre. Inutile de la
re-décortiquer : même patron, autre contexte.
En React, afficherChargement et afficherErreur deviendront des états
(isLoading, error) qui pilotent le rendu, et Next.js fournira même
des fichiers dédiés loading et error par page. La logique à trois
moments, elle, ne changera plus jamais.
Dans les tests Jest de ton app mobile, tu utilises msw (Mock Service
Worker) : tu peux maintenant dire précisément ce qu'il fait. Il intercepte les requêtes
HTTP que ton code émet et y répond avec des réponses que le test contrôle — un 200 avec
du JSON préparé pour le chemin heureux, un 500 fabriqué pour vérifier que ton
catch fonctionne. Tester « que se passe-t-il sur un 500 ? » sans faire tomber un
vrai serveur.
Voici une paire requête/réponse brute, capturée sur le réseau. Décode-la :
POST /api/appointments HTTP/1.1
Host: rendezvous.app
Content-Type: application/json
Authorization: Bearer eyJhbGci...
{"serviceId":"blood-test","date":"2026-08-12","time":"09:00"}
───────────────────────────────────────────
HTTP/1.1 409 Conflict
Content-Type: application/json
{"error":"slot_taken","message":"Ce créneau vient d'être réservé."}
Questions : (1) Méthode et intention ? (2) Que contient le corps de la
requête, et pourquoi l'en-tête Content-Type est-il indispensable ici ?
(3) Statut de la réponse : famille, fautif, et scène probable ? (4) Le catch
d'un fetch nu (sans vérification de res.ok) aurait-il vu passer
cette erreur ?
Voir le corrigé
(1) POST sur /api/appointments : créer un
rendez-vous — l'action non idempotente par excellence.
(2) Les détails de la réservation en JSON (service, date, heure). Le
Content-Type: application/json dit au serveur comment interpréter ces octets ;
sans lui, beaucoup de serveurs répondraient 400 sans même lire le corps.
(3) 409 Conflict : famille 4xx, donc « faute » du client — non pas un bug, mais une demande devenue impossible : quelqu'un a réservé ce créneau entre l'affichage de la page et le clic. Le corps le dit en toutes lettres, et un bon frontend affiche ce message et rafraîchit les créneaux.
(4) Non. Le serveur a répondu : pour fetch,
c'est un succès, la Promesse est tenue. Sans if (!res.ok) throw ..., le code
continuerait comme si la réservation avait marché — et l'utilisateur croirait avoir son
rendez-vous. C'est LE scénario qui rend la deuxième ligne du fetch honnête non négociable.
Le serveur répond 500. Le message d'erreur s'affiche bien. Pourtant
l'utilisateur écrit : « ça tourne encore ». Pourquoi le sablier ne s'éteint-il
jamais ?
setLoading(true);
try {
const res = await fetch('/api/appointments');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setAppointments(await res.json());
setLoading(false); // on éteint le sablier
} catch (err) {
setError(err.message);
}
Parce que setLoading(false) est posé sur le chemin heureux. Le
throw de la ligne du dessus saute immédiatement au catch : tout ce
qui restait dans le try est abandonné, l'extinction du sablier comprise.
L'interface se retrouve dans un état contradictoire — un message d'erreur et un
chargement en cours — ce qui est pire qu'une erreur nue : la personne ne sait plus si
quelque chose est encore en train de se produire.
La parade n'est pas d'ajouter la ligne une deuxième fois dans le catch, c'est
de la mettre là où elle appartient : finally, qui s'exécute que le
try réussisse, échoue ou retourne. Une requête a trois moments — ça charge, ça
a marché, ça a raté — et le premier doit se refermer dans tous les cas.
CORS, en survol honnête
Un jour — c'est une certitude — tu verras ce message rouge dans la console : « … has been blocked by CORS policy ». Ce module ne fera pas de toi un expert CORS (personne ne l'est vraiment), mais il t'apprend à reconnaître l'erreur et à en parler juste.
Le contexte : ton navigateur exécute du code téléchargé depuis un site, auquel il n'a aucune
raison de faire confiance. Sans garde-fou, n'importe quel onglet pourrait envoyer en douce des
requêtes à ta-banque.com avec tes cookies de session et lire les
réponses. D'où la politique de même origine : le code d'une origine (le trio
protocole + hôte + port) ne peut pas lire les réponses d'une autre origine.
CORS (Cross-Origin Resource Sharing) est le mécanisme d'exception :
le serveur interrogé déclare, via l'en-tête de réponse
Access-Control-Allow-Origin, « j'accepte que les pages de telle origine lisent
mes réponses ».
Le point qui échappe à presque tout le monde, et qu'il faut savoir dire en entrevue : CORS est une politique du NAVIGATEUR, pas du serveur. Quand une requête est « bloquée par CORS », il arrive qu'elle soit partie et que le serveur ait répondu — c'est le navigateur qui, faute d'en-tête d'autorisation, refuse de livrer la réponse à ton JavaScript. Preuves : la même requête via curl ou Postman passe sans problème, et ton app React Native n'a jamais eu ce souci avec Supabase. Une erreur CORS ne dit donc pas « le serveur refuse », mais « le serveur n'a pas déclaré qu'il acceptait ton origine ».
- ton ongletTon JavaScript appelle fetchvers api.partenaire.com, une autre origine
- réseauLa requête part pour de bonelle quitte la machine, avec tes cookies
- le serveurLe serveur répond 200, avec les donnéesmais sans l'en-tête Access-Control-Allow-Origin le serveur, lui, n'a rien refusé
- ton ongletLe navigateur retient la réponseelle est arrivée, ton JavaScript ne la verra jamais ✕ le blocage est ICI
Croire qu'on corrige une erreur CORS côté client. Le réflexe est naturel —
l'erreur apparaît dans ta console — mais aucune option de fetch, aucun en-tête
que tu ajoutes ne débloquera la réponse : la permission doit venir du
serveur. Le mode: 'no-cors' trouvé sur les forums est le pire des
remèdes : il fait taire l'erreur en te rendant une réponse « opaque », vide, illisible — un
message clair troqué contre un silence. Les vraies solutions sont ailleurs : faire ajouter
ton origine à Access-Control-Allow-Origin, ou passer par un intermédiaire que tu
contrôles (proxy de développement, ou Route Handler Next.js qui appelle l'API depuis le
serveur, où CORS ne s'applique pas).
Une réponse de forum promettait « ça enlève l'erreur CORS », et de fait la console
est redevenue propre. Que valent res.ok et res.status, et que fait
la dernière ligne ?
const res = await fetch('https://api.partenaire.com/slots', {
mode: 'no-cors',
});
const slots = await res.json();
res.ok vaut false et res.status vaut 0.
mode: 'no-cors' ne t'accorde aucune permission : il te rend une réponse
opaque — un accusé de réception sans statut, sans en-têtes et sans corps
lisible. La dernière ligne rejette donc sur un corps vide
(Unexpected end of JSON input). Tu as troqué un message d'erreur clair contre
un silence suivi d'une panne incompréhensible : le remède est pire que le mal.
Et le fond de l'affaire : rien de tout ça ne se règle côté client, parce que CORS
est une politique du navigateur, pas un refus du serveur. Le serveur a peut-être
parfaitement répondu — la même requête en curl passe, et ton app mobile n'a jamais eu ce
problème avec Supabase. C'est le navigateur qui, faute d'un en-tête
Access-Control-Allow-Origin nommant ton origine, refuse de livrer la réponse à
ton JavaScript. Les vraies issues sont ailleurs : faire ajouter ton origine côté serveur, ou
passer par un intermédiaire que tu contrôles.
L'onglet Network : ton réflexe n°1 quand « ça ne charge pas »
Dernière pièce, un outil et non un concept. L'onglet Network des DevTools enregistre toutes les requêtes émises par la page : chaque fetch, chaque image, chaque fichier. Quand quelque chose « ne charge pas », le réflexe professionnel n'est pas de relire son code en plissant les yeux, c'est d'ouvrir Network, de recharger et de regarder ce qui s'est réellement passé sur le réseau. En trente secondes tu sais si la requête est partie, vers quelle URL, et ce que le serveur a répondu.
Tu y vois d'abord la waterfall (cascade) : la liste chronologique des
requêtes, chacune avec une barre montrant son début et sa durée. Elle se lit comme un récit —
le HTML, puis le CSS et le JS qu'il référence, puis les requêtes API, puis les images. Barre
anormalement longue = goulot d'étranglement ; ligne rouge = échec ; barres en escalier bien
séquentiel = requêtes qui s'attendent alors qu'elles auraient pu partir en parallèle. Clique
sur l'une d'elles et tu retrouves en onglets les morceaux de la section « Anatomie d'une
requête et d'une réponse » : Headers (méthode, URL, statut, en-têtes des
deux sens — c'est là qu'on vérifie qu'un Authorization est parti),
Payload (le corps envoyé), Response (le corps reçu, brut) et
Preview (le même, déplié).
services tient le reste de la
page en otage, c'est le goulot d'étranglement. La ligne rouge :
appointments a échoué en 401 — la requête est partie, le serveur a répondu,
le problème est dans la conversation. Et l'escalier :
appointments n'a démarré qu'une fois services terminé, alors
que les deux ne dépendent pas l'une de l'autre. styles.css et
app.js, eux, sont partis ensemble — c'est à ça que ressemble le parallèle.
La checklist de débogage tient en quatre questions : la requête apparaît-elle ? (sinon ton code ne l'a jamais émise) — l'URL est-elle la bonne ? — le statut est-il 2xx ? (sinon : 4xx, regarde ta requête ; 5xx, le serveur) — le corps contient-il ce que tu crois ? Neuf « bugs » de chargement sur dix tombent avant la fin de la liste.
La waterfall d'une vraie page fait des dizaines de lignes (images, polices, scripts…). Le
filtre Fetch/XHR ne garde que les appels d'API — les requêtes dont ce module
parle. Quasiment toujours le premier clic. (XHR est l'ancêtre de fetch,
XMLHttpRequest ; le filtre couvre les deux.)
Un collègue te dit : « la page Mes rendez-vous reste vide, mais il n'y a aucune erreur dans la console ». Tu ouvres l'onglet Network, filtre Fetch/XHR, et tu vois une seule ligne :
Name Status Type Size Time
appointments 401 fetch 89 B 210 ms
Questions : (1) La requête est-elle partie ? Le serveur a-t-il répondu ? (2) Quelle est l'histoire la plus probable derrière ce 401 ? (3) Pourquoi la console ne montre-t-elle aucune erreur — et quelle ligne manque probablement dans le code ?
Voir le corrigé
(1) Oui et oui : la ligne existe (la requête a été émise) et porte un statut (le serveur a répondu). Le réseau fonctionne ; le problème est dans la conversation.
(2) 401 Unauthorized = « je ne sais pas qui tu es » : jeton expiré, absent,
ou en-tête Authorization jamais posé. On vérifie dans l'onglet Headers de la
requête si un Authorization: Bearer ... est parti — c'est le premier réflexe.
(3) Parce que fetch ne rejette pas sur un 401 (le serveur a
répondu !) : sans if (!res.ok) throw ..., aucun code d'erreur ne s'exécute,
res.json() parse le petit corps d'erreur sans broncher, et la page affiche
« rien » en silence. Le fetch honnête aurait transformé ce silence en erreur visible — et
un bon frontend en profiterait pour rediriger vers la connexion.
« Quelle est la différence entre GET et POST ? »
« GET sert à lire une ressource, sans effet côté serveur : pas de corps de
requête, les paramètres passent dans l'URL, et comme il est sûr et idempotent, il peut être
mis en cache ou rejoué sans risque. POST sert à créer ou soumettre : les
données voyagent dans le corps de la requête, et il n'est pas idempotent — envoyer
deux fois le même POST peut créer deux ressources, c'est pour ça qu'on protège les boutons
de soumission contre le double-clic. En pratique, dans une API REST : GET
/appointments pour lister, POST /appointments pour créer. »
« Que fait fetch quand le serveur répond 404 ? »
« C'est le piège classique : la Promesse de fetch est tenue, pas rejetée.
fetch ne rejette que si la requête elle-même n'a pas pu aboutir — panne réseau, hôte
introuvable, blocage CORS. Un 404, c'est un serveur qui répond, donc du point de vue de
fetch la mission est accomplie. C'est à moi de vérifier le verdict avec
response.ok ou response.status, et de lancer moi-même une erreur
si la réponse n'est pas un succès — sinon le code continue comme si tout allait bien et le
bug se manifeste plus loin, méconnaissable. »
« C'est quoi, une API REST ? »
« C'est un style d'API HTTP organisé autour de ressources plutôt que
d'actions. Chaque ressource a une URL — /appointments,
/appointments/42 — et on agit dessus avec les méthodes HTTP standard : GET pour
lire, POST pour créer, PUT ou PATCH pour modifier, DELETE pour supprimer. Le serveur répond
avec des codes de statut standard et, généralement, du JSON. L'intérêt, c'est
l'uniformité : quand une API suit REST, je peux deviner ses routes sans lire toute la doc —
et elle reste sans état, donc facile à mettre en cache et à faire monter en charge. »
HTTP = requête/réponse, sans état — d'où le jeton
Authorization: Bearer ... à chaque requête. Une URL se lit protocole / hôte /
chemin / query string ; une requête = méthode + en-têtes + corps, une réponse = statut +
en-têtes + corps. Les verbes : GET lit (sans effet), POST crée (pas idempotent !), PUT/PATCH
modifient, DELETE supprime. Les statuts : 2xx succès, 3xx redirection, 4xx ta faute,
5xx la sienne — avec le duo 401 (« qui es-tu ? ») / 403 (« pas le droit »). Le JSON
est la lingua franca, Content-Type son étiquette. Et la formule du fetch
honnête : await fetch, vérifier res.ok soi-même
(fetch ne rejette que sur panne réseau), puis await res.json() — deux Promesses,
trois lignes. Au moindre doute : onglet Network, filtre Fetch/XHR.
Et ailleurs : HTTP est probablement la connaissance la plus transférable de tout ce guide. Mêmes méthodes, mêmes statuts partout : ton app qui parle à Supabase, un backend Node qui appelle un service de paiement, un script Python sur une API météo, les webhooks, les Route Handlers de Next.js — tout est requête/réponse, verbe, statut, JSON. Et le réflexe « ouvre Network et regarde ce qui s'est vraiment passé » est une compétence de débogage universelle. Apprends HTTP une fois, sers-t'en toute ta carrière.
- Ce qui fait rejeter
fetch - l'échec du réseau lui-même, et rien d'autre. Un
404ou un500est une réussite pourfetch: la requête est partie, une réponse est revenue. Lire le verdict est à ta charge - Ce que vaut
res.ok - vrai si et seulement si le statut est entre
200et299 - Pourquoi
fetchdemande deuxawait - le premier se résout dès l'arrivée des en-têtes — statut compris — alors que le corps coule
encore.
res.json()finit de le télécharger puis le parse : elle attend à son tour, donc elle rend une Promesse elle aussi - Ce que dit le premier chiffre d'un statut
2xxsuccès,3xxredirection,4xxta faute,5xxla sienne. Un400t'envoie corriger ce que tu émets, un500t'envoie lire les journaux du serveur- La différence entre
401et403 401= « je ne sais pas qui tu es » (authentification) : direction l'écran de connexion.403= « je sais qui tu es, et non » (autorisation) : se reconnecter n'y changera rien, il faut afficher « accès refusé »- Ce que veut dire idempotent
- rejouer la requête laisse le même état — pas la même réponse.
GET,PUTetDELETEle sont ;POSTnon, puisqu'il crée du neuf à chaque appel - Ce que porte un chemin REST
- des noms, jamais des verbes — le verbe, c'est la méthode.
POST /api/appointments, et non/api/createAppointment - Ce qu'il faut poser pour envoyer du JSON
- l'en-tête
Content-Type: application/json, etJSON.stringifysur le corps. Oublier l'en-tête est une cause classique de400 - Qui bloque, dans une erreur CORS
- le navigateur, pas le serveur. La requête est souvent partie et le serveur a
répondu ; faute d'un
Access-Control-Allow-Originnommant ton origine, le navigateur refuse de livrer la réponse à ton JavaScript - Ce que donne
mode: 'no-cors' - une réponse opaque :
res.okàfalse,res.statusà0, corps illisible. Il fait taire l'erreur sans accorder la moindre permission