Scrivi il guardiano che scrivono tutti:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
Mandalo verso un server che spedisce subito status line e header e poi si pianta prima del body. La lettura va in timeout. Il guardiano non parte. L’eccezione che esce è ConnectionError, e ConnectionError non è una sottoclasse di Timeout.
Con lo stesso blocco, usando httpx, salta fuori ReadTimeout, che è un TimeoutException, e il guardiano corrispondente lo prende al volo.
Sono andato a vedere in cosa httpx differisce da requests nei punti che mandano in crisi gli scraper, pensando di scrivere dell’async. Alla fine, l’async è risultato l’aspetto meno interessante di tutta la lista.
Cosa ho testato e come
Otto verifiche su un server fixture locale, perché il fatto che un client racconti cosa ha fatto non prova affatto quello che ha davvero fatto. Il server conta le connessioni TCP — incrementate una sola volta per ogni socket accettato, prima ancora di parsare la request line — e i path effettivamente richiesti. Il riuso delle connessioni e il follow dei redirect sono entrambi affermazioni sul traffico reale, e il traffico reale è il punto in cui si verificano davvero.
httpx 0.28.1 con l’extra http2, requests 2.34.2, Python 3.14.2, macOS arm64. Entrambi in un virtualenv nuovo di zecca, così nessuno si porta dietro tracce dell’altro. Output grezzo: httpx-probes.json.
Sei ipotesi sono entrate nel harness prima della prima esecuzione e ci sono rimaste anche dopo. Tre si sono rivelate giuste, due sbagliate, una giusta sul caso che avevo immaginato e sbagliata sul caso che contava davvero. prediction-scorecard.json contiene il conto.
I default che ti cambiano sotto i piedi

| Comportamento | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| Segue i redirect di default | sì | no |
| La chiamata a livello di modulo riusa le connessioni | no | no |
| Stallo prima degli header | ReadTimeout | ReadTimeout |
| Stallo a metà body | ConnectionError | ReadTimeout |
| Nessun charset dichiarato | ISO-8859-1 | utf-8 |
| HTTP/2 | non disponibile | opzionale, funziona |
| Timeout separati per connect/read/write/pool | no | sì |
Redirect, riuso dei socket, esiti delle eccezioni, decoding e negoziazione del protocollo sono stati osservati nelle verifiche. La forma dell’API dei timeout e l’assenza di un flag HTTP/2 in requests sono osservazioni sulle capacità dell’API. httpx-probes.json.
Tre di queste righe cambieranno in silenzio il comportamento del tuo codice il giorno in cui farai la migrazione.
Redirect: disattivati di default, e il server lo dimostra
Una catena di redirect in quattro salti che finisce su /ok:
| Client | Il server ha visto | Status restituito |
|---|---|---|
| requests | 5 request | 200 |
| httpx | 1 request | 302 |
httpx, follow_redirects=True | 5 request | 200 |
Il cinque è dato da quattro salti più la destinazione finale. La mia previsione diceva quattro: era un conto che non ho verificato; la direzione era quella giusta e il conteggio viene corretto qui, invece di essere lasciato sottointeso nel testo.
Questo è il comportamento documentato di httpx, ed è una scelta progettuale difendibile — un redirect è qualcosa di cui chi chiama potrebbe voler essere consapevole. È anche il modo più probabile in cui una migrazione si rompe senza generare errori. Il tuo codice riceve un 302, response.text è vuoto, il parser non trova righe e i log dicono 200 OK… anzi no, dicono 302, e nessuno stava controllando lo status code perché con requests non c’era mai stato nulla da controllare.
Il risultato sui timeout, quello che avevo interpretato al contrario
Avevo previsto che httpx avrebbe nominato la fase fallita e che requests avrebbe fuso tutto in un’unica classe. È l’opposto.
Riferimento ufficiale: documentazione sui timeout di Requests.

Riferimento ufficiale: documentazione sui timeout di HTTPX.
| Stallo | requests | httpx |
|---|---|---|
| Prima della status line | ReadTimeout | ReadTimeout |
| A metà body, dopo l’invio degli header | ConnectionError | ReadTimeout |
httpx assegna lo stesso nome corretto a entrambi i casi. requests li distingue — e li separa proprio lungo il confine su cui è costruito il codice di retry.
La conseguenza non è un’inferenza ricavata dalla gerarchia delle classi. Ho eseguito davvero il guardiano:
| Stallo | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| Prima della status line | intercetta | intercetta |
| A metà body | sfugge come ConnectionError | intercetta |
timeout-retry-guard.json. requests.exceptions.ConnectionError non è una sottoclasse di requests.exceptions.Timeout; httpx.ReadTimeout è una sottoclasse di httpx.TimeoutException.
Il messaggio dell’eccezione di requests dice Read timed out. dentro un ConnectionError. La libreria sa cosa è successo. Semplicemente non lo comunica al type system, e il type system è quello che il tuo except consulta.
Il caso misurato è specifico: gli header arrivano, poi il body smette di avanzare abbastanza a lungo da superare il timeout di lettura. Una risposta che continua a mandare chunk entro la finestra del timeout, incluso uno stream intenzionale, può comportarsi in modo diverso e qui non è stata testata.
Pooling delle connessioni: tutta la differenza sta nell’API del client
Dieci GET, quattro modi diversi, socket contati lato server:
| Modalità | Socket aperti |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
Identico, e vale la pena dirlo perché è l’idea più diffusa su questa coppia — che httpx fa pooling e requests no. Nessuno dei due fa pooling a livello di modulo. Entrambi lo fanno attraverso l’oggetto client. Se oggi stai chiamando requests.get() dentro un ciclo, passare a httpx.get() dentro un ciclo non cambia nulla rispetto al churn dei socket.

HTTP/2 è esplicito e richiede l’extra
Contro un endpoint pubblico HTTP/2 registrato nell’artifact:
Riferimento ufficiale: RFC 9113: HTTP/2.
| Client | Protocollo negoziato |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1, non esiste un flag |
Serve l’extra httpx[http2]. Avevo supposto che un semplice pip install httpx ti lasciasse un client che negozia in silenzio HTTP/1.1, e sono andato a controllare prima di scriverlo:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
L’errore viene sollevato alla costruzione del Client, prima ancora di fare una richiesta, e il messaggio indica anche la correzione. Questa è la versione buona del fallimento, e io l’avevo interpretata al contrario (http2-extra-missing.json).
Questa verifica dimostra una negoziazione riuscita del protocollo su quell’endpoint. Non dimostra alcun vantaggio di velocità nello scraping; non è stato testato un carico HTTP/1.1 equivalente.
Throughput sequenziale contro concorrente sul fixture
Venti richieste verso un endpoint che dorme per 0,3 s:
| Modalità | Tempo totale | Socket |
|---|---|---|
Sincrona, un solo Client | 6.138 s | 1 |
Async, un solo AsyncClient | 0.357 s | 20 |
L’esecuzione concorrente si è chiusa in 0,357 secondi contro i 6,138 della sequenziale. Ha anche aperto venti connessioni mentre il client sincrono ne ha riutilizzata una sola, quindi l’esperimento cambia il modello di esecuzione e la concorrenza effettiva, invece di isolare la velocità della libreria.
Questo è il modo corretto di leggere quel numero. È una misura della concorrenza contro un endpoint volutamente lento, non della velocità di httpx. Qualsiasi client con una buona storia async finirà nello stesso ordine di grandezza, e contro un endpoint veloce il divario si riduce parecchio.
Il caso del charset che non avevo pensato di prevedere
Avevo previsto che una risposta il cui header mente — charset=iso-8859-1 su byte utf-8 — avrebbe prodotto lo stesso mojibake in entrambi. Ed è così. Entrambi restituiscono Café Ubersetzung â naïve résumé, mentre la sorgente dice Café Ubersetzung — naïve résumé.
Il caso che non avevo previsto è quello che conta:
| Risposta | requests decodifica | httpx decodifica |
|---|---|---|
charset=utf-8, byte utf-8 | corretto | corretto |
charset=iso-8859-1, byte utf-8 | mojibake | mojibake |
| nessun charset | mojibake | corretto |
requests torna a ISO-8859-1 quando l’header non specifica nulla, mentre httpx usa utf-8 come default. Nel fixture senza charset, i due client hanno quindi prodotto testo decodificato diverso tramite .text; chi usa response.content conserva invece gli stessi byte originali.
Memoria, visto che è economico misurarla
RSS di picco, /usr/bin/time -l, un processo nuovo per ogni cella:
| Cella | requests | httpx |
|---|---|---|
| Solo import | 36.0 MiB | 30.6 MiB |
| Import + un GET | 35.8 MiB | 40.7 MiB |
Sono snapshot di un singolo processo, e il valore di requests con un GET leggermente sotto quello del solo import mostra il rumore della misura. Non consentono nessuna conclusione direzionale sulla memoria; servirebbero più campioni e intervalli.
Cosa significa quando devi scegliere
Stai cercando bug nel codice esistente su requests? Controlla le assunzioni sui redirect, i gestori che catturano solo requests.exceptions.Timeout ma credono di coprire anche uno stallo a metà body, e i consumer di .text che ricevono risposte senza charset.
Stai migrando in modo meccanico verso httpx? Cambia i namespace delle eccezioni in httpx.TimeoutException o nelle classi di fase più specifiche, decidi se attivare follow_redirects e ritesta le assunzioni sul decoding. Il gestore requests già oggi si perde il caso dimostrato di stallo a metà body; la migrazione non introduce quel bug specifico.
Stai scrivendo qualcosa di nuovo che deve scaricare molti URL? httpx è un buon candidato quando ti servono AsyncClient e timeout separati per connect, read, write e pool. Quelle fasi ti dicono dove è avvenuta l’attesa — connessione, avanzamento del body della risposta, upload della request o acquisizione dal pool locale — non perché un host remoto si sia comportato così.
Stai scrivendo qualcosa di piccolo e sincrono? requests va benissimo ed è ovunque. Il motivo per spostarsi non è la velocità.
Qualunque sia la scelta, usa l’oggetto client invece della funzione a livello di modulo. È l’unico cambiamento in questa lista che è un vantaggio netto per entrambe le librerie.
Dove si colloca un’API gestita
Tutto quello che precede riguarda il livello di fetch, e il livello di fetch è la parte facile. Nessuno di questi strumenti renderizza JavaScript, nessuno gestisce una challenge anti-bot, e nessuno trasforma l’HTML nelle righe che volevi.
Nota dell’autore: Thunderbit è la nostra opzione gestita per rendering ed estrazione a partire dall’URL. Non è stata testata in questo harness di client HTTP. Considera questa categoria solo quando il problema che vuoi eliminare è l’acquisizione della pagina o l’estrazione strutturata — non la semantica del client HTTP.
Se stai recuperando pagine normali e le analizzi tu stesso, entrambi i client restano opzioni valide. Un servizio gestito è una decisione separata di build versus buy, non una prova per scegliere tra queste librerie.
Per il quadro più ampio, la nostra rassegna delle web scraping API copre le opzioni hosted e il pilastro sugli scraper open source quelle self-hosted.
Prova Thunderbit per l’estrazione di dati dal web
Verdetto
Per un nuovo layer di fetch in Python che richieda concorrenza async, timeout specifici per fase e un fallback a UTF-8, httpx è la mia scelta predefinita entro i vincoli testati qui. Requests resta una scelta valida per codice sincrono maturo, quando il rischio di migrazione pesa più di quei vantaggi. Proxy, policy di retry, fingerprint TLS, streaming, upload e variazioni realistiche di rete non sono stati testati, quindi questo non è un ranking universale dei client per lo scraping.
Il motivo per cui bisogna stare attenti è il default sui redirect, ed è un rischio reale proprio perché è una buona decisione progettuale. Esplicito è meglio di implicito, finché l’implicito non era parte portante di codice che hai già rilasciato.
La scorecard preregistrata si è chiusa con tre previsioni corrette, due sbagliate e una incompleta. La correzione utile è stata la classe di eccezione a metà body; il resto della decisione dovrebbe venire dal comportamento osservato, non dal racconto della scorecard.
Prova Thunderbit per l’estrazione di dati dal web Get Started Free
FAQ
È vero che httpx non segue davvero i redirect?
Non di default, no. Il server ha contato una sola richiesta per una catena di quattro salti, e la risposta è tornata come 302. Passa follow_redirects=True a ogni chiamata, oppure impostalo una volta sul Client. È un comportamento documentato e voluto; resta comunque il punto più probabile in cui una migrazione si rompe in silenzio, perché il fallimento è un parse vuoto e non un’eccezione.
Basta davvero except requests.exceptions.Timeout?
Non per un server che si blocca dopo aver inviato gli header. In quel caso viene sollevato ConnectionError, che non è una sottoclasse di Timeout, quindi il guardiano non lo intercetta — dimostrato direttamente, non dedotto. Cattura requests.exceptions.RequestException se vuoi coprire entrambi i casi, accettando però di intercettare anche cose che non sono timeout.
httpx è davvero più veloce di requests? Non in modo significativo, quando si parla di una richiesta per volta — non è per questo che è pensato. Il 17,2× di questo test misura venti richieste concorrenti contro un endpoint da 0,3 s, quindi misura la concorrenza. Se il tuo carico è sequenziale, aspettati nessun guadagno di velocità e scegli in base ai default.
Mi serve l’extra http2?
Solo se vuoi HTTP/2 — e se imposti http2=True senza averlo, httpx solleva ImportError quando costruisci il Client, con un messaggio che ti dice di installare httpx[http2]. Nessun downgrade silenzioso di cui preoccuparsi. Io mi aspettavo che avvenisse e invece ho controllato prima di scriverlo.
Cosa non è stato testato qui? Il comportamento con i proxy, che conta tantissimo nello scraping e richiede un harness dedicato. I retry — httpx non include logica di retry, mentre requests la eredita da urllib3, quindi un confronto corretto è davvero un confronto tra due librerie di retry. Il fingerprinting TLS, cioè l’asse su cui i sistemi anti-bot guardano davvero e che nessuna delle due librerie affronta. Streaming e upload di file. E inoltre tutto questo è stato eseguito su una sola macchina, una sola versione di Python, e su localhost per sei delle otto verifiche — i numeri di latenza di un fixture server misurano il design, non la tua rete.


