Zapiszmy strażnika, którego pisze prawie każdy:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
Teraz skierujmy go na serwer, który od razu odsyła status i nagłówki, a potem zawiesza się przed wysłaniem body. Odczyt przekroczy limit czasu. Strażnik nie zadziała. Zamiast tego pojawi się ConnectionError, a ConnectionError nie jest podklasą Timeout.
Ten sam scenariusz w httpx kończy się ReadTimeout, a to już jest TimeoutException, więc odpowiedni handler go przechwyci.
Chciałem sprawdzić, czym httpx różni się od requests w sposób, który potrafi wysadzić scrapery, i spodziewałem się tekstu o async. Okazało się, że async było najmniej ciekawą rzeczą na liście.
Co testowałem i jak
Osiem prób na lokalnym serwerze testowym, bo deklaracje klienta o tym, co zrobił, to jeszcze nie dowód. Serwer zlicza połączenia TCP — zwiększane raz na każdy zaakceptowany socket, zanim zostanie sparsowana pierwsza linia żądania — oraz rzeczywiście pobrane ścieżki. Reużycie połączeń i podążanie za przekierowaniami to twierdzenia o ruchu sieciowym, a sieć to miejsce, w którym można je zweryfikować.
httpx 0.28.1 z dodatkiem http2, requests 2.34.2, Python 3.14.2, macOS arm64. Oba w świeżym virtualenv, żeby jedno nie dziedziczyło po drugim żadnego śladu. Surowy wynik: httpx-probes.json.
Sześć przewidywań trafiło do harnessa jeszcze przed pierwszym uruchomieniem i zostało tam do końca. Trzy się potwierdziły, dwa były błędne, jedno było trafne dla przypadku, który miałem na myśli, ale nie dla tego, który naprawdę miał znaczenie. Rozliczenie jest w prediction-scorecard.json.
Domyślne ustawienia, które zmieniają zachowanie po przejściu na inne narzędzie

| Zachowanie | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| Domyślnie śledzi przekierowania | tak | nie |
| Wywołanie na poziomie modułu ponownie używa połączeń | nie | nie |
| Zawieszenie przed nagłówkami | ReadTimeout | ReadTimeout |
| Zawieszenie w trakcie body | ConnectionError | ReadTimeout |
| Brak zadeklarowanego charsetu | ISO-8859-1 | utf-8 |
| HTTP/2 | niedostępne | opcjonalne, działa |
| Osobne timeouty dla connect/read/write/pool | nie | tak |
Przekierowania, ponowne użycie socketów, wyniki wyjątków, dekodowanie i negocjacja protokołu zostały zaobserwowane w testach. Kształt API timeoutów oraz brak flagi HTTP/2 w requests to obserwacje dotyczące możliwości API. httpx-probes.json.
Trzy z tych wierszy zmienią działanie Twojego kodu w dniu migracji, i to bez żadnego ostrzeżenia.
Przekierowania: wyłączone domyślnie, a serwer to potwierdza
Łańcuch przekierowań o czterech skokach, kończący się na /ok:
| Klient | Co zobaczył serwer | Zwrócony status |
|---|---|---|
| requests | 5 żądań | 200 |
| httpx | 1 żądanie | 302 |
httpx, follow_redirects=True | 5 żądań | 200 |
Pięć to cztery skoki plus adres docelowy. W mojej prognozie było cztery — arytmetyka, której nie sprawdziłem; kierunek był sednem tezy, a liczba została tu poprawiona zamiast po cichu w tekście.
To zgadza się z dokumentacją httpx i ma sens projektowy — przekierowanie to coś, o czym wywołujący może chcieć wiedzieć. Jednocześnie to najprostszy sposób, żeby migracja rozjechała się bez żadnego wyjątku. Kod dostaje 302, response.text jest puste, parser nie znajduje wierszy, a log mówi 200 OK… no właśnie nie: mówi 302, tylko nikt wcześniej nie pilnował statusu, bo w requests nie było czego pilnować.
Wynik timeoutu, który odczytałem odwrotnie
Przewidywałem, że httpx nazwie konkretną fazę, która się wyłożyła, a requests wrzuci oba przypadki do jednej klasy. Jest odwrotnie.
Oficjalne źródło: dokumentacja timeoutów Requests.

Oficjalne źródło: dokumentacja timeoutów HTTPX.
| Zawieszenie | requests | httpx |
|---|---|---|
| Przed linią statusu | ReadTimeout | ReadTimeout |
| W trakcie body, po wysłaniu nagłówków | ConnectionError | ReadTimeout |
httpx nadaje ten sam, poprawny opis obu przypadkom. requests rozdziela je — i robi to na granicy, na której zwykle opiera się kod retry.
Konsekwencja nie wynika tu z samej hierarchii klas. Uruchomiłem strażnika:
| Zawieszenie | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| Przed linią statusu | łapie | łapie |
| W trakcie body | ucieka jako ConnectionError | łapie |
timeout-retry-guard.json. requests.exceptions.ConnectionError nie jest podklasą requests.exceptions.Timeout; httpx.ReadTimeout jest podklasą httpx.TimeoutException.
Komunikat wyjątku w requests mówi Read timed out. wewnątrz ConnectionError. Biblioteka wie, co się stało. Po prostu nie przekazuje tego typom, a to właśnie typ sprawdza Twoje except.
Ten przypadek jest bardzo konkretny: nagłówki przychodzą, a potem postęp body zatrzymuje się na tyle długo, że przekracza read timeout. Odpowiedź, która dalej dostarcza porcje danych w oknie timeoutu, w tym strumień celowo rozciągany w czasie, może zachowywać się inaczej i nie była tu testowana.
Pooling połączeń: cała różnica tkwi w API klienta
Dziesięć GET-ów, cztery sposoby, sockety liczone po stronie serwera:
| Sposób | Otwarte sockety |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
To identyczny wynik i warto to zaznaczyć, bo to najczęstszy element obiegowej wiedzy o tej parze — że httpx buforuje połączenia, a requests nie. Na poziomie modułu nie robi tego żaden z nich. Oba korzystają z poolingu przez obiekt klienta. Jeśli dziś wywołujesz requests.get() w pętli, przejście na httpx.get() w pętli nic nie zmieni w sposobie zużywania socketów.

HTTP/2 jest jawne i wymaga dodatkowego pakietu
Dla jednego publicznego endpointu HTTP/2 zapisanego w artefakcie:
Oficjalne źródło: RFC 9113: HTTP/2.
| Klient | Wynegocjowano |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1, nie ma takiej flagi |
Potrzebujesz dodatku httpx[http2]. Założyłem, że zwykłe pip install httpx da klienta, który po cichu dogaduje się przez 1.1, i sprawdziłem to zanim cokolwiek napisałem:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
Wyjątek pojawia się przy tworzeniu Client, zanim zostanie wykonane choć jedno żądanie, a komunikat wskazuje poprawkę. To jest dobra wersja takiego błędu, a ja miałem odwrotne oczekiwanie (http2-extra-missing.json).
Ten test potwierdza poprawną negocjację protokołu na tym endpointcie. Nie dowodzi przewagi prędkości scrapowania; nie testowano porównywalnego workloadu HTTP/1.1.
Przepustowość sekwencyjna i współbieżna na fixture
Dwadzieścia żądań do endpointu, który śpi 0,3 s:
| Tryb | Czas ścienny | Sockety |
|---|---|---|
Sync, jeden Client | 6.138 s | 1 |
Async, jeden AsyncClient | 0.357 s | 20 |
Wersja współbieżna zakończyła się po 0,357 sekundy wobec 6,138 sekundy dla sekwencyjnej. Jednocześnie otworzyła dwadzieścia połączeń, podczas gdy klient synchroniczny użył jednego ponownie, więc eksperyment mierzy zmianę modelu wykonania i efektywnej współbieżności, a nie samą szybkość biblioteki.
To uczciwy opis tego wyniku. To pomiar współbieżności na celowo wolnym endpointcie, a nie pomiar samego httpx. Każdy klient z działającym async wyląduje w podobnym miejscu, a dla szybkiego endpointu różnica się kurczy.
Przypadek charsetu, którego nie przewidziałem
Założyłem, że odpowiedź z kłamliwym nagłówkiem — charset=iso-8859-1 przy bajtach UTF-8 — da identyczny bełkot tekstowy w obu bibliotekach. I tak właśnie jest. Obie zwracają Café Ubersetzung â naïve résumé, podczas gdy źródło zawiera Café Ubersetzung — naïve résumé.
Nieprzewidziany przypadek to ten ważny:
| Odpowiedź | requests dekoduje | httpx dekoduje |
|---|---|---|
charset=utf-8, bajty utf-8 | poprawnie | poprawnie |
charset=iso-8859-1, bajty utf-8 | krzaki | krzaki |
| brak charsetu | krzaki | poprawnie |
requests wraca do ISO-8859-1, gdy nagłówek nic nie mówi, a httpx domyślnie używa UTF-8. W fixture bez charsetu oba klienty zwracały więc inny tekst po .text; odbiorcy korzystający z response.content zachowują te same oryginalne bajty.
Pamięć, bo to łatwo zmierzyć
Peak RSS, /usr/bin/time -l, jeden świeży proces na komórkę:
| Komórka | requests | httpx |
|---|---|---|
| Tylko import | 36.0 MiB | 30.6 MiB |
| Import + jedno GET | 35.8 MiB | 40.7 MiB |
To migawki z jednego procesu, a to, że requests po jednym GET ma wynik minimalnie niższy niż przy samym imporcie, pokazuje szum pomiarowy. Nie dają podstaw do wniosków o kierunku różnicy pamięci; potrzebne byłyby wielokrotne próbki i zakresy.
Co to oznacza przy wyborze
Szukasz błędów w istniejącym kodzie na requests? Sprawdź założenia dotyczące przekierowań, handlery łapiące tylko requests.exceptions.Timeout, ale spodziewające się objąć także zawieszenie w trakcie body, oraz konsumentów .text, którzy dostają odpowiedzi bez charsetu.
Mechaniczna migracja do httpx? Zmień przestrzeń wyjątków na httpx.TimeoutException albo węższe klasy faz, zdecyduj, czy włączyć follow_redirects, i ponownie zweryfikuj założenia dekodowania. Istniejący handler w requests i tak nie łapie pokazanego przypadku mid-body; migracja nie tworzy tego konkretnego błędu.
Piszesz nowy kod, który pobiera wiele URL-i? httpx ma sens, gdy potrzebujesz AsyncClient oraz osobnych timeoutów dla connect, read, write i pool. Te fazy mówią, gdzie nastąpiło oczekiwanie — przy zestawianiu połączenia, postępie body odpowiedzi, wysyłaniu żądania albo lokalnym oczekiwaniu na pool — ale nie tłumaczą, dlaczego zdalny host tak się zachował.
Piszesz coś małego i synchronicznego? requests jest w porządku i jest wszędzie. Powód do migracji nie leży w prędkości.
Cokolwiek wybierzesz, korzystaj z obiektu klienta, a nie z funkcji na poziomie modułu. To jedyna zmiana z tej listy, która jest wygraną w obu bibliotekach.
Gdzie pasuje zarządzane API
Wszystko powyżej dotyczy warstwy pobierania, a to akurat jest najłatwiejsza część. Żadna z tych opcji nie renderuje JavaScriptu, nie obsługuje challenge anty-botowego i nie zamienia HTML-a w wiersze, których potrzebujesz.
Nota autora: Thunderbit to nasza zarządzana opcja do renderowania i ekstrakcji URL-in. Nie była testowana w tym harnessie dla klientów HTTP. Traktuj tę kategorię jako rozwiązanie wtedy, gdy problemem jest pobranie strony lub ustrukturyzowana ekstrakcja — a nie semantyka klienta HTTP.
Jeśli pobierasz zwykłe strony i samodzielnie je parsujesz, oba klienty nadal wchodzą w grę. Usługa zarządzana to osobna decyzja build-versus-buy, a nie argument za wyborem jednej z tych bibliotek.
Szerszy przegląd znajdziesz w naszym zestawieniu API do web scrapingu, a po stronie własnego hostingu — w pillarze o open-source scraperach.
Wypróbuj Thunderbit do ekstrakcji danych z sieci
Werdykt
Do nowej warstwy pobierania w Pythonie, która potrzebuje współbieżności async, timeoutów rozdzielonych na fazy i fallbacku do UTF-8, moja domyślna rekomendacja przy tych testach to httpx. requests nadal jest dobrym wyborem dla dojrzałego kodu synchronicznego, gdzie ryzyko migracji przeważa nad tymi korzyściami. Proxy, polityki retry, fingerprinting TLS, streaming, uploady i realistyczna zmienność sieci nie były testowane, więc nie jest to uniwersalny ranking klientów do scrapingu.
Powód, by uważać, to domyślne zachowanie przy przekierowaniach, i jest to prawdziwe zagrożenie właśnie dlatego, że jest to dobre rozwiązanie projektowe. Jasne jest lepsze od domyślnego — aż do momentu, gdy domyślne okazało się nośne w kodzie, który już wysłałeś do produkcji.
Prerejestrowana karta wyników zakończyła się trzema trafnymi przewidywaniami, dwoma błędnymi i jednym niepełnym. Najbardziej użyteczna korekta dotyczyła wyjątku mid-body; resztę decyzji powinno się opierać na zaobserwowanym zachowaniu, a nie na narracji wokół scorecardu.
Wypróbuj Thunderbit do ekstrakcji danych z sieci Get Started Free
FAQ
Czy httpx naprawdę nie śledzi przekierowań?
Nie domyślnie. Serwer zliczył jedno żądanie dla łańcucha z czterema skokami, a odpowiedź wróciła jako 302. Przekaż follow_redirects=True przy wywołaniu albo ustaw to raz na Client. To zachowanie jest udokumentowane i zamierzone; nadal pozostaje najprostszym powodem cichej awarii przy migracji, bo zamiast wyjątku dostajesz pusty parse.
Czy except requests.exceptions.Timeout naprawdę nie wystarczy?
Nie w przypadku serwera, który zawiesza się po wysłaniu nagłówków. Wtedy pojawia się ConnectionError, a to nie jest podklasa Timeout, więc strażnik tego nie złapie — pokazane bezpośrednio, a nie wywnioskowane. Jeśli chcesz łapać oba przypadki, użyj requests.exceptions.RequestException, ale zaakceptuj, że przechwycisz też rzeczy, które timeoutami nie są.
Czy httpx jest szybszy od requests? Nie w praktyczny sposób przy pojedynczym żądaniu — od tego nie jest. Wynik 17,2× z tego testu to pomiar dwudziestu współbieżnych żądań do endpointu z opóźnieniem 0,3 s, czyli pomiar współbieżności. Jeśli Twoje obciążenie jest sekwencyjne, nie spodziewaj się przyspieszenia i wybieraj na podstawie domyślnych ustawień.
Czy potrzebuję dodatku http2?
Tylko jeśli chcesz HTTP/2 — a jeśli ustawisz http2=True bez niego, httpx przy tworzeniu Client rzuci ImportError z komunikatem o instalacji httpx[http2]. Bez cichego downgrade’u. Spodziewałem się cichego przejścia na niższy protokół i sprawdziłem to zamiast zgadywać.
Czego tutaj nie testowano? Zachowania proxy, które ma ogromne znaczenie w scrapingu i wymaga własnego harnessa. Retry — httpx nie ma własnej logiki retry, a requests dostaje ją z urllib3, więc uczciwe porównanie to tak naprawdę porównanie dwóch bibliotek retry. Fingerprinting TLS, czyli oś, na którą faktycznie patrzą systemy anty-botowe, a której żadna z tych bibliotek nie rozwiązuje. Streaming i uploadów plików. I wszystko, co dotyczy jednego komputera, jednej wersji Pythona i localhosta w sześciu z ośmiu prób — liczby opóźnień z serwera testowego mówią o projekcie, a nie o Twojej sieci.


