Skip to content

httpx + retries/backoff

TL;DRhttpx est le client HTTP moderne de Python : même API que requests, mais avec un vrai mode async, du HTTP/2, du connection pooling explicite et des timeouts granulaires (connect / read / write / pool). En production, un appel réseau qui n'a pas de timeout est un bug, pas un détail : c'est la première cause de fuite de connexions et de freeze d'event loop. La robustesse vient de trois couches qu'il faut distinguer : (1) un AsyncClient long-vécu et réutilisé, (2) des timeouts par phase, (3) une politique de retry avec backoff exponentiel + jitter qui ne retente que les erreurs idempotentes et transitoires (429, 5xx, timeouts réseau) — jamais un POST non idempotent ni un 400. Pour appeler un agent LLM (SDK Anthropic), le SDK fait déjà 1 et 3 pour vous ; vous gardez la main sur les timeouts, le streaming et la boucle de tool-use.


🧠 Mental model

Pense à httpx comme à la plomberie entre ton service et le reste du monde, et aux retries comme au disjoncteur de cette plomberie.

L'analogie qui colle quand on vient de NestJS/Angular (où HttpClient cache tout) : un appel HTTP n'est pas une fonction, c'est quatre rendez-vous successifs, chacun pouvant échouer indépendamment.

   ton process                réseau / serveur distant
   ───────────                ────────────────────────
   [pool]  ──(1)──▶  acquérir une connexion du pool   (pool timeout)
           ──(2)──▶  ouvrir le socket TCP+TLS         (connect timeout)
           ──(3)──▶  écrire la requête                (write timeout)
           ──(4)──▶  ... attendre ... lire la réponse (read timeout)

Le piège mental n°1 quand on arrive de requests : timeout=5 ne veut pas dire « 5 secondes max pour tout l'appel ». C'est un timeout par phase, et surtout par chunk en lecture — il se réarme à chaque octet reçu. Une réponse qui coule au compte-goutte (heartbeats SSE, proxy mal réglé) peut bloquer indéfiniment même avec un timeout. Il n'existe pas de timeout « wall-clock total » natif ; pour un délai dur, c'est asyncio.timeout() qui l'impose, pas httpx.

Le piège mental n°2 : retenter aveuglément. Un retry sur un POST /payments qui a en fait réussi (mais dont la réponse s'est perdue) facture deux fois le client. Le retry est sûr seulement quand l'opération est idempotente et l'erreur transitoire.


Le client : un objet long-vécu, pas une fonction jetable

La bonne façon

httpx.AsyncClient détient un pool de connexions, une config TLS, des limites. On le crée une fois (au démarrage de l'app, via le lifespan FastAPI) et on le réutilise. Le recréer à chaque requête refait le handshake TLS à chaque fois et tue les perfs.

python
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI, Depends

# Timeouts par phase. None = pas de limite (à éviter sauf pour le streaming).
TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)

# Bornes du pool : combien de connexions ouvertes, combien gardées chaudes.
LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20)


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Un seul client pour toute la durée de vie du process.
    async with httpx.AsyncClient(
        base_url="https://api.partner.example",
        timeout=TIMEOUT,
        limits=LIMITS,
        http2=True,  # multiplexage : plusieurs requêtes sur une connexion
        headers={"user-agent": "learning-hub/1.0"},
    ) as client:
        app.state.http = client
        yield
    # le `async with` ferme proprement le pool ici


app = FastAPI(lifespan=lifespan)


def get_http(app_request) -> httpx.AsyncClient:
    return app_request.app.state.http


@app.get("/users/{user_id}")
async def get_user(user_id: int, http: httpx.AsyncClient = Depends(get_http)) -> dict:
    resp = await http.get(f"/v1/users/{user_id}")
    resp.raise_for_status()  # lève httpx.HTTPStatusError sur 4xx/5xx
    return resp.json()

Note l'injection de dépendance FastAPI : le client vit dans app.state, pas en variable globale, et on le passe via Depends. C'est testable (on peut le remplacer par un client monté sur un transport mock) et explicite.

La mauvaise façon (vue partout)

python
# ❌ ANTI-PATTERN : un client neuf par requête
@app.get("/users/{user_id}")
async def get_user_wrong(user_id: int) -> dict:
    async with httpx.AsyncClient() as client:  # nouveau pool + TLS à CHAQUE appel
        resp = await client.get(f"https://api.partner.example/v1/users/{user_id}")
        return resp.json()  # ❌ pas de raise_for_status : un 500 renvoie {} ou lève en aval

Deux fautes : (1) le pool jeté à chaque requête → handshake TLS répété, pas de keep-alive, latence x2-x3 sous charge ; (2) pas de timeout explicite (donc le défaut de 5s, mais surtout aucune intention affichée) et pas de raise_for_status(), donc une réponse d'erreur passe pour un succès.

⚠️ Le défaut de httpx est un timeout de 5s (contrairement à requests qui est None = infini). Mais ne te repose jamais sur le défaut : un read de 5s est trop court pour un appel LLM et trop long pour un health-check. Choisis-le par usage.


Retries : la couche qui décide quoi retenter

Le retry ne se met pas n'importe où ni sur n'importe quoi. La règle senior tient en une phrase : on retente uniquement une erreur transitoire sur une opération idempotente, avec un backoff exponentiel jitteré, et un nombre de tentatives borné.

Ce qui est retentable

CasRetentable ?Pourquoi
429 Too Many Requests✅ (respecter Retry-After)rate limit — réessayer plus tard marche
500 / 502 / 503 / 504panne transitoire côté serveur
httpx.ConnectError, ConnectTimeoutla requête n'a jamais atteint le serveur
httpx.ReadTimeout sur un GETGET idempotent
httpx.ReadTimeout sur un POST non idempotentl'op a peut-être réussi → double effet
400 / 401 / 403 / 404 / 422erreur cliente — retenter redonnera la même erreur

Implémentation maison (comprendre les rouages)

On peut écrire la boucle soi-même. C'est ce que tu dois savoir faire en entretien, même si en prod tu utilises tenacity ou le retry intégré du SDK.

python
import asyncio
import random

import httpx

RETRYABLE_STATUS = {429, 500, 502, 503, 504}
RETRYABLE_EXC = (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout)


async def request_with_retries(
    client: httpx.AsyncClient,
    method: str,
    url: str,
    *,
    max_attempts: int = 4,
    base_delay: float = 0.5,
    max_delay: float = 8.0,
    **kwargs,
) -> httpx.Response:
    """Retry avec backoff exponentiel + full jitter.

    N'appelle CECI que sur des opérations idempotentes
    (GET, PUT, DELETE, ou POST explicitement idempotents).
    """
    last_exc: Exception | None = None
    for attempt in range(1, max_attempts + 1):
        try:
            resp = await client.request(method, url, **kwargs)
        except RETRYABLE_EXC as exc:
            last_exc = exc
        else:
            if resp.status_code not in RETRYABLE_STATUS:
                return resp  # succès, ou erreur cliente non retentable : on rend la main
            last_exc = httpx.HTTPStatusError(
                f"retryable status {resp.status_code}",
                request=resp.request,
                response=resp,
            )
            # Respecter Retry-After si le serveur le donne (429 / 503)
            retry_after = _parse_retry_after(resp)
            if retry_after is not None:
                await asyncio.sleep(retry_after)
                continue

        if attempt == max_attempts:
            break

        # Full jitter : delay = random(0, min(max, base * 2^(attempt-1)))
        ceiling = min(max_delay, base_delay * 2 ** (attempt - 1))
        await asyncio.sleep(random.uniform(0, ceiling))

    assert last_exc is not None
    raise last_exc


def _parse_retry_after(resp: httpx.Response) -> float | None:
    raw = resp.headers.get("retry-after")
    if raw is None:
        return None
    try:
        return float(raw)  # forme "secondes" ; la forme HTTP-date est ignorée ici
    except ValueError:
        return None

Pourquoi full jitter et pas un backoff fixe 2^n sec ? Si 500 clients tombent en même temps sur un 503 et retentent tous exactement à t+2s, tu recrées la panne — c'est le thundering herd. Le jitter étale les retries dans le temps. Le full jitter (random(0, ceiling)) est le schéma recommandé par AWS dans son article de référence sur le sujet.

Ce qu'on fait en vrai : tenacity

En prod, on ne réécrit pas cette boucle à chaque fois. tenacity la fournit, déclarative et testée :

python
import httpx
from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential_jitter,
    before_sleep_log,
)
import logging

logger = logging.getLogger(__name__)


@retry(
    retry=retry_if_exception_type((httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout)),
    wait=wait_exponential_jitter(initial=0.5, max=8.0),
    stop=stop_after_attempt(4),
    before_sleep=before_sleep_log(logger, logging.WARNING),  # observabilité gratuite
    reraise=True,
)
async def fetch_user(client: httpx.AsyncClient, user_id: int) -> dict:
    resp = await client.get(f"/v1/users/{user_id}")
    resp.raise_for_status()
    return resp.json()

Pour retenter aussi sur les status codes (et pas seulement les exceptions), on raise_for_status() à l'intérieur et on ajoute retry_if_result ou on filtre HTTPStatusError par code. Garde la table « retentable » ci-dessus en tête : tenacity ne sait pas que ton POST n'est pas idempotent — c'est toi qui décides où tu poses le décorateur.

L'idempotency key, l'arme du POST sûr. Si l'API distante l'accepte (Stripe, etc.), envoie un header Idempotency-Key: <uuid stable> : le serveur déduplique, et ton POST redevient safe à retenter. C'est la vraie réponse au « peut-on retenter un POST ? ».


⚙️ En production

Modes de défaillance

  • Pas de timeout / timeout trop long. Un read=None sur un appel sortant + un upstream qui hang = un worker FastAPI bloqué indéfiniment, puis tout le pool de workers, puis le service. Toujours un timeout fini en prod. Pour un plafond total (et pas par-phase), enveloppe l'appel : async with asyncio.timeout(20): ....
  • Fuite de connexions. Client recréé par requête, ou réponse stream=True jamais fermée (await resp.aclose() / async with client.stream(...)). Symptôme : pool timeout qui apparaît sous charge alors que l'upstream va bien.
  • Retry storm. Backoff sans jitter + max_attempts élevé → tu amplifies une panne au lieu de l'absorber. Et pire : des retries imbriqués (le client retente, l'API gateway retente, le SDK retente) multiplient la charge. Une seule couche de retry par appel, idéalement la plus externe que tu contrôles.
  • Retenter du non-idempotent. Double paiement, double envoi d'e-mail, double tool-call. Voir la table.

Performance

  • Réutilise le client (connection pooling + keep-alive). C'est le levier n°1.
  • HTTP/2 (http2=True, nécessite pip install 'httpx[http2]') multiplexe plusieurs requêtes sur une connexion — utile quand tu fais beaucoup d'appels au même host.
  • Concurrence : asyncio.gather sur le même client pour fan-out. Le pool limite naturellement le parallélisme ; règle max_connections en conséquence.

Sécurité

  • Vérifie le TLS (défaut : oui). Ne désactive verify=False jamais en prod — c'est une porte ouverte au MITM.
  • SSRF : si l'URL appelée vient d'un input utilisateur, valide-la (host autorisé, pas d'IP privée). httpx suivra une redirection vers 169.254.169.254 (metadata cloud) si tu le laisses faire.
  • Secrets : les clés d'API vont dans les headers du client, pas dans l'URL (les URLs finissent dans les logs).

Observabilité

  • Logge chaque retry (before_sleep de tenacity) avec l'attempt, le delay, et la raison — sinon une dégradation upstream est invisible jusqu'au time-out global.
  • Métrique : compteur de retries par endpoint + histogramme de latence avec retries inclus. Un endpoint « rapide » qui retente 3 fois en silence est un piège.
  • Propage le tracing : injecte les headers traceparent (OpenTelemetry) via un event_hook httpx pour relier l'appel sortant au span courant.

Tradeoffs senior

max_attempts et max_delay sont un arbitrage latence vs disponibilité. Un endpoint user-facing tolère mal 4 retries (l'utilisateur attend) ; un job batch nocturne, oui. Adapte par route. Et au-delà du retry : si un upstream tombe durablement, le retry ne sert plus à rien — il faut un circuit breaker (couper après N échecs consécutifs, laisser passer une requête « sonde » périodiquement). Le retry gère le transitoire ; le breaker gère le durable.


Servir / appeler un agent LLM avec le SDK Anthropic

C'est là que tout converge : appeler un LLM, c'est un appel HTTP long (génération qui peut durer des minutes), souvent streamé, avec des erreurs transitoires fréquentes (429, 529 overloaded). Bonne nouvelle : le SDK anthropic est bâti sur httpx et fait déjà le client long-vécu + les retries pour toi. Tu ne réimplémentes pas la boucle de retry par-dessus le SDK — tu la configures.

Le client : retries intégrés, exceptions typées

python
import os
from anthropic import AsyncAnthropic

# Réutilise ce client (lifespan FastAPI), comme un httpx.AsyncClient.
# max_retries gère DÉJÀ 429 / 5xx / 529 avec backoff exponentiel + respect de Retry-After.
client = AsyncAnthropic(
    api_key=os.environ["ANTHROPIC_API_KEY"],
    max_retries=4,          # défaut = 2 ; le SDK fait le backoff jitteré pour toi
    timeout=60.0,           # surcharge le httpx.Timeout sous-jacent
)

Côté gestion d'erreurs, on utilise les exceptions typées du SDK (jamais du string-matching sur le message) :

python
import anthropic


async def ask(client: AsyncAnthropic, question: str) -> str:
    try:
        msg = await client.messages.create(
            model="claude-opus-4-8",
            max_tokens=1024,
            messages=[{"role": "user", "content": question}],
        )
    except anthropic.RateLimitError:
        # 429 : le SDK a déjà retenté max_retries fois et a fini par abandonner.
        raise
    except anthropic.APIStatusError as exc:
        # finer-grained si besoin : exc.status_code, exc.type ("overloaded_error"…)
        raise
    return msg.content[0].text

Streaming : pourquoi c'est non négociable sur les longues sorties

Une requête à max_tokens élevé peut dépasser le timeout HTTP en mode non-streamé. Le streaming résout ça : chaque token reçu réarme le read timeout, donc la connexion reste vivante pendant toute la génération.

python
async def stream_answer(client: AsyncAnthropic, question: str) -> str:
    chunks: list[str] = []
    async with client.messages.stream(
        model="claude-opus-4-8",
        max_tokens=8192,
        messages=[{"role": "user", "content": question}],
    ) as stream:
        async for text in stream.text_stream:
            chunks.append(text)
            # ici tu pousserais `text` vers le client SSE/WebSocket en temps réel
        final = await stream.get_final_message()  # message complet + usage
    return "".join(chunks)

Dans une route FastAPI, on relaie ces tokens en SSE (StreamingResponse), ce qui couvre le cas « afficher la réponse de l'agent au fur et à mesure » côté Angular.

Sorties structurées et boucle de tool-use

Pour une sortie structurée (extraire un JSON validé), utilise les structured outputs natifs plutôt que de parser à la main :

python
from pydantic import BaseModel


class Ticket(BaseModel):
    title: str
    severity: str
    needs_human: bool


msg = await client.messages.parse(
    model="claude-opus-4-8",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Classe ce log: disque plein sur prod-db-3"}],
    output_format=Ticket,  # convenience SDK Python : traduit en output_config.format
)
ticket: Ticket = msg.parsed_output  # typé, validé

Pour un agent avec tools, la boucle (le modèle demande un tool → tu l'exécutes → tu renvoies le résultat → il continue) suit la même discipline réseau : le SDK gère les retours transitoires, toi tu gères l'idempotence de tes tools (un tool send_email ne doit pas s'exécuter deux fois si la boucle est rejouée) et le stop_reason (tool_use, end_turn, refusal). Le SDK expose aussi un tool runner qui automatise la boucle ; la version manuelle reste utile quand tu veux un gate d'approbation humaine avant chaque exécution de tool.

💡 Prompt caching (cache_control) sur le system prompt stable d'un agent : ~0,1x le prix d'input sur la partie cachée. C'est l'optimisation coût n°1 quand tu sers le même gros préambule à chaque tour. Garde le contenu stable devant (le cache est un préfixe — un seul octet qui change invalide tout ce qui suit).


🏋️ Exercices

1. Le client lifespan + DI (échauffement)

Objectif : monter un AsyncClient dans le lifespan FastAPI, l'injecter via Depends, exposer un endpoint GET /proxy/{path} qui relaie vers un upstream avec raise_for_status() et timeouts par phase.

Indice/Solution : copie le squelette lifespan ci-dessus. Le piège à éviter : ne pas mettre le client en variable module-globale (intestable) — passe par app.state + Depends(get_http). Vérifie en test qu'un upstream qui renvoie 503 fait bien remonter une HTTPStatusError.

2. Backoff jitteré à la main (cœur du sujet)

Objectif : réimplémenter request_with_retries sans regarder, avec full jitter, respect de Retry-After, et la table « retentable ». Écris un test qui prouve que 3 réponses 503 puis un 200 → 1 seul succès et 3 sleeps ; et qu'un 400 → 0 retry.

Indice/Solution : monte un httpx.MockTransport qui renvoie une séquence scriptée de réponses. Mocke asyncio.sleep (AsyncMock) pour vérifier le nombre d'appels et que les delays sont bien dans [0, ceiling]. Le bug classique : retenter aussi sur le dernier essai — borne avec if attempt == max_attempts: break.

3. Rendre un POST sûr à retenter (production-grade)

Objectif : transformer un POST /charge non idempotent en opération retentable via une Idempotency-Key. Génère une clé stable par tentative logique (pas par tentative HTTP — la clé reste la même sur les retries), et prouve qu'un double envoi ne facture qu'une fois.

Indice/Solution : la clé doit être générée avant la boucle de retry et passée en header constant à toutes les tentatives. Si tu la régénères dans la boucle, tu casses la déduplication. Simule un serveur qui mémorise les clés vues dans un dict et renvoie la réponse mémorisée pour une clé déjà connue.

4. Plafond wall-clock + annulation propre (dur)

Objectif : imposer un délai total de 10s sur une chaîne d'appels (avec retries inclus), via asyncio.timeout(). À l'expiration, garantir qu'aucune connexion ne fuit (pas de socket laissé ouvert).

Indice/Solution : httpx ne te donne pas de wall-clock total — c'est async with asyncio.timeout(10): qui annule la tâche. Vérifie que CancelledError se propage et que le async with client.stream(...) ferme bien la réponse via son __aexit__. Teste le cas « la 2e tentative dépasse le budget restant ».

5. Streaming LLM relayé en SSE (AI, intégration)

Objectif : exposer POST /chat qui streame la réponse de client.messages.stream() vers le client en Server-Sent Events. Gérer la déconnexion du client (arrêter la génération) et logguer l'usage final.

Indice/Solution : StreamingResponse(gen(), media_type="text/event-stream")gen() est un async generator qui yield f"data: {text}\n\n". Pour détecter la déconnexion, surveille await request.is_disconnected() et sors de la boucle (le async with stream se ferme et annule la requête HTTP sous-jacente). Le piège : oublier de récupérer get_final_message() → tu perds l'usage pour le billing.

6. Break-then-fix : la tempête de retries (casser puis réparer)

Objectif : on te donne un service où trois couches retentent (client → un wrapper interne → le SDK), avec backoff fixe sans jitter. Sous une panne upstream de 503, mesure l'amplification de charge, puis corrige.

Indice/Solution : compte les requêtes réelles atteignant un MockTransport pour une seule requête logique — tu verras 4 × 4 × ... tentatives. Fix : (1) une seule couche de retry (la plus externe que tu maîtrises, ou celle du SDK — pas les deux), (2) ajoute du full jitter, (3) ajoute un circuit breaker qui ouvre après N échecs consécutifs et court-circuite les appels pendant un cooldown. Prouve que la charge upstream redevient bornée.


🎤 En entretien

« Quelle est la différence entre timeout=5 dans httpx et un délai total de 5s ? »timeout=5 est un timeout par phase (connect/read/write/pool) qui se réarme à chaque chunk en lecture — une réponse qui coule lentement peut bloquer bien au-delà. Pour un plafond wall-clock dur, il faut envelopper l'appel dans asyncio.timeout().

« Quand est-il sûr de retenter un appel HTTP, et quand est-ce dangereux ? » Sûr si l'opération est idempotente (GET/PUT/DELETE, ou POST avec idempotency key) et l'erreur transitoire (429, 5xx, timeout réseau côté connexion). Dangereux sur un POST non idempotent en ReadTimeout (l'op a peut-être réussi → double effet) ou sur une 4xx (retenter redonnera la même erreur).

« Pourquoi du jitter dans le backoff exponentiel ? » Sans jitter, des clients qui échouent ensemble retentent tous au même instant (t+2s, t+4s…) et recréent la panne — thundering herd. Le full jitter (random(0, ceiling)) étale les retries et laisse l'upstream récupérer.

« Pour servir un agent LLM, tu réécris ta boucle de retry par-dessus le SDK Anthropic ? » Non. Le SDK AsyncAnthropic est bâti sur httpx et gère déjà client long-vécu + retries (429/5xx/529) avec backoff. On le configure (max_retries, timeout), on attrape ses exceptions typées, et on streame les longues sorties pour ne pas time-out. Empiler une boucle de retry maison par-dessus crée une tempête de retries imbriqués.

Bibliothèque tech perso — Achref