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).
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 :
# 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)
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 :
# 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)
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.
| Besoin | urllib (standard) | requests |
|---|---|---|
| Paramètres | urlencode({...}) dans l'URL | params={...} |
| En-têtes | Request(url, headers={...}) | headers={...} |
| Corps JSON | data=json.dumps(...).encode() et en-tête Content-Type | json={...} |
| Lire le JSON | json.load(reponse) | reponse.json() |
| Erreur HTTP | HTTPError levée automatiquement | raise_for_status() |
| Connexion réutilisée, en-têtes communs | À la main | requests.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 :
# 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)}")
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é)
| Exception | Ce qui s'est passé | Réessayer ? |
|---|---|---|
HTTPError (après raise_for_status()) | Le serveur a répondu, avec un code d'erreur | Oui pour 429, 502, 503, 504 ; non pour 400, 401, 403, 404, 422 |
ConnectionError | Pas de connexion : nom DNS inconnu, rien n'écoute sur le port, réseau coupé | Oui, plus tard |
Timeout | Pas de réponse dans le délai | Oui, 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).
# 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"])
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 :
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 :
# 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])
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
# 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']})")
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
# 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())
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
# 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} €")
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é ?