Aller au contenu
Réussir mon BTS SIO

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

Par exemple

Chapitre 1319 minDébutant

Appeler une API REST en Python : urllib et requests

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

À la fin de ce chapitre, tu sauras

  • envoyer une requête GET ou POST et lire la réponse JSON ;
  • gérer le délai d'attente et les trois familles d'erreurs ;
  • s'authentifier avec un jeton sans l'écrire dans le code ;
  • parcourir toutes les pages d'une collection.

3 exercices corrigés1 schéma

Appeler une API REST en Python, c'est envoyer une requête HTTP à une adresse, vérifier le code de statut de la réponse et convertir le JSON reçu en dictionnaires et en listes. La bibliothèque standard le fait avec urllib.request ; le paquet requests, installé presque partout, le fait avec moins de code. Ce chapitre montre les deux sur l'API officielle du découpage administratif français, puis traite ce qu'un vrai client doit gérer : délai d'attente, erreurs, authentification par jeton, pagination et nouvelles tentatives, sur l'API de démonstration des fiches de frais GSB du cours API REST.

Rappel : une requête et sa réponse

Une requête HTTP a une méthode (GET pour lire, POST pour créer, PUT ou PATCH pour modifier, DELETE pour supprimer), une URL avec éventuellement des paramètres (?codePostal=71000), des en-têtes (Accept, Authorization) et parfois un corps en JSON. La réponse a un code de statut (200, 201, 404, 401, 500…), des en-têtes et un corps. Le cours API REST détaille le protocole (requête, réponse, méthodes et en-têtes) et les codes (codes de statut HTTP).

script Pythonrequests.get(…)reponse.json()liste de 3 dictAPI Découpageadministratifgeo.api.gouv.frGET /communes?codePostal=71000&fields=nom,code200 OK, Content-Type: application/json[{"nom": "Mâcon", "code": "71270"},{"nom": "Sancé", "code": "71497"},{"nom": "Varennes-lès-Mâcon","code": "71556"}]le JSON reçu devientune liste de dict
Un appel d'API : la requête GET part avec ses paramètres, la réponse JSON devient une liste de dictionnaires

Avec la bibliothèque standard : urllib.request

L'API Découpage administratif (geo.api.gouv.fr), publiée par l'État, est ouverte et sans authentification : idéale pour s'entraîner. Les communes du code postal 71000 :

python
# communes_urllib.py : appeler une API REST avec la seule bibliothèque standard
import json
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

API = "https://geo.api.gouv.fr"
parametres = urlencode({"codePostal": "71000", "fields": "nom,code"})
requete = Request(f"{API}/communes?{parametres}", headers={"Accept": "application/json"})

with urlopen(requete, timeout=10) as reponse:
    print(reponse.status, reponse.headers["Content-Type"])
    communes = json.load(reponse)

for commune in communes:
    print(commune["code"], commune["nom"])

try:
    urlopen(f"{API}/communes/99999", timeout=10)
except HTTPError as erreur:
    print("Erreur HTTP", erreur.code)
text
200 application/json; charset=utf-8
71270 Mâcon
71497 Sancé
71556 Varennes-lès-Mâcon
Erreur HTTP 404

urlencode() construit la chaîne de paramètres en encodant les caractères spéciaux ; Request ajoute les en-têtes ; urlopen() envoie la requête et renvoie la réponse, à lire dans un bloc with. json.load() convertit directement le corps. Une réponse 4xx ou 5xx lève une HTTPError, dont l'attribut code porte le statut. Cette version ne demande aucune installation : utile sur un serveur où tu ne peux rien ajouter.

Avec requests

requests (version 2.34 en novembre 2026) n'est pas dans la bibliothèque standard (installer un paquet), mais c'est le client HTTP le plus utilisé en Python :

python
# communes_requests.py : le même appel avec requests
import requests

API = "https://geo.api.gouv.fr"

reponse = requests.get(f"{API}/communes",
                       params={"codePostal": "71000", "fields": "nom,code"}, timeout=10)
reponse.raise_for_status()                  # lève une exception pour un code 4xx ou 5xx
print(reponse.status_code, reponse.url)
for commune in reponse.json():
    print(commune["code"], commune["nom"])

introuvable = requests.get(f"{API}/communes/99999", timeout=10)
print(introuvable.status_code, introuvable.ok)
text
200 https://geo.api.gouv.fr/communes?codePostal=71000&fields=nom%2Ccode
71270 Mâcon
71497 Sancé
71556 Varennes-lès-Mâcon
404 False

params= encode les paramètres (la virgule devient %2C dans l'URL finale), reponse.json() convertit le corps, reponse.status_code donne le statut et reponse.ok vaut True pour un code inférieur à 400. Contrairement à urlopen, requests ne lève pas d'exception pour une erreur HTTP : c'est raise_for_status() qui le fait, à appeler après chaque requête dont le succès compte.

Besoinurllib (standard)requests
Paramètresurlencode({...}) dans l'URLparams={...}
En-têtesRequest(url, headers={...})headers={...}
Corps JSONdata=json.dumps(...).encode() et en-tête Content-Typejson={...}
Lire le JSONjson.load(reponse)reponse.json()
Erreur HTTPHTTPError levée automatiquementraise_for_status()
Connexion réutilisée, en-têtes communsÀ la mainrequests.Session()

Paramètres, en-têtes et délai

Un appel réseau peut rester bloqué : serveur surchargé, pare-feu qui ignore les paquets. requests n'a aucun délai par défaut : sans timeout=, un script planifié peut attendre indéfiniment. Mets un timeout à chaque appel, en secondes (5 à 10 pour une API interne). Les erreurs se rangent en trois familles, toutes filles de requests.RequestException :

python
# erreurs_reseau.py : les trois familles d'erreurs d'un appel d'API
import requests


def appeler(url):
    try:
        reponse = requests.get(url, timeout=3)
        reponse.raise_for_status()
    except requests.HTTPError as erreur:
        return f"le serveur a répondu {erreur.response.status_code}"
    except requests.ConnectionError:
        return "connexion impossible (nom inconnu, serveur arrêté, réseau coupé)"
    except requests.Timeout:
        return "pas de réponse dans le délai"
    return f"{reponse.status_code} OK, {len(reponse.content)} octets"


for url in ["https://geo.api.gouv.fr/communes/71270",
            "https://geo.api.gouv.fr/communes/99999",
            "https://api.gsb.example/v1/fiches",
            "http://127.0.0.1:9/"]:
    print(f"{url}\n  -> {appeler(url)}")
text
https://geo.api.gouv.fr/communes/71270
  -> 200 OK, 159 octets
https://geo.api.gouv.fr/communes/99999
  -> le serveur a répondu 404
https://api.gsb.example/v1/fiches
  -> connexion impossible (nom inconnu, serveur arrêté, réseau coupé)
http://127.0.0.1:9/
  -> connexion impossible (nom inconnu, serveur arrêté, réseau coupé)
ExceptionCe qui s'est passéRéessayer ?
HTTPError (après raise_for_status())Le serveur a répondu, avec un code d'erreurOui pour 429, 502, 503, 504 ; non pour 400, 401, 403, 404, 422
ConnectionErrorPas de connexion : nom DNS inconnu, rien n'écoute sur le port, réseau coupéOui, plus tard
TimeoutPas de réponse dans le délaiOui, avec prudence (la requête a peut-être été traitée)

Le nom api.gsb.example n'existe pas sur Internet (le domaine .example est réservé à la documentation) : dans ce cours, c'est le nom de production de l'API AppliFrais du cours API REST, et tes scripts visent plutôt une API qui tourne sur ta machine (paragraphe suivant). Rien n'écoute non plus sur le port 9 de la machine : deux ConnectionError.

S'authentifier avec un jeton

Les exemples GSB de ce chapitre et du chapitre 18 ont besoin d'une API AppliFrais qui tourne sur ta machine : la tienne, si tu l'as développée avec le cours API REST, ou l'API de démonstration api_gsb_demo.py, un seul fichier sans paquet à installer. Lance-la dans un second terminal avec python api_gsb_demo.py : elle écoute sur http://localhost:3000/v1, l'adresse que les scripts prennent par défaut, et accepte le jeton demo-jeton-gsb. Ses données repartent de zéro à chaque lancement, et ce sont elles qui ont produit les résultats affichés.

Une API qui manipule des données privées exige une authentification. La plus courante est le jeton porteur (Bearer token), envoyé dans l'en-tête Authorization de chaque requête (authentifier une API). Le jeton est un secret : il arrive par une variable d'environnement, jamais écrit dans le code (chapitre 10).

python
# creer_fiche.py : envoyer du JSON avec POST et lire une erreur de validation
import os

import requests

API = os.environ.get("GSB_API", "http://localhost:3000/v1")
entetes = {"Authorization": f"Bearer {os.environ['GSB_JETON']}"}

for mois in ["2026-13", "2026-12"]:
    reponse = requests.post(f"{API}/fiches", json={"mois": mois}, headers=entetes, timeout=5)
    if reponse.status_code == 201:
        print("Créée :", reponse.headers["Location"], reponse.json())
    else:
        probleme = reponse.json()           # erreur au format de la RFC 9457
        print(reponse.status_code, probleme["title"])
        for erreur in probleme.get("erreurs", []):
            print(" -", erreur["champ"], ":", erreur["message"])

sans_jeton = requests.get(f"{API}/fiches", timeout=5)
print(sans_jeton.status_code, sans_jeton.json()["title"])
bash
export GSB_API="http://localhost:3000/v1"     # ton API AppliFrais en local (cours API REST)
export GSB_JETON="demo-jeton-gsb"              # avec ta propre API, le jeton obtenu à la connexion
python creer_fiche.py

Contre l'API de démonstration :

text
422 Données invalides
 - mois : Format attendu : AAAA-MM
Créée : /v1/fiches/69 {'id': 69, 'visiteurId': 7, 'mois': '2026-12', 'etat': 'CR', 'montantValide': None}
401 Authentification requise

json= sérialise le dictionnaire et ajoute l'en-tête Content-Type: application/json. L'API renvoie ses erreurs au format de la RFC 9457 (Problem Details) : un objet avec title, status et, ici, la liste des champs refusés. Un client bien écrit affiche ces détails au lieu d'un simple « erreur 422 ». La création réussie renvoie 201 Created et l'adresse de la nouvelle ressource dans l'en-tête Location. Le script demande décembre, car la fiche de novembre du visiteur existe déjà : une seule fiche par visiteur et par mois, et une deuxième demande pour novembre recevrait 409 Conflict. Sans jeton, 401.

Sous PowerShell, les variables s'écrivent $env:GSB_JETON = "…".

Paginer

Une API ne renvoie jamais une collection entière d'un coup : elle la découpe en pages (la pagination). L'API GSB renvoie donnees et un objet pagination qui donne le nombre de pages. Pour tout récupérer, on boucle tant qu'il reste des pages :

python
# fiches.py : lire toutes les fiches clôturées de l'API GSB, page par page, avec un jeton
import os

import requests

API = os.environ.get("GSB_API", "http://localhost:3000/v1")
JETON = os.environ["GSB_JETON"]               # jamais écrit dans le code

session = requests.Session()                  # réutilise la connexion et les en-têtes
session.headers.update({"Authorization": f"Bearer {JETON}", "Accept": "application/json"})


def toutes_les_fiches(etat):
    """Parcourt toutes les pages de /fiches et renvoie la liste complète."""
    fiches, page = [], 1
    while True:
        reponse = session.get(f"{API}/fiches", params={"etat": etat, "page": page, "limite": 20}, timeout=5)
        reponse.raise_for_status()
        corps = reponse.json()
        fiches.extend(corps["donnees"])
        print(f"page {page}/{corps['pagination']['pages']} : {len(corps['donnees'])} fiches")
        if page >= corps["pagination"]["pages"]:
            return fiches
        page += 1


cloturees = toutes_les_fiches("CL")
print(len(cloturees), "fiches clôturées ; la première :", cloturees[0])
text
page 1/3 : 20 fiches
page 2/3 : 20 fiches
page 3/3 : 7 fiches
47 fiches clôturées ; la première : {'id': 1, 'visiteurId': 1, 'mois': '2026-08', 'etat': 'CL', 'montantValide': None}

Le client ne présume pas du nombre de pages : il lit pagination.pages dans chaque réponse. requests.Session() garde la connexion ouverte d'une requête à l'autre et ajoute les en-têtes communs à chaque appel. os.environ["GSB_JETON"], avec des crochets, arrête le script tout de suite si le jeton manque, avec une KeyError qui nomme la variable.

Exercices

Exercice 1 sur 3 Le département d'une commune Réussi À revoir

Avec l'API geo.api.gouv.fr, affiche pour la commune de code 71270 son nom, ses codes postaux et le nom de son département, sous la forme Mâcon (71000) : Saône-et-Loire (71). La route /communes/{code} accepte le paramètre fields (ici nom,codeDepartement,codesPostaux) et la route /departements/{code} renvoie le nom du département.

Voir la réponseMasquer la réponse de l'exercice 1
python
# ex_commune.py : le département d'une commune, en deux appels
import requests

API = "https://geo.api.gouv.fr"

commune = requests.get(f"{API}/communes/71270",
                       params={"fields": "nom,codeDepartement,codesPostaux"}, timeout=10)
commune.raise_for_status()
c = commune.json()

departement = requests.get(f"{API}/departements/{c['codeDepartement']}", timeout=10)
departement.raise_for_status()
d = departement.json()

print(f"{c['nom']} ({', '.join(c['codesPostaux'])}) : {d['nom']} ({d['code']})")
text
Mâcon (71000) : Saône-et-Loire (71)

Le deuxième appel dépend du résultat du premier : c'est le schéma habituel quand une ressource fait référence à une autre. codesPostaux est une liste (une commune peut en avoir plusieurs), d'où le join.

Tu avais trouvé ?

Exercice 2 sur 3 Réessayer sans insister Réussi À revoir

La route /sante de l'API répond parfois 503 Service Unavailable pendant un redémarrage. Écris get_avec_reprises(url, essais=3, attente=0.5) qui réessaie sur les erreurs passagères (429, 502, 503, 504, connexion impossible, délai dépassé), en doublant l'attente à chaque fois, et lève une erreur après le dernier essai. Une erreur définitive (404, 401) ne doit pas être réessayée.

Voir la réponseMasquer la réponse de l'exercice 2
python
# ex_reprises.py : réessayer un appel sur une erreur passagère, avec une attente croissante
import os
import time

import requests

API = os.environ.get("GSB_API", "http://localhost:3000/v1")
PASSAGERES = {429, 502, 503, 504}


def get_avec_reprises(url, essais=3, attente=0.5):
    for essai in range(1, essais + 1):
        try:
            reponse = requests.get(url, timeout=5)
        except (requests.ConnectionError, requests.Timeout):
            statut = None
        else:
            if reponse.status_code not in PASSAGERES:
                reponse.raise_for_status()
                return reponse
            statut = reponse.status_code
        if essai == essais:
            raise RuntimeError(f"échec après {essais} essais (dernier statut : {statut})")
        print(f"essai {essai} : {statut}, nouvelle tentative dans {attente} s")
        time.sleep(attente)
        attente *= 2                      # 0,5 s, puis 1 s, puis 2 s...


print(get_avec_reprises(f"{API}/sante").json())
text
essai 1 : 503, nouvelle tentative dans 0.5 s
essai 2 : 503, nouvelle tentative dans 1.0 s
{'statut': 'ok', 'version': '1.4.0'}

Une erreur définitive passe par raise_for_status() et sort tout de suite. Doubler l'attente (backoff exponentiel) évite de marteler un serveur qui redémarre ; une API qui envoie l'en-tête Retry-After indique elle-même combien attendre. Ne réessaie un POST que si l'API le permet sans risque de doublon (clé d'idempotence).

Tu avais trouvé ?

Exercice 3 sur 3 Le total des fiches validées Réussi À revoir

Calcule le montant total (montantValide) des fiches à l'état VA, toutes pages confondues, en demandant des pages de 2 fiches pour vérifier que ta boucle de pagination fonctionne.

Voir la réponseMasquer la réponse de l'exercice 3
python
# ex_total.py : montant total des fiches validées, toutes pages confondues
import os

import requests

API = os.environ.get("GSB_API", "http://localhost:3000/v1")
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['GSB_JETON']}"

total, nombre, page = 0.0, 0, 1
while True:
    reponse = session.get(f"{API}/fiches", params={"etat": "VA", "page": page, "limite": 2}, timeout=5)
    reponse.raise_for_status()
    corps = reponse.json()
    for fiche in corps["donnees"]:
        total += fiche["montantValide"]
        nombre += 1
    if page >= corps["pagination"]["pages"]:
        break
    page += 1

print(f"{nombre} fiches validées sur {page} pages : {total:.2f} €")
text
4 fiches validées sur 2 pages : 2779.53 €

Avec 4 fiches validées et 2 par page, la boucle fait 2 tours. Une petite taille de page est le meilleur test d'une pagination : avec la taille par défaut, toutes les fiches tiendraient sur une page et une boucle fausse passerait inaperçue. Pour des montants, un vrai programme compterait en centimes entiers ou avec decimal.Decimal, pour éviter les arrondis des flottants.

Tu avais trouvé ?

Chapitre terminé !

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

chapitres
–
de série
–
du cours
–
Chapitre suivant : Classes et dataclasses en Python (POO)Revoir le sommaire du cours