Module 5 · Fondations du web

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.

💡 Pourquoi « sans état » est une force

Ç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 : 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. Le s signifie 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 paires clé=valeur séparées par des &, toujours après le ?. Ici : « filtre sur bone-scan, en français ».
🔗 Pont — tu fais du HTTP tous les jours via Supabase

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…).

Trois parties, deux fois, en miroir
la requête →
ligne de commandeGET /api/services
en-têtesAccept: application/json
corps(aucun : un GET demande)
← la réponse
ligne de statut200 OK
en-têtesContent-Type: application/json
corps[{ "id": 1, … }]
Les deux colonnes ont la même charpente, et c'est tout l'intérêt : une fois ces trois cases en tête, tu lis n'importe quel échange HTTP. Seule la première ligne diffère de nature — 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 — et il est souvent absent d'une requête.

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.

🧭 HTTP/1.1, HTTP/2, HTTP/3 ?

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éthodeIntentionCorps de requête ?Analogie
GETLire, sans rien changerNonConsulter le catalogue
POSTCréer une ressourceOuiDéposer un nouveau dossier
PUTRemplacer entièrementOuiÉchanger le dossier complet
PATCHModifier partiellementOuiCorriger une ligne du dossier
DELETESupprimerRarementDé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 ».

📅 Dans RendezVous

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.

🧠 Quiz éclair

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 :

CodeNomLa scène dans RendezVous
200OKLa liste des services arrive, l'écran s'affiche : la journée normale.
201CreatedTa réservation a été créée — la réponse au POST réussi, souvent avec le nouvel objet dans le corps.
301Moved Permanently/rendez-vous redirige pour toujours vers /appointments ; le navigateur suit sans demander.
400Bad RequestLe formulaire a envoyé une date au mauvais format : le serveur refuse de deviner.
401UnauthorizedTa session a expiré : « je ne sais pas qui tu es, reconnecte-toi ».
403ForbiddenTu consultes le rendez-vous d'un autre patient : « je sais qui tu es, et justement, non ».
404Not FoundLe rendez-vous n°42 a été annulé hier ; l'URL en favori ne mène plus nulle part.
409ConflictDeux patients ont cliqué « Réserver » sur le même créneau : le second reçoit « quelqu'un t'a devancé ».
500Internal Server ErrorLe 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 statut est un résumé, pas toute l'histoire

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.

🧠 Quiz éclair

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.

📖 La formule

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.

✍️ Exercice de lecture

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.

🧠 Quiz éclair

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 :

CheminCe que fait fetchOù ç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.

🔗 Pont — msw, ou intercepter ces mêmes requêtes dans tes tests

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.

✍️ Exercice de lecture

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.

🧠 Quiz éclair

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 ».

Où le blocage se produit vraiment
  1. ton ongletTon JavaScript appelle fetchvers api.partenaire.com, une autre origine
  2. réseauLa requête part pour de bonelle quitte la machine, avec tes cookies
  3. 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é
  4. ton ongletLe navigateur retient la réponseelle est arrivée, ton JavaScript ne la verra jamais ✕ le blocage est ICI
Suis l'étiquette de droite : elle dit chaque étape se déroule. La requête part (2), le serveur répond normalement (3) — et c'est seulement au retour, à l'intérieur de ton propre navigateur, que la réponse est retenue (4). Voilà pourquoi la même requête passe sans broncher depuis curl, Postman ou ton app React Native : aucun d'eux n'est un navigateur, donc personne n'applique la politique. Et voilà pourquoi un proxy ou un Route Handler règle le problème — l'appel part alors d'un serveur, où l'étape 4 n'existe pas.
⚠️ Piège fréquent

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).

🧠 Quiz éclair

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é).

Une cascade, et les trois choses qu'on y cherche
index.html
styles.css
app.js
services 500 ms
appointments 401
0 400 ms 800 ms
Une colonne vaut 100 ms. Trois signatures se lisent d'un coup d'œil. La barre anormalement longue : 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.

🧭 Filtre « Fetch/XHR »

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.)

✍️ Exercice de lecture

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.

🎤 En entrevue

« 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. »

À retenir

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.

🗂️ L'aide-mémoire
Ce qui fait rejeter fetch
l'échec du réseau lui-même, et rien d'autre. Un 404 ou un 500 est une réussite pour fetch : 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 200 et 299
Pourquoi fetch demande deux await
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
2xx succès, 3xx redirection, 4xx ta faute, 5xx la sienne. Un 400 t'envoie corriger ce que tu émets, un 500 t'envoie lire les journaux du serveur
La différence entre 401 et 403
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, PUT et DELETE le sont ; POST non, 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, et JSON.stringify sur le corps. Oublier l'en-tête est une cause classique de 400
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-Origin nommant 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