Schreibe den Schutzmechanismus, den alle schreiben:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
Zeig damit auf einen Server, der Statuszeile und Header sofort sendet und dann vor dem Body hängen bleibt. Der Lese-Timeout tritt ein. Der Schutz greift nicht. Stattdessen kommt ConnectionError zurück – und ConnectionError ist keine Unterklasse von Timeout.
Der gleiche Stall löst bei httpx ein ReadTimeout aus. Das ist eine TimeoutException, und genau das fängt der entsprechende Guard ab.
Ich wollte eigentlich herausfinden, wie sich httpx und requests in den Punkten unterscheiden, die Scraper kaputtmachen, und hatte erwartet, darüber asynchrones Verhalten zu schreiben. Am Ende stellte sich heraus, dass Async der am wenigsten spannende Punkt auf der Liste war.
Was ich getestet habe und wie
Acht Probes gegen einen lokalen Fixture-Server – denn wenn ein Client erzählt, was er getan hat, ist das noch kein Beweis dafür, dass er es wirklich getan hat. Der Server zählt TCP-Verbindungen – einmal erhöht pro akzeptiertem Socket, noch bevor eine Request-Zeile geparst wird – und tatsächlich abgerufene Pfade. Verbindungswiederverwendung und Redirect-Folgen sind beides Aussagen über das Wire-Verhalten, und genau dort werden sie geprüft.
httpx 0.28.1 mit dem http2-Extra, requests 2.34.2, Python 3.14.2, macOS arm64. Beides in einem frischen Virtualenv, damit nichts vom jeweils anderen Paket übernommen wird. Rohdaten: httpx-probes.json.
Sechs Vorhersagen gingen vor dem ersten Lauf ins Test-Setup und blieben danach unverändert. Drei stimmten, zwei lagen daneben, eine war für den Fall richtig, den ich im Kopf hatte, und verfehlte den Fall, der wirklich zählte. prediction-scorecard.json enthält die Aufstellung.
Die Defaults, die dich später überraschen

| Verhalten | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| Folgt Redirects standardmäßig | ja | nein |
| Auf Modulebene wird eine Verbindung wiederverwendet | nein | nein |
| Stall vor den Headern | ReadTimeout | ReadTimeout |
| Stall mitten im Body | ConnectionError | ReadTimeout |
| Kein Zeichensatz deklariert | ISO-8859-1 | utf-8 |
| HTTP/2 | nicht verfügbar | per Opt-in, funktioniert |
| Getrennte Connect-/Read-/Write-/Pool-Timeouts | nein | ja |
Redirects, Socket-Wiederverwendung, Exception-Ergebnisse, Dekodierung und Protokoll-Aushandlung wurden in Probes beobachtet. Form der Timeout-API und das Fehlen eines HTTP/2-Schalters bei requests sind Beobachtungen zur API-Fähigkeit. httpx-probes.json.
Drei dieser Zeilen ändern still und leise das Verhalten deines Codes an dem Tag, an dem du wechselst.
Redirects: standardmäßig aus, und der Server beweist es
Eine Redirect-Kette mit vier Hops, die bei /ok endet:
| Client | Vom Server gesehen | Zurückgegebener Status |
|---|---|---|
| requests | 5 Requests | 200 |
| httpx | 1 Request | 302 |
httpx, follow_redirects=True | 5 Requests | 200 |
Die fünf bedeuten vier Hops plus Ziel. Meine Vorhersage sagte vier – eine Rechnung, die ich nicht überprüft habe; die Richtung der Aussage stimmte, und die Anzahl ist hier korrigiert statt stillschweigend im Text zu bleiben.
Das ist dokumentiertes httpx-Verhalten und eine vertretbare Designentscheidung – ein Redirect ist etwas, von dem der Aufrufer vielleicht wissen will. Es ist aber auch der wahrscheinlichste Weg, wie eine Migration ohne Fehlermeldung scheitert. Dein Code bekommt ein 302, response.text ist leer, dein Parser findet keine Zeilen, und deine Logs sagen 200 OK … wobei sie eben 302 sagen, und niemand hat auf den Statuscode geachtet, weil es bei requests dafür nie Anlass gab.
Die Timeout-Erkenntnis, bei der ich mich geirrt habe
Ich hatte erwartet, dass httpx die fehlerhafte Phase präzise benennt und requests beide Fälle in eine Klasse wirft. In Wirklichkeit ist es umgekehrt.
Offizielle Referenz: Requests-Timeout-Dokumentation.

Offizielle Referenz: HTTPX-Timeout-Dokumentation.
| Stall | requests | httpx |
|---|---|---|
| Vor der Statuszeile | ReadTimeout | ReadTimeout |
| Mitten im Body, nachdem Header gesendet wurden | ConnectionError | ReadTimeout |
httpx gibt beiden Fällen denselben, korrekten Namen. requests trennt sie – und trennt sie genau über die Grenze, auf die Retry-Code geschrieben wird.
Die Folge ergibt sich nicht aus der Klassenhierarchie per Schlussfolgerung. Ich habe den Guard direkt ausgeführt:
| Stall | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| Vor der Statuszeile | fängt ab | fängt ab |
| Mitten im Body | entkommt als ConnectionError | fängt ab |
timeout-retry-guard.json. requests.exceptions.ConnectionError ist keine Unterklasse von requests.exceptions.Timeout; httpx.ReadTimeout ist eine Unterklasse von httpx.TimeoutException.
Die requests-Exception meldet Read timed out. innerhalb eines ConnectionError. Die Library weiß also, was passiert ist. Sie teilt es nur dem Typsystem nicht mit – und genau das fragt deine except-Klausel ab.
Der gemessene Fall ist konkret: Header kommen an, dann stoppt der Body-Fortschritt lange genug, um das Read-Timeout zu überschreiten. Ein Response, der innerhalb des Timeout-Fensters weiter Chunks liefert, etwa ein absichtlicher Stream, kann sich anders verhalten und wurde hier nicht getestet.
Connection Pooling: Die Client-API macht den ganzen Unterschied
Zehn GETs, vier Varianten, Sockets serverseitig gezählt:
| Wie | Geöffnete Sockets |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
Das ist identisch – und erwähnenswert, weil es der häufigste Mythos über dieses Paar ist: dass httpx pooled und requests nicht. Auf Modulebene poolt keines von beiden. Beide poolen über ihr Client-Objekt. Wenn du heute requests.get() in einer Schleife aufrufst, ändert ein Wechsel zu httpx.get() in derselben Schleife nichts an deinem Socket-Churn.

HTTP/2 ist explizit und braucht das Extra
Gegen einen öffentlichen HTTP/2-Endpunkt, wie im Artefakt aufgezeichnet:
Offizielle Referenz: RFC 9113: HTTP/2.
| Client | Ausgehandelt |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1, kein Schalter vorhanden |
Du brauchst das httpx[http2]-Extra. Ich hatte angenommen, dass ein einfaches pip install httpx einen Client hinterlässt, der stillschweigend 1.1 aushandelt, und habe es vor dem Aufschreiben überprüft:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
Der Fehler wird bereits bei der Client-Erstellung ausgelöst, noch vor der ersten Anfrage, und die Meldung nennt die Lösung. Das ist die gute Version dieses Fehlers, und ich lag damit daneben (http2-extra-missing.json).
Dieser Probe zeigt, dass auf diesem Endpunkt das Protokoll erfolgreich ausgehandelt wird. Er belegt keinen Vorteil bei der Scraping-Geschwindigkeit; ein vergleichbarer HTTP/1.1-Workload wurde nicht getestet.
Sequenzieller versus paralleler Fixture-Durchsatz
Zwanzig Requests gegen einen Endpunkt, der 0,3 s schläft:
| Modus | Gesamtzeit | Sockets |
|---|---|---|
Sync, ein Client | 6.138 s | 1 |
Async, ein AsyncClient | 0.357 s | 20 |
Der parallele Lauf war mit 0,357 Sekunden deutlich schneller als der sequenzielle Lauf mit 6,138 Sekunden. Gleichzeitig öffnete er zwanzig Verbindungen, während der synchrone Client eine einzige wiederverwendete. Das Experiment misst also ein anderes Ausführungsmodell und effektiv höhere Parallelität, statt nur die reine Bibliotheksgeschwindigkeit zu isolieren.
Das ist die ehrliche Einordnung der Zahl. Es ist eine Messung von Parallelität gegen einen absichtlich langsamen Endpunkt, keine Messung von httpx allein. Jeder Client mit einer funktionierenden Async-Unterstützung landet in derselben Größenordnung, und gegen einen schnellen Endpunkt schrumpft der Abstand.
Der Charset-Fall, den ich nicht vorhergesehen habe
Ich hatte erwartet, dass ein Response, dessen Header lügt – charset=iso-8859-1 bei utf-8-Bytes – in beiden Fällen denselben Zeichensalat erzeugt. Tut er auch. Beide geben Café Ubersetzung â naïve résumé zurück, obwohl die Quelle Café Ubersetzung — naïve résumé lautet.
Der Fall, den ich nicht vorhergesehen habe, ist der wichtige:
| Response | requests dekodiert | httpx dekodiert |
|---|---|---|
charset=utf-8, utf-8-Bytes | korrekt | korrekt |
charset=iso-8859-1, utf-8-Bytes | Zeichensalat | Zeichensalat |
| kein Charset angegeben | Zeichensalat | korrekt |
requests fällt auf ISO-8859-1 zurück, wenn der Header nichts sagt, während httpx standardmäßig utf-8 annimmt. Im Fixture ohne Charset erzeugten die Clients daher über .text unterschiedliche dekodierte Texte; Verbraucher, die response.content nutzen, behalten dieselben ursprünglichen Bytes.
Speicherverbrauch, weil er leicht zu messen ist
Peak RSS, /usr/bin/time -l, pro Zelle ein frischer Prozess:
| Zelle | requests | httpx |
|---|---|---|
| Nur Import | 36.0 MiB | 30.6 MiB |
| Import plus ein GET | 35.8 MiB | 40.7 MiB |
Das sind Einzel-Snapshots pro Prozess, und dass requests bei einem GET etwas unter seinem Import-only-Wert liegt, zeigt das Messrauschen. Daraus lässt sich kein belastbarer Trend beim Speicher ableiten; dafür bräuchte es mehrere Stichproben und Spannweiten.
Was das für deine Wahl bedeutet
Bugs in bestehendem requests-Code finden? Prüfe Annahmen zu Redirects, Handler, die nur requests.exceptions.Timeout abfangen, aber einen Stall mitten im Body abdecken sollen, und .text-Nutzung bei Antworten ohne Charset.
Mechanisch zu httpx migrieren? Wechsle die Exception-Namespace auf httpx.TimeoutException oder die spezifischeren Phasenklassen, entscheide, ob follow_redirects aktiv sein soll, und teste Dekodierungsannahmen erneut. Der vorhandene requests-Handler verpasst den gezeigten Mid-Body-Fall bereits; die Migration erzeugt genau diesen Bug nicht.
Etwas Neues bauen, das viele URLs abruft? httpx ist eine Option, wenn du AsyncClient sowie getrennte Connect-, Read-, Write- und Pool-Timeouts brauchst. Diese Phasen zeigen dir, wo das Warten stattgefunden hat – beim Verbindungsaufbau, beim Fortschritt des Response-Bodys, beim Hochladen der Anfrage oder bei der lokalen Pool-Zuweisung – nicht, warum sich ein Remote-Host so verhalten hat.
Etwas Kleines und Synchrones schreiben? requests ist völlig okay und überall verfügbar. Der Grund für einen Wechsel ist nicht Geschwindigkeit.
Egal wofür du dich entscheidest: Verwende das Client-Objekt statt der Modulebene-Funktion. Das ist die eine Änderung auf dieser Liste, die in beiden Libraries ein klarer Gewinn ist.
Wo eine Managed API passt
Alles oben betrifft die Fetch-Schicht – und genau die ist der einfache Teil. Nichts davon rendert JavaScript, nichts davon löst Anti-Bot-Challenges, und nichts davon verwandelt HTML in die Zeilen, die du eigentlich wolltest.
Hinweis des Autors: Thunderbit ist unsere Managed-Option für Rendering und Extraktion von URL-Inhalten. Sie wurde in diesem HTTP-Client-Harness nicht getestet. Ziehe diese Kategorie nur dann in Betracht, wenn die eigentliche Hürde das Abrufen von Seiten oder die strukturierte Extraktion ist – nicht die Semantik des HTTP-Clients.
Wenn du normale Seiten abrufst und selbst parsest, kommen beide Clients weiterhin infrage. Ein Managed Service ist eine separate Make-or-Buy-Entscheidung und kein Beleg dafür, welche dieser Bibliotheken du wählen solltest.
Für den breiteren Markt deckt unsere Übersicht der Web-Scraping-APIs die gehosteten Optionen ab und das Open-Source-Scraper-Pillar die selbst gehosteten.
Thunderbit für Web-Datenextraktion ausprobieren
Fazit
Für eine neue Python-Fetch-Schicht, die Async-Parallelität, phasenspezifische Timeouts und einen UTF-8-Fallback braucht, ist httpx unter den hier getesteten Bedingungen meine Standardwahl. Requests bleibt für ausgereiften synchronen Code eine gute Option, wenn das Migrationsrisiko schwerer wiegt als diese Vorteile. Proxies, Retry-Strategien, TLS-Fingerprinting, Streaming, Uploads und reale Netzwerkschwankungen wurden nicht getestet – das ist also keine universelle Rangliste für Scraping-Clients.
Vorsicht ist vor allem beim Redirect-Default geboten, und genau deshalb ist er gefährlich: Er ist eine gute Designentscheidung. Explizit ist besser als implizit – bis das implizite Verhalten zu einer tragenden Annahme in bereits ausgeliefertem Code wurde.
Die vorregistrierte Scorecard endete mit drei richtigen Vorhersagen, zwei falschen und einer unvollständigen Vorhersage. Die nützliche Korrektur war die Exception-Klasse beim Mid-Body-Fall; den Rest der Entscheidung sollte man aus dem beobachteten Verhalten ableiten, nicht aus der Erzählung der Scorecard.
Thunderbit für Web-Datenextraktion ausprobieren Get Started Free
FAQs
Folgt httpx wirklich keine Redirects?
Nicht standardmäßig, nein. Der Server zählte bei einer Kette mit vier Hops nur einen Request, und die Antwort kam als 302 zurück. Übergib follow_redirects=True pro Aufruf oder setze es einmal am Client. Das ist dokumentiert und absichtlich so; es ist trotzdem genau die Art von Sache, die bei einer Migration stillschweigend kaputtgehen kann, weil der Fehler ein leerer Parse statt einer Exception ist.
Reicht except requests.exceptions.Timeout wirklich nicht aus?
Nicht bei einem Server, der nach dem Senden der Header hängen bleibt. In diesem Fall wird ConnectionError ausgelöst, und das ist keine Unterklasse von Timeout – also verfehlt der Guard den Fall, was direkt demonstriert wurde statt nur abgeleitet. Fang requests.exceptions.RequestException ab, wenn du beides abdecken willst, und akzeptiere, dass du dann auch Dinge erwischst, die keine Timeouts sind.
Ist httpx schneller als requests? Nicht wirklich, wenn man nur eine Anfrage auf einmal macht – dafür ist es nicht gedacht. Die 17,2-fache Beschleunigung in diesem Test misst zwanzig parallele Requests gegen einen Endpunkt mit 0,3 s Wartezeit, also Parallelität. Wenn dein Workload sequentiell ist, erwarte keinen Speedup und entscheide nach den Defaults.
Brauche ich das http2-Extra?
Nur wenn du HTTP/2 willst – und wenn du http2=True ohne dieses Extra setzt, wirft httpx beim Erstellen des Client einen ImportError, der dir sagt, dass du httpx[http2] installieren sollst. Kein stilles Downgrade also. Ich hatte etwas anderes erwartet und habe nachgeprüft, statt es einfach aufzuschreiben.
Was wurde hier nicht getestet? Proxy-Verhalten, das für Scraping sehr wichtig ist und ein eigenes Test-Setup braucht. Retries – httpx bringt keine eigene Retry-Logik mit, und requests bezieht sie von urllib3, also ist ein fairer Vergleich eigentlich ein Vergleich zweier Retry-Libraries. TLS-Fingerprinting, also genau die Achse, auf die Anti-Bot-Systeme schauen, und die keine der beiden Libraries abdeckt. Streaming und Datei-Uploads. Und außerdem: Alles hier lief auf einem Rechner, mit einer Python-Version und in sechs der acht Probes auf localhost – Latenzwerte von einem Fixture-Server messen das Design, nicht dein Netz.


