httpx + retries/backoff
TL;DR —
httpxest le client HTTP moderne de Python : même API querequests, mais avec un vrai modeasync, 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) unAsyncClientlong-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.
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)
# ❌ 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 avalDeux 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
httpxest un timeout de 5s (contrairement àrequestsqui estNone= infini). Mais ne te repose jamais sur le défaut : unreadde 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
| Cas | Retentable ? | Pourquoi |
|---|---|---|
429 Too Many Requests | ✅ (respecter Retry-After) | rate limit — réessayer plus tard marche |
500 / 502 / 503 / 504 | ✅ | panne transitoire côté serveur |
httpx.ConnectError, ConnectTimeout | ✅ | la requête n'a jamais atteint le serveur |
httpx.ReadTimeout sur un GET | ✅ | GET idempotent |
httpx.ReadTimeout sur un POST non idempotent | ❌ | l'op a peut-être réussi → double effet |
400 / 401 / 403 / 404 / 422 | ❌ | erreur 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.
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 NonePourquoi 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 :
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=Nonesur 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=Truejamais fermée (await resp.aclose()/async with client.stream(...)). Symptôme :pool timeoutqui 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écessitepip install 'httpx[http2]') multiplexe plusieurs requêtes sur une connexion — utile quand tu fais beaucoup d'appels au même host. - Concurrence :
asyncio.gathersur le même client pour fan-out. Le pool limite naturellement le parallélisme ; règlemax_connectionsen conséquence.
Sécurité
- Vérifie le TLS (défaut : oui). Ne désactive
verify=Falsejamais 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).
httpxsuivra une redirection vers169.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_sleepde 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 unevent_hookhttpx 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
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) :
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].textStreaming : 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.
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 :
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") où 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.