Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 827 minIntermédiaire

Consommer une API : fetch en JavaScript, PHP, Python et C#

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • appeler une API avec fetch et traiter correctement ses erreurs ;
  • faire le même appel en PHP, en Python ou en C# ;
  • fixer un délai maximal et réessayer sans danger ;
  • garder les secrets hors du code des clients.

5 exercices corrigés

Consommer une API, c'est écrire le client : le code qui construit la requête HTTP, l'envoie, attend la réponse, vérifie son code de statut et transforme le JSON reçu en données du programme. Ce chapitre montre comment le faire proprement en JavaScript avec fetch, en PHP avec cURL, en Python avec requests et en C# avec HttpClient, puis les règles communes : délais d'attente, gestion des erreurs, nouvelles tentatives et protection des secrets.

Ce que tout client doit faire

Quel que soit le langage, un appel d'API robuste suit les mêmes étapes :

  1. Construire l'URL en encodant les paramètres (jamais par simple concaténation d'une saisie).
  2. Ajouter les en-têtes : Accept, Authorization, et Content-Type s'il y a un corps.
  3. Sérialiser le corps en JSON.
  4. Fixer un délai maximal (timeout) : sans lui, un serveur qui ne répond pas bloque ton programme indéfiniment.
  5. Distinguer deux sortes d'échecs : l'échec réseau (serveur injoignable, délai dépassé, certificat invalide), où il n'y a pas de réponse du tout, et l'échec HTTP (4xx, 5xx), où il y a une réponse qu'il faut lire.
  6. Lire le corps selon son type : JSON pour une réussite, application/problem+json pour une erreur, rien pour un 204.
  7. Réagir au code : afficher l'erreur de validation à côté du champ (422), renvoyer vers la connexion (401), réessayer plus tard (429, 503), ne jamais réessayer une erreur 400.

Dans les exemples, l'API est celle d'AppliFrais à l'adresse https://api.gsb.example/v1 ; chacun a été testé contre l'API de démonstration.

JavaScript : fetch, les bases

fetch est la fonction standard des navigateurs pour faire des requêtes HTTP, disponible aussi dans Node.js depuis la version 18. Elle renvoie une promesse de réponse ; avec async et await, le code se lit de haut en bas (rappels sur l'asynchrone en JavaScript).

js
const API = 'https://api.gsb.example/v1';

async function chargerFiche(id, jeton) {
  const reponse = await fetch(`${API}/fiches/${id}`, {
    headers: {
      Accept: 'application/json',
      Authorization: `Bearer ${jeton}`,
    },
  });
  if (!reponse.ok) {
    throw new Error(`Erreur ${reponse.status}`);
  }
  return reponse.json();   // une autre promesse : le corps lu et converti
}

const fiche = await chargerFiche(12, jeton);
console.log(fiche.mois, fiche.etat);   // 2026-10 CR

Deux await sont nécessaires : le premier attend les en-têtes de la réponse, le second (reponse.json()) attend et convertit le corps.

L'objet Response donne tout ce qu'il faut :

Propriété ou méthodeContenu
reponse.oktrue si le code est entre 200 et 299
reponse.statusLe code, par exemple 404
reponse.headers.get('Location')Un en-tête (le nom est insensible à la casse)
await reponse.json()Le corps converti depuis JSON ; lève une erreur si le corps n'est pas du JSON
await reponse.text()Le corps brut, en texte
await reponse.blob()Le corps en binaire (un PDF, une image)

Le corps ne se lit qu'une seule fois : appeler reponse.json() après reponse.text() lève une erreur.

JavaScript : envoyer des données et paramétrer la requête

Pour un POST, un PUT ou un PATCH, on précise la méthode, l'en-tête Content-Type et le corps sérialisé :

js
const reponse = await fetch(`${API}/fiches/12/frais-hors-forfait`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${jeton}`,
  },
  body: JSON.stringify({ date: '2026-10-14', libelle: 'Repas client à Lyon', montant: 23.5 }),
});
if (reponse.status === 201) {
  console.log('Créé à', reponse.headers.get('Location'));
}

Oublier JSON.stringify envoie la chaîne "[object Object]" ; oublier Content-Type fait souvent refuser le corps par le serveur (415) ou le laisse vide côté Express.

Pour les paramètres de la chaîne de requête, URLSearchParams encode tout correctement, accents et espaces compris :

js
const params = new URLSearchParams({ etat: 'CL', mois: '2026-10', q: 'note de frais' });
const url = `${API}/fiches?${params}`;
// https://api.gsb.example/v1/fiches?etat=CL&mois=2026-10&q=note+de+frais

URLSearchParams code l'espace en +, la convention des formulaires HTML, au lieu de %20 : les deux formes sont comprises par les serveurs dans la chaîne de requête.

Et pour fixer un délai maximal, AbortSignal.timeout annule la requête au bout du temps donné :

js
try {
  const reponse = await fetch(`${API}/fiches`, { signal: AbortSignal.timeout(5000) });
  // …
} catch (e) {
  if (e.name === 'TimeoutError') console.error("L'API n'a pas répondu en 5 secondes");
  else throw e;
}

JavaScript : une fonction d'appel réutilisable

Dans un vrai projet, on ne répète pas ces quinze lignes à chaque appel. On écrit une fonction qui ajoute le jeton, sérialise le corps, fixe le délai et transforme toute réponse d'erreur en exception exploitable :

js
class ErreurApi extends Error {
  constructor(probleme) {
    super(probleme.detail ?? probleme.title);
    this.status = probleme.status;
    this.type = probleme.type;
    this.probleme = probleme;   // le corps RFC 9457 complet, avec ses extensions
  }
}

async function appelerApi(chemin, { methode = 'GET', corps, signal } = {}) {
  const reponse = await fetch(`${API}${chemin}`, {
    method: methode,
    headers: {
      Accept: 'application/json',
      Authorization: `Bearer ${lireJeton()}`,
      ...(corps !== undefined && { 'Content-Type': 'application/json' }),
    },
    body: corps !== undefined ? JSON.stringify(corps) : undefined,
    signal: signal ?? AbortSignal.timeout(5000),
  });

  if (reponse.status === 204) return null;
  const type = reponse.headers.get('Content-Type') ?? '';
  const donnees = type.includes('json') ? await reponse.json() : await reponse.text();
  if (!reponse.ok) {
    throw new ErreurApi(typeof donnees === 'object' ? donnees : { status: reponse.status, title: donnees });
  }
  return donnees;
}

Le code appelant devient court, et chaque erreur se traite selon son code :

js
try {
  const fiche = await appelerApi('/fiches', { methode: 'POST', corps: { mois: moisChoisi } });
  afficherFiche(fiche);
} catch (e) {
  if (!(e instanceof ErreurApi)) afficherMessage('Connexion impossible. Vérifie ton réseau.');
  else if (e.status === 401) redirigerVersConnexion();
  else if (e.status === 409) afficherMessage('Tu as déjà une fiche pour ce mois.');
  else if (e.status === 422) afficherErreursChamps(e.probleme.erreurs);
  else afficherMessage(`Erreur inattendue (${e.status}). Réessaie plus tard.`);
}

Dans un composant React, l'appel se place dans un gestionnaire d'événement ou dans un effet, avec un état pour le chargement et l'erreur (appels API avec React). Des bibliothèques comme Axios offrent les mêmes services que cette fonction ; fetch suffit largement pour un projet de BTS. Une application mobile React Native fait exactement le même appel, avec en plus le jeton gardé dans un stockage sécurisé et le cas du réseau absent (appeler une API depuis une application mobile).

PHP : cURL

Une application PHP appelle une API quand elle consomme un service extérieur : vérifier un numéro SIRET, géocoder une adresse, transmettre une commande à un partenaire. L'extension cURL est la solution standard :

php
<?php
function appelerApi(string $methode, string $url, ?array $corps = null, string $jeton = ''): array
{
    $ch = curl_init($url);
    $entetes = ['Accept: application/json'];
    if ($jeton !== '') {
        $entetes[] = 'Authorization: Bearer ' . $jeton;
    }
    if ($corps !== null) {
        $entetes[] = 'Content-Type: application/json';
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($corps, JSON_THROW_ON_ERROR));
    }
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $methode,
        CURLOPT_HTTPHEADER     => $entetes,
        CURLOPT_RETURNTRANSFER => true,   // renvoyer le corps au lieu de l'afficher
        CURLOPT_CONNECTTIMEOUT => 3,      // secondes pour établir la connexion
        CURLOPT_TIMEOUT        => 10,     // secondes au total
    ]);

    $texte = curl_exec($ch);
    if ($texte === false) {
        throw new RuntimeException('Erreur réseau : ' . curl_error($ch));
    }
    $statut = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $donnees = $texte === '' ? null : json_decode($texte, true, 512, JSON_THROW_ON_ERROR);

    return ['statut' => $statut, 'donnees' => $donnees];
}

$reponse = appelerApi('GET', 'https://geo.api.gouv.fr/communes?' . http_build_query([
    'codePostal' => '69001',
    'fields'     => 'nom,population',
]));
if ($reponse['statut'] === 200) {
    foreach ($reponse['donnees'] as $commune) {
        echo htmlspecialchars($commune['nom']), "\n";
    }
}

Quelques points à retenir :

  • CURLOPT_RETURNTRANSFER est indispensable : sans lui, curl_exec affiche le corps au lieu de le renvoyer ;
  • curl_exec renvoie false seulement en cas d'échec réseau ; un 404 ou un 500 est une réponse normale, qu'il faut détecter avec CURLINFO_RESPONSE_CODE, comme avec fetch ;
  • http_build_query() construit et encode la chaîne de requête ;
  • les données reçues d'une API tierce sont des entrées non fiables : on les échappe à l'affichage (htmlspecialchars) et on les valide avant de les enregistrer, comme une saisie d'utilisateur ;
  • curl_close() n'a plus d'effet depuis PHP 8.0 : la ressource est libérée automatiquement.

Pour une application plus importante, les frameworks fournissent un client de plus haut niveau : HttpClient de Symfony, le client Http de Laravel, ou la bibliothèque Guzzle. Ils gèrent les délais, les nouvelles tentatives et le JSON avec moins de code.

Python : requests

En Python, la bibliothèque requests reste la plus utilisée (pip install requests) ; httpx, très proche, ajoute l'asynchrone et HTTP/2.

python
import requests

API = "https://api.gsb.example/v1"
session = requests.Session()   # réutilise la connexion et les en-têtes communs
session.headers.update({"Authorization": f"Bearer {jeton}", "Accept": "application/json"})

# Lire une liste filtrée : params encode la chaîne de requête
reponse = session.get(f"{API}/fiches", params={"etat": "CR", "limite": 20}, timeout=5)
reponse.raise_for_status()   # lève HTTPError pour un code 4xx ou 5xx
for fiche in reponse.json()["donnees"]:
    print(fiche["id"], fiche["mois"], fiche["etat"])

# Créer une fiche et traiter les erreurs
try:
    reponse = session.post(f"{API}/fiches", json={"mois": "2026-13"}, timeout=5)
    reponse.raise_for_status()
    print("Créée :", reponse.headers["Location"])
except requests.HTTPError as e:
    probleme = e.response.json()
    print(e.response.status_code, probleme["title"])
    for erreur in probleme.get("erreurs", []):
        print(" -", erreur["champ"], ":", erreur["message"])
except requests.RequestException as e:
    print("Problème réseau :", e)

Ce script affiche, contre l'API de démonstration :

text
12 2026-10 CR
13 2026-11 CR
422 Données invalides
 - mois : Format attendu : AAAA-MM

Le paramètre json= sérialise le corps et ajoute Content-Type: application/json ; params= encode la chaîne de requête. Attention : requests n'a aucun délai par défaut. Sans timeout=, un appel peut attendre indéfiniment, ce qui bloque un script planifié pendant des heures.

C# : HttpClient

En C#, la classe HttpClient et les méthodes d'extension de System.Net.Http.Json convertissent directement entre JSON et objets. C'est ce qu'utilise une application WinForms ou WPF qui affiche les données d'une API au lieu de se connecter à la base (consommer une API REST en C#).

csharp
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;

using var http = new HttpClient
{
    BaseAddress = new Uri("https://api.gsb.example/v1/"),
    Timeout = TimeSpan.FromSeconds(5),
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jeton);

// Lire une fiche : le JSON devient un objet Fiche
Fiche? fiche = await http.GetFromJsonAsync<Fiche>("fiches/12");
Console.WriteLine($"{fiche!.Mois} {fiche.Etat}");   // 2026-10 CR

// Créer une fiche et lire le code de statut
HttpResponseMessage reponse = await http.PostAsJsonAsync("fiches", new { mois = "2026-11" });
if (reponse.StatusCode == HttpStatusCode.Created)
{
    Fiche? creee = await reponse.Content.ReadFromJsonAsync<Fiche>();
    Console.WriteLine($"Créée : {reponse.Headers.Location} (id {creee!.Id})");
}
else
{
    Probleme? p = await reponse.Content.ReadFromJsonAsync<Probleme>();
    Console.WriteLine($"{(int)reponse.StatusCode} {p?.Title} : {p?.Detail}");
}

record Fiche(int Id, int VisiteurId, string Mois, string Etat, decimal? MontantValide);
record Probleme(string Type, string Title, int Status, string? Detail);

Les méthodes …FromJsonAsync et …AsJsonAsync utilisent les réglages « web » de System.Text.Json : les noms en camelCase du JSON (montantValide) correspondent aux propriétés C# (MontantValide). Le type decimal? garde le montant exact et accepte null.

Réessayer intelligemment

Certaines erreurs sont passagères : 429 Too Many Requests, 503 Service Unavailable, 502, 504, un délai dépassé. Réessayer a du sens, à trois conditions :

  1. La requête est idempotente (GET, PUT, DELETE), ou protégée par une clé d'idempotence (chapitre 6). On ne rejoue jamais un POST ordinaire en aveugle.
  2. On respecte Retry-After quand le serveur le donne : il dit combien de secondes attendre (ou une date).
  3. On espace les essais de plus en plus (attente exponentielle : 1 s, 2 s, 4 s…), avec un peu de hasard (jitter) pour que mille clients ne reviennent pas tous à la même seconde, et on abandonne après quelques essais.
js
const attendre = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const CODES_PASSAGERS = [429, 502, 503, 504];
const METHODES_IDEMPOTENTES = ['GET', 'HEAD', 'PUT', 'DELETE'];

async function fetchAvecReessais(url, options = {}, essaisMax = 4) {
  // Un POST (ou un PATCH) sans clé d'idempotence n'est jamais rejoué : un seul essai
  const methode = (options.method ?? 'GET').toUpperCase();
  const rejouable = METHODES_IDEMPOTENTES.includes(methode) || new Headers(options.headers).has('Idempotency-Key');
  if (!rejouable) essaisMax = 1;

  for (let essai = 1; ; essai++) {
    let reponse;
    try {
      reponse = await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
    } catch (erreur) {
      // Délai dépassé (TimeoutError) ou réseau coupé (TypeError) : passager lui aussi
      if (essai === essaisMax) throw erreur;
      await attendre(2 ** (essai - 1) * 1000 + Math.random() * 300);
      continue;
    }
    if (!CODES_PASSAGERS.includes(reponse.status) || essai === essaisMax) return reponse;

    // Retry-After en secondes si le serveur le donne, sinon 1 s, 2 s, 4 s… plus un peu de hasard
    const retryAfter = Number(reponse.headers.get('Retry-After'));
    const delai = retryAfter > 0 ? retryAfter * 1000 : 2 ** (essai - 1) * 1000 + Math.random() * 300;
    await attendre(delai);
  }
}

Testée contre un serveur qui répond 503, puis 429, puis 200, la fonction attend deux fois puis renvoie la réponse 200. Un POST sans en-tête Idempotency-Key ne fait qu'un essai : l'appelant reçoit le 503 et décide lui-même. Un serveur qui ne répond pas en cinq secondes déclenche une TimeoutError, traitée comme une erreur passagère ; au dernier essai, l'erreur remonte à l'appelant. Si Retry-After contient une date plutôt qu'un nombre de secondes, Number() donne NaN et la fonction se rabat sur l'attente exponentielle.

Ne jamais exposer un secret dans le client

Le code d'un front web se lit dans les outils de développement, et une application mobile se décompile en quelques minutes. Un secret placé dans le code d'un client (la clé d'API payante d'un service de cartographie, le secret OAuth d'une application) est donc un secret publié.

La règle : le client appelle ton serveur, et c'est ton serveur, qui seul détient la clé, qui appelle le service tiers. Ce schéma s'appelle un backend for frontend (BFF) ou, plus simplement, un proxy. Il permet aussi de contrôler qui appelle, de limiter le débit et de mettre les réponses en cache.

text
Front (navigateur)  →  API d'AppliFrais (détient la clé)  →  Service de géocodage
       aucun secret          contrôle le jeton de Lucas          reçoit la clé d'API

Les seules valeurs acceptables dans un client sont publiques par nature : l'adresse de l'API, ou une clé expressément prévue pour être publique et restreinte à ton domaine par le fournisseur.

Exercices

Exercice 1 sur 5 Trouver les bogues Réussi À revoir

Ce code doit afficher les fiches clôturées d'octobre. Il contient quatre erreurs.

js
async function fichesCloturees() {
  const reponse = fetch('https://api.gsb.example/v1/fiches?etat=CL&mois=' + mois);
  const fiches = reponse.json();
  fiches.donnees.forEach(f => afficher(f));
}
Voir la réponseMasquer la réponse de l'exercice 1
  1. Il manque await devant fetch : reponse est une promesse, pas une réponse.
  2. Il manque await devant reponse.json().
  3. Le code ne teste pas reponse.ok : un 401 ou un 500 serait traité comme une liste (et fiches.donnees serait undefined, d'où une TypeError).
  4. L'en-tête Authorization est absent : l'API répondra 401. (On peut aussi relever que mois n'est ni défini ni encodé : URLSearchParams serait plus sûr, et qu'aucun délai maximal n'est fixé.)
js
async function fichesCloturees(mois) {
  const params = new URLSearchParams({ etat: 'CL', mois });
  const reponse = await fetch(`https://api.gsb.example/v1/fiches?${params}`, {
    headers: { Authorization: `Bearer ${lireJeton()}` },
    signal: AbortSignal.timeout(5000),
  });
  if (!reponse.ok) throw new Error(`Erreur ${reponse.status}`);
  const fiches = await reponse.json();
  fiches.donnees.forEach((f) => afficher(f));
}

Tu avais trouvé ?

Exercice 2 sur 5 Réseau ou HTTP ? Réussi À revoir

Pour chaque situation, dis si fetch rejette sa promesse (le catch s'exécute) ou si elle se résout avec une réponse ; dans ce cas, donne reponse.ok et reponse.status.

  1. L'API répond 404.
  2. Le Wi-Fi est coupé.
  3. L'API répond 201.
  4. Le délai de AbortSignal.timeout(5000) est dépassé.
  5. L'API répond 503.
Voir la réponseMasquer la réponse de l'exercice 2
  1. Résolue : ok vaut false, status vaut 404.
  2. Rejetée (TypeError) : il n'y a aucune réponse.
  3. Résolue : ok vaut true, status vaut 201.
  4. Rejetée, avec une erreur dont le nom est TimeoutError.
  5. Résolue : ok vaut false, status vaut 503.

Tu avais trouvé ?

Exercice 3 sur 5 Appeler une API en PHP Réussi À revoir

Écris le code PHP qui crée un frais hors forfait dans la fiche 12 avec la fonction appelerApi du chapitre (date 2026-10-14, libellé « Taxi gare », montant 18,40 €), puis affiche « Frais ajouté » pour un 201, la liste des erreurs de champs pour un 422, et « Fiche non modifiable » pour un 409.

Voir la réponseMasquer la réponse de l'exercice 3
php
<?php
$reponse = appelerApi('POST', 'https://api.gsb.example/v1/fiches/12/frais-hors-forfait', [
    'date'    => '2026-10-14',
    'libelle' => 'Taxi gare',
    'montant' => 18.40,
], $jeton);

switch ($reponse['statut']) {
    case 201:
        echo 'Frais ajouté';
        break;
    case 422:
        foreach ($reponse['donnees']['erreurs'] ?? [] as $erreur) {
            echo htmlspecialchars($erreur['champ'] . ' : ' . $erreur['message']), '<br>';
        }
        break;
    case 409:
        echo 'Fiche non modifiable';
        break;
    default:
        echo 'Erreur ' . (int) $reponse['statut'];
}

La fonction lève une exception en cas d'échec réseau : dans une vraie page, l'appel serait entouré d'un try … catch (RuntimeException $e). Le montant 18.40 est envoyé en JSON comme 18.4, ce qui est la même valeur.

Tu avais trouvé ?

Exercice 4 sur 5 Combien de temps attendre ? Réussi À revoir

La fonction fetchAvecReessais du chapitre reçoit successivement trois réponses 503 sans en-tête Retry-After, puis une réponse 200. On ignore la part de hasard.

  1. Combien de temps attend-elle avant chaque nouvel essai ?
  2. Quel est le temps total d'attente ?
  3. Que se passe-t-il si le serveur répond 503 aux quatre essais ?
  4. Pourquoi ajoute-t-on une part de hasard ?
Voir la réponseMasquer la réponse de l'exercice 4
  1. Après l'essai 1 : 2⁰ × 1 000 = 1 000 ms ; après l'essai 2 : 2¹ × 1 000 = 2 000 ms ; après l'essai 3 : 2² × 1 000 = 4 000 ms. L'essai 4 reçoit 200.
  2. 1 + 2 + 4 = 7 secondes (plus jusqu'à 3 × 300 ms de hasard).
  3. Au quatrième essai, essai === essaisMax : la fonction renvoie la réponse 503 sans attendre davantage, et l'appelant doit afficher une erreur.
  4. Si un incident coupe l'API pour mille clients en même temps, sans hasard ils reviendraient tous à la même milliseconde, et cette vague pourrait faire retomber le serveur. Le hasard étale les nouveaux essais.

Tu avais trouvé ?

Exercice 5 sur 5 Où placer la clé ? Réussi À revoir

Le front React d'AppliFrais doit afficher, pour chaque frais kilométrique, la distance calculée par un service de cartographie payant, qui exige une clé d'API secrète. Un développeur propose de la placer dans une variable d'environnement du front (VITE_CLE_CARTO). Que réponds-tu ?

Voir la réponseMasquer la réponse de l'exercice 5

Une variable d'environnement d'un front est injectée dans le code JavaScript au moment de la construction : n'importe qui la lit dans les outils de développement. La clé serait volée et utilisée aux frais du laboratoire. Il faut ajouter à l'API d'AppliFrais une route, par exemple GET /v1/distances?depart=…&arrivee=…, qui vérifie le jeton du visiteur, applique une limite de débit, puis appelle le service de cartographie depuis le serveur avec la clé stockée dans sa configuration. Le front n'appelle que l'API d'AppliFrais.

Tu avais trouvé ?

Chapitre terminé !

Fais les 5 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Authentifier une API : clé d'API, Bearer, JWT et OAuth 2.0Revoir le sommaire du cours