Escreva a proteção que todo mundo escreve:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
Aponta isso para um servidor que manda na hora a linha de status e os headers e depois trava antes do corpo. A leitura expira. A proteção não dispara. A exceção que aparece é ConnectionError, e ConnectionError não é subclass de Timeout.
O mesmo travamento com httpx gera ReadTimeout, que é um TimeoutException, e aí a proteção equivalente captura.
Fui investigar em que pontos o httpx se diferencia do requests de um jeito que quebra scrapers, imaginando que escreveria sobre async. No fim, async foi a parte menos interessante da lista.
O que testei e como
Oito verificações em um servidor de testes local, porque a resposta de um cliente sobre o que fez não é prova do que de fato aconteceu. O servidor conta conexões TCP — incrementadas uma vez por socket aceito, antes de qualquer linha de requisição ser interpretada — e caminhos realmente acessados. Reaproveitamento de conexão e acompanhamento de redirect são, os dois, afirmações sobre a rede, e é na rede que elas são validadas.
httpx 0.28.1 com o extra http2, requests 2.34.2, Python 3.14.2, macOS arm64. Os dois rodando em um virtualenv novo, para que nenhum herdasse vestígios do outro. Saída bruta: httpx-probes.json.
Seis previsões foram colocadas no harness antes da primeira execução e ficaram lá depois. Três acertaram, duas estavam erradas, e uma estava certa sobre o caso que eu tinha em mente, mas errou o caso que importava. prediction-scorecard.json traz a conta.
Os padrões que mudam sem você perceber

| Comportamento | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| Segue redirects por padrão | sim | não |
| Chamada no nível do módulo reaproveita conexões | não | não |
| Travamento antes dos headers | ReadTimeout | ReadTimeout |
| Travamento no meio do corpo | ConnectionError | ReadTimeout |
| Sem charset declarado | ISO-8859-1 | utf-8 |
| HTTP/2 | não disponível | opcional, funciona |
| Timeouts separados para connect/read/write/pool | não | sim |
Redirects, reaproveitamento de socket, resultados de exceção, decodificação e negociação de protocolo foram observados em testes. A forma da API de timeout e a ausência de uma flag HTTP/2 no requests são observações sobre capacidade da API. httpx-probes.json.
Três dessas linhas vão mudar o comportamento do teu código no dia da migração, sem aviso.
Redirects: desligados por padrão, e o servidor prova isso
Uma cadeia de redirect com quatro saltos até /ok:
| Cliente | O servidor viu | Status retornado |
|---|---|---|
| requests | 5 requests | 200 |
| httpx | 1 request | 302 |
httpx, follow_redirects=True | 5 requests | 200 |
O cinco é quatro saltos mais o destino. Minha previsão dizia quatro, que foi uma conta que eu não conferi; a direção era a afirmação principal, e o número foi corrigido aqui em vez de ser escondido no texto.
Esse é um comportamento documentado do httpx e faz sentido do ponto de vista de design — um redirect é algo que o chamador pode querer saber. Ainda assim, é a forma mais provável de uma migração quebrar sem levantar exceção nenhuma. Teu código recebe um 302, response.text vem vazio, o parser não encontra linhas, e teus logs dizem 200 OK… exceto que eles dizem 302, e ninguém estava olhando o status code porque com requests nunca houve necessidade de olhar.
O achado sobre timeout, que foi justamente o que eu inverti
Eu previ que o httpx daria nome à fase que falhou e que o requests misturaria tudo em uma classe só. É o contrário.
Referência oficial: Documentação de timeout do Requests.

Referência oficial: Documentação de timeout do HTTPX.
| Travamento | requests | httpx |
|---|---|---|
| Antes da linha de status | ReadTimeout | ReadTimeout |
| No meio do corpo, depois de enviar headers | ConnectionError | ReadTimeout |
O httpx atribui o mesmo nome correto aos dois casos. O requests separa os casos — e faz essa divisão justamente na fronteira que o código de retry usa.
A consequência não vem de uma inferência sobre a hierarquia de classes. Eu rodei a proteção:
| Travamento | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| Antes da linha de status | captura | captura |
| No meio do corpo | escapa como ConnectionError | captura |
timeout-retry-guard.json. requests.exceptions.ConnectionError não é subclass de requests.exceptions.Timeout; httpx.ReadTimeout é subclass de httpx.TimeoutException.
A mensagem da exceção no requests diz Read timed out. dentro de um ConnectionError. A biblioteca sabe o que aconteceu. Ela só não passa isso para o sistema de tipos, e é o sistema de tipos que o teu except consulta.
O caso medido é específico: os headers chegam, e então o progresso do corpo para tempo suficiente para exceder o timeout de leitura. Uma resposta que continua entregando chunks dentro da janela do timeout, incluindo um stream intencional, pode se comportar de forma diferente e não foi testada aqui.
Pool de conexões: a API do cliente é toda a diferença
Dez GETs, quatro formas, sockets contados do lado do servidor:
| Como | Sockets abertos |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
Igual, e vale registrar isso porque é a crença popular mais comum sobre essa dupla — que o httpx faz pool e o requests não. Nenhum deles faz pool no nível do módulo. Ambos reaproveitam conexões por meio do objeto cliente. Se hoje tu chamas requests.get() dentro de um loop, trocar por httpx.get() num loop não muda nada no churn de sockets.

HTTP/2 é explícito e exige o extra
Contra um endpoint público com HTTP/2 registrado no artefato:
Referência oficial: RFC 9113: HTTP/2.
| Cliente | Negociado |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1, não existe flag |
Tu precisa do extra httpx[http2]. Eu supus que um pip install httpx simples deixaria você com um cliente que negociaria HTTP/1.1 silenciosamente, e fui conferir antes de escrever isso:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
Ele lança no momento de construir o Client, antes de qualquer requisição, e a mensagem já aponta a correção. Essa é a versão boa dessa falha, e eu tinha imaginado o contrário (http2-extra-missing.json).
Essa verificação comprova a negociação bem-sucedida do protocolo nesse endpoint. Ela não comprova ganho de velocidade em scraping; nenhum workload correspondente em HTTP/1.1 foi testado.
Throughput sequencial versus concorrente no fixture
Vinte requisições para um endpoint que dorme 0,3 s:
| Modo | Tempo total | Sockets |
|---|---|---|
Sync, um Client | 6.138 s | 1 |
Async, um AsyncClient | 0.357 s | 20 |
A execução concorrente terminou em 0,357 segundos, contra 6,138 segundos na execução sequencial. Ela também abriu vinte conexões enquanto o cliente síncrono reutilizou uma, então o experimento muda o modelo de execução e a concorrência efetiva, em vez de isolar a velocidade da biblioteca.
Essa é a leitura honesta do número. É uma medição de concorrência contra um endpoint propositalmente lento, não uma medição do httpx. Qualquer cliente com um async funcional cai na mesma faixa, e contra um endpoint rápido a diferença desaparece.
O caso de charset que eu não pensei em prever
Eu previ que uma resposta cujo header mente — charset=iso-8859-1 em bytes utf-8 — produziria o mesmo mojibake em ambos. Produz. Ambos retornam Café Ubersetzung â naïve résumé quando o original diz Café Ubersetzung — naïve résumé.
O caso que eu não previ é o que realmente importa:
| Resposta | requests decodifica | httpx decodifica |
|---|---|---|
charset=utf-8, bytes utf-8 | correto | correto |
charset=iso-8859-1, bytes utf-8 | mojibake | mojibake |
| sem charset algum | mojibake | correto |
O requests cai para ISO-8859-1 quando o header não informa nada, enquanto o httpx usa utf-8 por padrão. No fixture sem charset, os clientes produziram textos decodificados diferentes via .text; consumidores que usam response.content mantêm os mesmos bytes originais.
Memória, já que é barato medir
RSS máximo, /usr/bin/time -l, um processo novo por célula:
| Célula | requests | httpx |
|---|---|---|
| Só importação | 36.0 MiB | 30.6 MiB |
| Importação + um GET | 35.8 MiB | 40.7 MiB |
Esses são snapshots de um único processo, e o valor do requests com um GET ficando ligeiramente abaixo do valor só de importação expõe o ruído da execução. Eles não sustentam conclusão direcional sobre memória; seriam necessárias amostras repetidas e intervalos.
O que isso significa na hora de escolher
Encontrando bugs em código existente com requests? Revise premissas sobre redirects, handlers que capturam só requests.exceptions.Timeout mas esperam cobrir um travamento no meio do corpo, e consumidores de .text recebendo respostas sem charset.
Migrando mecanicamente para httpx? Troque os namespaces de exceção para httpx.TimeoutException ou para as classes mais específicas por fase, decida se vai habilitar follow_redirects, e teste de novo as premissas de decodificação. O handler atual do requests já deixa passar o caso de meio do corpo demonstrado; a migração não cria esse bug específico.
Escrevendo algo novo que busca muitos URLs? O httpx é candidato quando tu precisa de AsyncClient e de timeouts separados para connect, read, write e pool. Essas fases dizem onde a espera aconteceu — estabelecimento da conexão, progresso do corpo da resposta, upload da requisição ou aquisição do pool local —, não por que um host remoto se comportou daquele jeito.
Escrevendo algo pequeno e síncrono? requests está ótimo e está em todo lugar. O motivo para mudar não é velocidade.
Seja qual for a tua escolha, use o objeto cliente em vez da função no nível do módulo. Essa é a única mudança nesta lista que é ganho direto nas duas bibliotecas.
Onde uma API gerenciada entra nisso
Tudo acima é a camada de fetch, e a camada de fetch é a parte fácil. Nada disso renderiza JavaScript, nada disso lida com challenge anti-bot, e nada disso transforma HTML nas linhas que tu queria.
Nota do autor: Thunderbit é nossa opção gerenciada para renderização e extração a partir de uma URL. Ela não foi testada neste harness de cliente HTTP. Considere essa categoria só quando o problema que tu quer remover for aquisição da página ou extração estruturada — e não a semântica do cliente HTTP.
Se tu está buscando páginas comuns e fazendo o parsing por conta própria, ambos os clientes continuam valendo. Um serviço gerenciado é uma decisão separada de build versus buy, não um argumento para escolher entre essas bibliotecas.
Para o panorama mais amplo, nosso resumo das melhores APIs de web scraping cobre as opções hospedadas, e o pilar de scrapers open source cobre as auto-hospedadas.
Experimente Thunderbit para Extração de Dados da Web
Veredito
Para uma nova camada de fetch em Python que precise de concorrência async, timeouts específicos por fase e fallback para UTF-8, httpx é minha escolha padrão dentro das restrições testadas aqui. requests continua sendo uma opção viável para código síncrono maduro, quando o risco de migração pesa mais do que esses benefícios. Proxies, políticas de retry, fingerprint TLS, streaming, uploads e variação real de rede não foram testados, então isso não é um ranking universal de clientes de scraping.
O motivo para ter cuidado é o padrão de redirects, e ele é um risco real justamente por ser uma boa decisão de design. Explícito é melhor que implícito — até o dia em que a coisa implícita era parte estrutural do código que tu já publicou.
A scorecard pré-registrada terminou com três previsões corretas, duas incorretas e uma previsão incompleta. A correção útil foi a classe de exceção no meio do corpo; o resto da decisão deve vir do comportamento observado, e não da narrativa da scorecard.
Experimente Thunderbit para Extração de Dados da Web Get Started Free
Perguntas frequentes
O httpx realmente não segue redirects?
Não por padrão. O servidor contou uma requisição para uma cadeia de quatro saltos, e a resposta voltou como 302. Passe follow_redirects=True em cada chamada, ou configure isso uma vez no Client. Isso é documentado e intencional; ainda assim, é a coisa com maior chance de quebrar silenciosamente numa migração, porque a falha é um parse vazio, não uma exceção.
except requests.exceptions.Timeout realmente não é suficiente?
Não para um servidor que trava depois de enviar headers. Nesse caso, a exceção é ConnectionError, que não é subclass de Timeout, então a proteção não pega — demonstrado diretamente, não inferido. Capture requests.exceptions.RequestException se quiser cobrir ambos, aceitando que também estará capturando coisas que não são timeouts.
O httpx é mais rápido que o requests? Não de forma relevante, quando se faz uma requisição por vez — não é para isso que ele serve. O 17,2× deste teste é uma medição de vinte requisições concorrentes contra um endpoint de 0,3 s, ou seja, uma medição de concorrência. Se teu workload é sequencial, espere nenhum ganho de velocidade e escolha com base nos padrões.
Preciso do extra http2?
Só se tu quiser HTTP/2 — e, se definir http2=True sem ele, o httpx lança ImportError ao construir o Client, com uma mensagem dizendo para instalar httpx[http2]. Nada de downgrade silencioso para se preocupar. Eu esperava um e fui conferir em vez de escrever no papel.
O que não foi testado aqui? Comportamento de proxy, que importa muito para scraping e precisa de um harness próprio. Retries — o httpx não traz lógica de retry e o requests a obtém do urllib3, então uma comparação justa é, na prática, uma comparação entre duas bibliotecas de retry. Fingerprinting TLS, que é o eixo que sistemas anti-bot realmente observam e que nenhuma das duas bibliotecas resolve. Streaming e uploads de arquivos. E tudo aqui foi feito em uma máquina, uma versão de Python e localhost em seis das oito verificações — números de latência de um servidor fixture medem o design, não a tua rede.


