Écris la garde que tout le monde écrit :
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
Fais-la pointer vers un serveur qui envoie tout de suite sa ligne de statut et ses en-têtes, puis se bloque avant le corps. Le délai de lecture expire. La garde ne s’active pas. L’exception renvoyée est ConnectionError, et ConnectionError n’est pas une sous-classe de Timeout.
Avec httpx, le même blocage lève ReadTimeout, qui est un TimeoutException, et la garde équivalente le capture bien.
Je suis allé regarder en quoi httpx diffère de requests sur les points qui font sauter les scrapers, en pensant écrire sur l’async. Au final, l’async s’est révélé être le sujet le moins intéressant de la liste.
Ce que j’ai testé et comment
Huit probes sur un serveur de test local, parce qu’un client qui raconte ce qu’il a fait ne constitue pas une preuve de ce qu’il a réellement fait. Le serveur compte les connexions TCP — incrémentées une fois par socket acceptée, avant même l’analyse de la ligne de requête — et les chemins effectivement récupérés. La réutilisation des connexions et le suivi des redirections sont des affirmations sur le fil réseau, et c’est sur ce fil qu’elles se vérifient.
httpx 0.28.1 avec l’option http2, requests 2.34.2, Python 3.14.2, macOS arm64. Les deux dans un nouvel environnement virtuel, afin qu’aucun n’hérite d’une empreinte de l’autre. Résultat brut : httpx-probes.json.
Six prédictions ont été inscrites dans le harness avant le premier lancement et y sont restées après. Trois se sont confirmées, deux étaient fausses, une était juste pour le cas auquel je pensais, mais ratée sur le cas qui comptait. prediction-scorecard.json contient le détail.
Les valeurs par défaut qui changent sous vos pieds

| Comportement | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| Suit les redirections par défaut | oui | non |
| Un appel au niveau du module réutilise les connexions | non | non |
| Blocage avant les en-têtes | ReadTimeout | ReadTimeout |
| Blocage en milieu de corps | ConnectionError | ReadTimeout |
| Aucun charset déclaré | ISO-8859-1 | utf-8 |
| HTTP/2 | indisponible | optionnel, fonctionne |
| Timeouts séparés pour connect/read/write/pool | non | oui |
Les redirections, la réutilisation des sockets, les types d’exception, le décodage et la négociation de protocole ont été observés via les probes. La forme de l’API de timeout et l’absence d’un indicateur HTTP/2 dans requests relèvent des capacités de l’API. httpx-probes.json.
Trois de ces lignes vont changer silencieusement le comportement de votre code le jour où vous basculerez.
Redirections : désactivées par défaut, et le serveur le prouve
Une chaîne de redirections en quatre sauts menant à /ok :
| Client | Ce que le serveur a vu | Statut renvoyé |
|---|---|---|
| requests | 5 requêtes | 200 |
| httpx | 1 requête | 302 |
httpx, follow_redirects=True | 5 requêtes | 200 |
Le chiffre cinq correspond à quatre sauts plus la destination. Ma prédiction parlait de quatre, ce qui était un calcul que je n’ai pas vérifié ; le sens était correct, et le compte est rectifié ici plutôt que discrètement dans le texte.
C’est le comportement documenté de httpx, et c’est un choix défendable — une redirection est une information que l’appelant peut vouloir connaître. C’est aussi la manière la plus probable de casser une migration sans lever d’erreur. Votre code reçoit un 302, response.text est vide, votre parser ne trouve aucune ligne, et vos logs disent 200 OK… sauf qu’en réalité ils disent 302, et personne ne surveillait le code de statut parce qu’avec requests il n’y avait jamais rien à surveiller.
La découverte sur les timeouts, et j’avais tout inversé
Je pensais que httpx nommerait la phase en échec et que requests fusionnerait tout dans une seule classe. C’est l’inverse.
Référence officielle : Documentation des timeouts de Requests.

Référence officielle : Documentation des timeouts d’HTTPX.
| Blocage | requests | httpx |
|---|---|---|
| Avant la ligne de statut | ReadTimeout | ReadTimeout |
| Milieu du corps, après l’envoi des en-têtes | ConnectionError | ReadTimeout |
httpx donne le même nom, et le bon, dans les deux cas. requests les sépare — et les sépare précisément sur la frontière sur laquelle le code de retry s’appuie.
La conséquence ne vient pas de la hiérarchie des classes. J’ai exécuté la garde :
| Blocage | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| Avant la ligne de statut | capture | capture |
| Milieu du corps | échappe sous forme de ConnectionError | capture |
timeout-retry-guard.json. requests.exceptions.ConnectionError n’est pas une sous-classe de requests.exceptions.Timeout; httpx.ReadTimeout est une sous-classe de httpx.TimeoutException.
Le message d’exception de requests indique Read timed out. à l’intérieur d’un ConnectionError. La bibliothèque sait ce qui s’est passé. Elle ne le remonte simplement pas au système de types, et c’est justement ce système que votre clause except consulte.
Le cas mesuré est précis : les en-têtes arrivent, puis la progression du corps s’arrête assez longtemps pour dépasser le délai de lecture. Une réponse qui continue à envoyer des fragments dans la fenêtre du timeout, y compris un flux volontaire, peut se comporter différemment et n’a pas été testée ici.
Le pooling de connexions : toute la différence se joue dans l’API client
Dix GET, quatre façons, sockets comptés côté serveur :
| Méthode | Sockets ouverts |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
Identique, et cela mérite d’être dit, car c’est l’idée reçue la plus fréquente à propos de ce duo : httpx mutualise les connexions et requests non. Aucun des deux ne le fait au niveau du module. Les deux passent par leur objet client. Si vous appelez aujourd’hui requests.get() en boucle, passer à httpx.get() en boucle ne change rien à l’usure des sockets.

HTTP/2 est explicite et nécessite l’option supplémentaire
Sur un point de terminaison HTTP/2 public consigné dans l’artefact :
Référence officielle : RFC 9113: HTTP/2.
| Client | Négociation |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1, aucun indicateur n’existe |
Tu as besoin de l’option httpx[http2]. Je pensais qu’un simple pip install httpx te laisserait avec un client qui négocie silencieusement en 1.1, et je suis allé vérifier avant de l’écrire :
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
L’erreur est levée à la construction de Client, avant la moindre requête, et le message indique la correction. C’est la bonne version de cet échec, et je m’étais trompé là-dessus (http2-extra-missing.json).
Cette probe confirme une négociation de protocole réussie sur ce point de terminaison. Elle ne démontre pas de gain de vitesse pour le web scraping ; aucune charge HTTP/1.1 comparable n’a été testée.
Débit séquentiel versus concurrent sur le fixture
Vingt requêtes vers un endpoint qui dort 0,3 s :
| Mode | Temps total | Sockets |
|---|---|---|
Sync, un Client | 6.138 s | 1 |
Async, un AsyncClient | 0.357 s | 20 |
L’exécution concurrente s’est terminée en 0,357 seconde contre 6,138 secondes pour l’exécution séquentielle. Elle a aussi ouvert vingt connexions tandis que le client synchrone en réutilisait une seule, donc l’expérience modifie le modèle d’exécution et la concurrence effective plutôt que d’isoler la vitesse de la bibliothèque.
C’est l’interprétation honnête du chiffre. C’est une mesure de concurrence face à un endpoint volontairement lent, pas une mesure de httpx. Tout client avec une implémentation async fonctionnelle se situerait dans la même zone, et face à un endpoint rapide l’écart disparaît.
Le cas du charset auquel je n’ai pas pensé à me préparer
J’ai prédit qu’une réponse dont l’en-tête ment — charset=iso-8859-1 sur des octets utf-8 — produirait le même mojibake dans les deux bibliothèques. C’est le cas. Les deux renvoient Café Ubersetzung â naïve résumé alors que la source indique Café Ubersetzung — naïve résumé.
Le cas que je n’avais pas anticipé est celui qui compte :
| Réponse | décodage requests | décodage httpx |
|---|---|---|
charset=utf-8, octets utf-8 | correct | correct |
charset=iso-8859-1, octets utf-8 | mojibake | mojibake |
| aucun charset du tout | mojibake | correct |
requests retombe sur ISO-8859-1 quand l’en-tête ne dit rien, tandis que httpx utilise utf-8 par défaut. Dans le fixture sans charset, les clients ont donc produit un texte décodé différent via .text; les consommateurs de response.content conservent les mêmes octets d’origine.
Mémoire, puisqu’elle est facile à mesurer
RSS de pointe, /usr/bin/time -l, un processus neuf par cellule :
| Cellule | requests | httpx |
|---|---|---|
| Import seul | 36.0 MiB | 30.6 MiB |
| Import + un GET | 35.8 MiB | 40.7 MiB |
Ce sont des instantanés à un seul processus, et la valeur « import + un GET » de requests légèrement inférieure à sa valeur « import seul » montre le bruit de mesure. Elles ne permettent aucune conclusion directionnelle sur la mémoire ; il faudrait des échantillons répétés et des intervalles.
Ce que cela signifie au moment de choisir
Tu cherches des bugs dans du code requests existant ? Vérifie les hypothèses sur les redirections, les gestionnaires qui ne capturent que requests.exceptions.Timeout alors qu’ils pensent couvrir un blocage en milieu de réponse, et les consommateurs de .text qui reçoivent des réponses sans charset.
Tu migres mécaniquement vers httpx ? Remplace les espaces de noms d’exceptions par httpx.TimeoutException ou par des classes de phase plus précises, décide si tu actives follow_redirects, et reteste les hypothèses de décodage. Le gestionnaire requests existant rate déjà le cas de blocage en milieu de corps démontré ici ; la migration ne crée pas ce bug précis.
Tu écris quelque chose de neuf qui récupère beaucoup d’URL ? httpx est un bon candidat si tu as besoin de AsyncClient et de timeouts séparés pour connect, read, write et pool. Ces phases te disent où l’attente a eu lieu — établissement de la connexion, progression du corps de réponse, envoi de la requête ou acquisition d’un slot du pool — pas pourquoi un hôte distant s’est comporté ainsi.
Tu écris quelque chose de petit et synchrone ? requests convient très bien et est omniprésent. La raison de changer n’est pas la vitesse.
Quel que soit ton choix, utilise l’objet client plutôt que la fonction au niveau du module. C’est le seul changement de cette liste qui soit clairement gagnant dans les deux bibliothèques.
Où une API managée a sa place
Tout ce qui précède concerne la couche de récupération, et la récupération est la partie facile. Rien de tout cela n’exécute JavaScript, rien ne gère un défi anti-bot, et rien ne transforme le HTML en lignes exploitables.
Note de l’auteur : Thunderbit est notre option managée pour le rendu et l’extraction à partir d’une URL. Elle n’a pas été testée dans ce harness de client HTTP. Ne considère cette catégorie que lorsque l’acquisition de la page ou l’extraction structurée — et non la sémantique du client HTTP — est le problème que tu essaies de supprimer.
Si tu récupères des pages ordinaires et les analyses toi-même, les deux clients restent pertinents. Un service managé est une décision séparée de build vs buy, pas une preuve pour choisir entre ces bibliothèques.
Pour une vue plus large du secteur, notre comparatif des API de web scraping couvre les options hébergées et le pilier open source des scrapers les solutions auto-hébergées.
Essayez Thunderbit pour l’extraction de données web
Verdict
Pour une nouvelle couche de récupération Python qui a besoin de concurrence async, de timeouts par phase et d’un fallback UTF-8, httpx est mon choix par défaut dans le cadre testé ici. requests reste parfaitement valable pour du code synchrone mature lorsque le risque de migration dépasse ces avantages. Les proxies, les politiques de retry, l’empreinte TLS, le streaming, les uploads et la variabilité réseau réaliste n’ont pas été testés ; ce n’est donc pas un classement universel des clients de scraping.
La raison d’être prudent, c’est la valeur par défaut des redirections, et c’est un vrai danger précisément parce que c’est une bonne décision de conception. Le explicite vaut mieux que l’implicite… jusqu’au jour où l’implicite était une pierre porteuse dans le code que tu as déjà livré.
La scorecard préenregistrée s’est terminée avec trois prédictions correctes, deux incorrectes et une incomplète. La correction utile portait sur la classe d’exception en milieu de réponse ; le reste de la décision doit venir du comportement observé plutôt que du récit de la scorecard.
Essayez Thunderbit pour l’extraction de données web Get Started Free
FAQ
httpx ne suit vraiment pas les redirections ?
Pas par défaut, non. Le serveur n’a compté qu’une requête pour une chaîne en quatre sauts, et la réponse est revenue en 302. Passe follow_redirects=True à chaque appel, ou définis-le une fois sur le Client. C’est documenté et volontaire ; c’est quand même ce qui risque le plus de casser silencieusement lors d’une migration, parce que l’échec prend la forme d’un parsing vide plutôt que d’une exception.
except requests.exceptions.Timeout ne suffit vraiment pas ?
Pas pour un serveur qui se fige après l’envoi des en-têtes. Ce cas lève ConnectionError, qui n’est pas une sous-classe de Timeout, donc la garde le rate — démontré directement et non déduit. Capture requests.exceptions.RequestException si tu veux englober les deux, en acceptant que tu captureras aussi des choses qui ne sont pas des timeouts.
httpx est-il plus rapide que requests ? Pas de manière significative pour une requête à la fois — ce n’est pas son objectif. Le 17,2× de ce test mesure vingt requêtes concurrentes contre un endpoint de 0,3 s, donc la concurrence. Si ta charge est séquentielle, n’attends pas de gain et choisis plutôt selon les valeurs par défaut.
Ai-je besoin de l’option http2 ?
Seulement si tu veux HTTP/2 — et si tu définis http2=True sans elle, httpx lève un ImportError lors de la construction du Client, avec un message qui te demande d’installer httpx[http2]. Aucun downgrade silencieux à craindre. Je m’attendais à l’inverse et j’ai vérifié au lieu de l’écrire.
Qu’est-ce qui n’a pas été testé ici ? Le comportement des proxys, qui compte énormément pour le web scraping et nécessite son propre harness. Les retries — httpx n’inclut aucune logique de retry et requests l’obtient via urllib3, donc une comparaison équitable revient en réalité à comparer deux bibliothèques de retry. L’empreinte TLS, qui est l’axe réellement observé par les systèmes anti-bot et auquel aucune des deux bibliothèques ne répond. Le streaming et les envois de fichiers. Et ici tout repose sur une seule machine, une seule version de Python et localhost pour six des huit probes — les chiffres de latence d’un serveur de test mesurent le design, pas ton réseau.


