Aller au contenu principal

Appeler une API tierce sans casser ton application

Timeout, codes de statut, retry, backoff exponentiel, jitter, rate limit, idempotence. Cet article détaille les réflexes qui séparent un appel d'API qui fonctionne en démonstration d'un appel qui tient quand le service d'en face vacille. Exemples en Python 3.13 avec requests 2.34, liste des pièges classiques du retry mal réglé, et une FAQ sur les questions qui reviennent le plus souvent.

Guides & tutoriels ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

En vous inscrivant, vous acceptez de recevoir notre newsletter. Désinscription possible à tout moment.

Une application connectée passe son temps à parler à des services qu'elle n'héberge pas : un moteur de paiement, un envoi d'e-mails, un modèle d'IA. Le code qui fait ces appels tient souvent en trois lignes.

En local, ces trois lignes répondent : réseau court, machine calme, quelques requêtes par minute. En production arrivent des réponses jamais croisées pendant le développement, une lenteur passagère un mardi à 14h, une erreur 500 sur un service surchargé, un blocage pour excès de requêtes.

Les tutoriels s'arrêtent presque tous à l'appel qui réussit. La gestion des erreurs, quand elle apparaît, se résume à un try/except qui attrape tout. Ce chapitre manquant décide pourtant du comportement de l'application les jours où l'API va mal.

Tu vas voir comment lire un code de statut, quand réessayer et quand t'arrêter, comment respecter une limite de débit, et quelles erreurs de retry font plus de dégâts que l'absence de retry. Les exemples sont écrits pour Python 3.13 avec requests 2.34, la logique se transpose à n'importe quel langage.

Le moment où une API tierce lâche

Une API tierce, c'est un service exposé par quelqu'un d'autre, sur des machines qu'on ne gère pas, avec des règles qu'on ne fixe pas. Stripe, GitHub, l'API météo gratuite trouvée en deux minutes. Tant que la réponse arrive vite et bien, le code donne l'illusion de fonctionner.

Ce service peut ralentir quand tous ses clients l'appellent en même temps, tomber pour maintenance, renvoyer une erreur passagère qui aurait disparu deux secondes plus tard. Il peut aussi juger ton rythme trop élevé et te couper l'accès, ce qui arrive vite : l'API REST de GitHub autorise 60 requêtes par heure sans authentification, contre 5 000 avec un jeton personnel. Une boucle un peu gourmande épuise le premier quota en moins d'une minute.

Voici l'appel tel qu'on l'écrit le plus souvent :

import requests
 
def get_user(user_id: int) -> dict:
    response = requests.get(f"https://api.exemple.com/users/{user_id}")
    return response.json()

Trois défauts restent invisibles tant que tout va bien. Aucun timeout n'est posé, donc si l'API cesse de répondre sans fermer la connexion, l'appel attend indéfiniment. Le statut HTTP n'est jamais lu, donc une erreur 500 accompagnée d'une page HTML part dans .json() et déclenche une exception de parsing, loin de la cause réelle. Et l'appel abandonne à la première difficulté, même quand un second essai aurait suffi.

Ce que tu cherches, c'est un code qui se comporte correctement quand le service d'en face se comporte mal. Cela commence par distinguer les erreurs entre elles.

Le principe : quatre familles d'erreurs, quatre réactions

Une réponse HTTP arrive toujours avec un code de statut, un nombre à trois chiffres qui résume le sort de la requête. C'est lui qui guide ta décision. Quatre familles suffisent à couvrir la quasi-totalité des situations rencontrées face à une API publique.

Les 2xx : la requête a abouti

Un code dans la tranche 200 signifie que le serveur a traité la demande. C'est le seul cas où la réponse se lit en confiance. Attention au 204 malgré tout : il annonce une réponse sans contenu, et .json() lève une exception dessus alors que rien n'a échoué.

Les 4xx : la requête est en cause

Un code dans la tranche 400 signale une demande que le serveur refuse de traiter telle quelle. Un 401 pointe un jeton absent, expiré ou invalide, un 403 un accès interdit, un 404 une ressource qui n'existe pas à cette adresse, un 422 un contenu mal formé. Répéter l'appel à l'identique te donnera le même résultat.

Le 429 : le rythme est trop élevé

Le code 429 mérite sa propre case. Il signifie "Too Many Requests" : la limite de débit imposée par l'API, son rate limit, vient d'être dépassée. Réessayer a du sens, mais plus tard. Beaucoup d'API indiquent le délai à respecter dans un en-tête Retry-After, exprimé en secondes ou sous forme de date HTTP. Toutes ne suivent pas la norme : GitHub renvoie parfois un 403 quand un quota est épuisé.

Les 5xx : le serveur est en difficulté

Un code dans la tranche 500 signale un problème côté API : 500 générique, 502 quand un proxy n'obtient pas de réponse valide, 503 quand le service est indisponible, 504 sur un timeout interne. Ces erreurs sont souvent passagères, et c'est le terrain où un nouvel essai après une courte pause règle la situation sans que personne s'en aperçoive.

Les coupures réseau forment un cas à part, puisqu'aucun statut n'arrive : connexion refusée, DNS qui ne résout pas, timeout côté client. La plupart se traitent comme les 5xx. Un domaine qui ne résout pas fait exception : il est aussi définitif qu'un 404, et le retenter quatre fois ajoute huit secondes à un échec certain.

Réponse reçue 2xx Lire la réponse 4xx hors 429 Échec, corriger la requête 429 Attendre Retry-After, réessayer 5xx ou réseau Backoff, puis réessayer
La décision à prendre selon la réponse obtenue, avant même d'écrire la moindre ligne de retry.

De cette grille découle une règle courte : on réessaie les erreurs passagères, jamais les définitives. Répéter trois fois une requête avec un jeton expiré produit trois erreurs 401 au lieu d'une.

L'idempotence sépare un retry sûr d'un retry dangereux. Elle revient plus bas, dans les pièges.

En pratique : timeout, retry, backoff et rate limits

On reprend le code du début, par étapes. Si tu construis des API plutôt que d'en consommer, la formation Créer une API REST avec Python et FastAPI te montre l'autre côté du miroir, et voir les deux faces rend ces choix plus lisibles.

Étape 1 : poser un timeout et lire le statut

Première correction, la plus simple et la plus oubliée. On borne le temps d'attente et on vérifie que la requête a abouti avant de toucher au contenu.

import requests
 
def get_user(user_id: int) -> dict:
    response = requests.get(
        f"https://api.exemple.com/users/{user_id}",
        timeout=(3.05, 10),  # 3 s pour établir la connexion, 10 s pour la réponse
    )
    response.raise_for_status()  # lève une exception si statut 4xx ou 5xx
    return response.json()

Le paramètre timeout accepte un simple nombre, mais le tuple sépare deux durées différentes : le temps de connexion et le temps d'attente entre deux paquets. La documentation de requests conseille une valeur de connexion légèrement supérieure à un multiple de 3, à cause du rythme de retransmission TCP, d'où le 3,05 répandu dans les bases de code. La méthode raise_for_status transforme ensuite toute réponse hors 2xx en exception.

Étape 2 : réessayer avec un backoff exponentiel

Sur une erreur passagère, on retente. Pas en boucle immédiate, ce qui ajoute de la charge à un service déjà en peine, mais en attendant de plus en plus longtemps entre chaque tentative. C'est le backoff exponentiel : le délai double à chaque essai, une seconde, puis deux, puis quatre.

import random
import time
 
import requests
 
STATUTS_RETENTABLES = {429, 500, 502, 503, 504}
 
def get_user(user_id: int, max_essais: int = 4) -> dict:
    url = f"https://api.exemple.com/users/{user_id}"
 
    for essai in range(max_essais):
        try:
            response = requests.get(url, timeout=(3.05, 10))
        except requests.exceptions.RequestException:
            pass  # coupure réseau ou timeout, on retente
        else:
            if response.status_code < 400:
                return response.json()
            if response.status_code not in STATUTS_RETENTABLES:
                response.raise_for_status()  # 4xx définitif, inutile d'insister
 
        if essai < max_essais - 1:
            delai = 2 ** essai + random.uniform(0, 1)  # backoff + jitter
            time.sleep(delai)
 
    raise RuntimeError(f"API injoignable après {max_essais} essais : {url}")

Le random.uniform ajouté au délai porte un nom : le jitter. Sans lui, mille clients tombés en erreur à la même seconde réessaient à la même seconde et écrasent l'API une deuxième fois. Ce comportement s'appelle l'effet de troupeau.

Étape 3 : respecter le rate limit annoncé

Quand une API renvoie un 429, elle indique souvent la durée à patienter dans l'en-tête Retry-After. La suivre revient à laisser le serveur décider du rythme. Attention au format : la norme autorise deux écritures, un nombre de secondes ou une date HTTP complète. Un int() posé dessus fonctionne sur la première et lève une exception sur la seconde, ce qui tue l'appel au moment précis où il fallait le rattraper. Une petite fonction gère les deux, plafonne l'attente pour qu'un serveur qui répond 3600 ne bloque pas le programme une heure, et renvoie None quand l'en-tête manque ou reste illisible.

from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
 
PLAFOND_ATTENTE = 60.0  # ne jamais dormir plus longtemps
 
def delai_retry_after(valeur, plafond=PLAFOND_ATTENTE):
    """Retry-After : un nombre de secondes ou une date HTTP."""
    if not valeur:
        return None
    valeur = valeur.strip()
    try:
        delai = float(valeur)
    except ValueError:
        try:
            cible = parsedate_to_datetime(valeur)
        except (TypeError, ValueError):
            return None
        if cible.tzinfo is None:
            cible = cible.replace(tzinfo=timezone.utc)
        delai = (cible - datetime.now(timezone.utc)).total_seconds()
    return max(0.0, min(delai, plafond))

Beaucoup d'API exposent aussi des compteurs comme X-RateLimit-Remaining et X-RateLimit-Reset, qui donnent les requêtes restantes et l'heure de remise à zéro du quota. Les lire permet de ralentir avant le blocage plutôt que de foncer dans le mur. Sur un script qui parcourt une longue liste, une pause déclenchée sous un seuil coûte quelques secondes et évite une coupure d'une heure.

Étape 4 : borner le temps total

Un nombre d'essais borné ne borne pas le temps passé. Quatre tentatives à dix secondes de timeout, backoffs compris, mettent près de cinquante secondes à échouer face à un serveur muet, et la personne qui attendait la page est partie depuis longtemps. Un budget global complète la limite d'essais et raccourcit le dernier timeout au temps restant. La fonction complète, avec le Retry-After de l'étape précédente :

def get_user(user_id: int, max_essais: int = 4, budget: float = 20.0) -> dict:
    url = f"https://api.exemple.com/users/{user_id}"
    echeance = time.monotonic() + budget
 
    for essai in range(max_essais):
        attente = None
        restant = echeance - time.monotonic()
        if restant <= 0:
            break  # budget épuisé, inutile d'ouvrir une connexion
 
        try:
            response = requests.get(url, timeout=(3.05, min(10.0, restant)))
        except requests.exceptions.RequestException:
            pass  # coupure réseau ou timeout, on retente
        else:
            if response.status_code < 400:
                return response.json()
            if response.status_code == 429:
                attente = delai_retry_after(response.headers.get("Retry-After"))
            elif response.status_code not in STATUTS_RETENTABLES:
                response.raise_for_status()  # 4xx définitif, inutile d'insister
 
        if essai == max_essais - 1:
            break  # dernier essai : pas de sommeil avant l'échec
        if attente is None:
            attente = 2 ** essai + random.uniform(0, 1)  # backoff + jitter
        if time.monotonic() + attente > echeance:
            break  # le budget serait dépassé au réveil
 
        time.sleep(attente)
 
    raise RuntimeError(f"API injoignable après {max_essais} essais : {url}")

Le délai dicté par l'API prime, le backoff reprend la main quand aucune valeur exploitable n'arrive, et l'échéance coupe court avant que l'attente cumulée dépasse le budget. Sur le même serveur muet, l'appel échoue en vingt secondes au lieu de cinquante.

Étape 5 : centraliser la logique

Recopier cette boucle à chaque appel devient pénible. La Session de requests couplée à la classe Retry de urllib3, déjà installée puisque requests en dépend, fait le travail :

import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
 
politique = Retry(
    total=4,
    backoff_factor=0.5,
    backoff_jitter=1.0,
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods={"GET", "HEAD", "PUT", "DELETE", "OPTIONS"},
    respect_retry_after_header=True,
)
 
session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=politique))
 
response = session.get("https://api.exemple.com/users/42", timeout=(3.05, 10))

Le détail qui compte ici est allowed_methods, qui exclut POST par défaut. urllib3 refuse de rejouer une méthode non idempotente sans consigne explicite, et ce choix par défaut évite exactement le scénario du double paiement. Trois écarts avec la boucle manuelle méritent d'être connus : total=4 déclenche cinq requêtes, une initiale et quatre reprises ; à l'épuisement, urllib3 lève une RetryError qui ne transporte ni le statut ni le corps de la dernière réponse, donc plus rien à journaliser ; et backoff_jitter demande urllib3 2.0 ou plus récent, la version 1.26 encore très installée rejetant l'argument. RetryError hérite de RequestException, donc mélanger les deux approches ferait avaler l'épuisement des essais par le except précédent. L'autre option est la librairie tenacity, qui encapsule la même mécanique dans un décorateur. Pour du scripting autour de services externes, la formation Automatisation et scripting avec Python détaille ce genre d'outillage.

Ces cinq étapes couvrent la majorité des appels sortants. Quand un service externe devient critique, on y ajoute un disjoncteur, ce qu'on appelle un circuit breaker : après plusieurs échecs consécutifs, le client cesse d'appeler pendant un temps donné.

Les pièges à éviter

Une logique de retry mal pensée fait plus de dégâts qu'une absence de retry. Les erreurs qui reviennent le plus souvent :

  • Rejouer une opération non idempotente. Un POST crée un paiement, la réponse tarde, le client conclut à un échec et relance. Le serveur, lui, avait bien reçu la première requête : deux débits pour un achat. La parade est la clé d'idempotence, un identifiant unique envoyé avec la requête et que l'API utilise pour reconnaître un doublon, comme l'en-tête Idempotency-Key de Stripe.
  • Insister sur une erreur 4xx. Répéter un 401 ou un 404 ne corrige rien et masque la cause réelle, un jeton expiré ou une URL fausse. Tu tries sur le code de statut, pas au jugé.
  • Oublier le jitter. Un backoff sans hasard synchronise toutes les tentatives à la seconde près. Quand un service revient après une panne, des milliers de clients le frappent au même instant et le font retomber. Le jitter coûte une ligne.
  • Boucler sans limite. Un retry infini transforme une panne temporaire de l'API en blocage permanent du service qui l'appelle. Un nombre maximum d'essais, puis un échec propre avec une erreur explicite, vaut mieux qu'une attente sans fin. Et comme un compteur d'essais ne dit rien du temps écoulé, le budget global de l'étape 4 vient le compléter.
  • Se passer de timeout. Sans limite de temps, une requête lente occupe un fil d'exécution, puis un deuxième, jusqu'à figer l'application entière. requests n'applique aucun timeout par défaut.
  • Avaler les erreurs en silence. Un except qui attrape tout et renvoie None laisse aveugle le jour où l'API change de format. Journalise l'URL, le code de statut et le numéro de tentative : deux lignes qui te feront gagner des heures d'enquête.
  • Rejouer une écriture après un timeout de lecture. Un timeout côté client ne prouve pas que le serveur n'a rien fait, seulement que la réponse n'est pas arrivée à temps. Pour une opération qui modifie des données, ce cas se traite comme une incertitude.

Un dernier point touche à la sécurité. Ces appels passent par une clé, qui devrait porter les droits strictement nécessaires à l'usage prévu. Une clé en lecture seule limitée à une ressource fait beaucoup moins de dégâts en cas de fuite. Le raisonnement complet est dans notre article sur le principe de moindre privilège, qui s'applique mot pour mot aux clés d'API.

Cette logique mérite d'être testée, et c'est plus simple qu'il n'y paraît : une bibliothèque comme responses ou respx simule une réponse 500 puis un 200, et vérifie que le code retente le bon nombre de fois. Notre guide sur les tests et le TDD montre comment poser ces filets sans y passer ses journées.

Ce qu'il faut retenir

Consommer une API tierce proprement tient en quelques réflexes. Un timeout sur chaque appel. Un statut lu avant de faire confiance au contenu. Des essais supplémentaires réservés aux erreurs passagères, avec backoff et jitter. Le Retry-After respecté dans ses deux formats. Un budget de temps global. Et aucune répétition d'une écriture sans clé d'idempotence.

Ces quelques lignes ne se voient jamais quand tout va bien. Elles se voient le jour où le service d'en face tombe et où l'application répond quand même.

Questions fréquentes

Quelle est la différence entre un timeout et un retry ?

Le timeout fixe la durée maximale d'attente d'une réponse. Le retry décide de relancer une requête qui a échoué. Le premier provoque un échec rapide quand l'API ne répond pas, le second décide ensuite si l'appel mérite une nouvelle chance.

L'en-tête Retry-After est-il toujours un nombre de secondes ?

Non. La norme autorise aussi une date HTTP complète, du type Fri, 04 Sep 2026 09:28:58 GMT. Un code qui convertit la valeur en entier plante sur cette forme, au moment précis où il fallait rattraper l'appel. Lis les deux formats, et plafonne le résultat pour ne pas dormir une heure sur un quota généreux.

Combien de fois faut-il réessayer une requête ?

Trois à cinq essais suffisent dans la plupart des cas, doublés d'un budget de temps global. Au-delà, la panne est durable et insister ralentit le service appelant sans rien résoudre. Un échec propre et une erreur claire dans les journaux valent mieux qu'une dixième tentative.

Que veut dire le code d'erreur 429 ?

Le code 429 signifie "Too Many Requests" : la limite de débit autorisée par l'API vient d'être dépassée. La bonne réaction consiste à patienter avant de relancer, idéalement la durée indiquée par l'en-tête Retry-After, puis à réduire le rythme d'appels.

Pourquoi ne pas réessayer une erreur 404 ou 401 ?

Ces erreurs viennent de la requête, pas d'un incident passager côté API. Un 404 signale une ressource absente, un 401 une authentification invalide. Relancer donnera la même réponse : la correction se fait sur l'URL ou sur le jeton.

Qu'est-ce qu'une opération idempotente ?

Une opération est idempotente si l'exécuter plusieurs fois produit le même résultat qu'une seule fois. Lire une donnée avec un GET l'est. Créer un paiement avec un POST ne l'est pas toujours, puisque deux appels peuvent créer deux paiements. Les premières se rejouent sans risque ; les autres demandent une clé d'idempotence.

Le backoff exponentiel est-il utile sur un petit projet ?

Oui, dès qu'un appel part vers une API extérieure. Un script qui interroge une API publique gratuite peut se faire bloquer s'il relance trop vite, et certains quotas se comptent en dizaines de requêtes par heure. Quelques lignes de backoff avec jitter suffisent.

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous