Escribe el guardián que todos escriben:
try:
r = requests.get(url, timeout=0.5)
except requests.exceptions.Timeout:
retry()
Llévalo a un servidor que envía la línea de estado y las cabeceras al instante, y luego se queda colgado antes del cuerpo. La lectura vence. El guardián no se dispara. La excepción que aparece es ConnectionError, y ConnectionError no es subclase de Timeout.
Ante el mismo bloqueo, httpx lanza ReadTimeout, que sí es un TimeoutException, y ese sí lo atrapa el guardián equivalente.
Me puse a revisar en qué se diferencia httpx de requests en los puntos que suelen romper scrapers, pensando que acabaría escribiendo sobre async. Al final, lo async resultó ser lo menos interesante de toda la lista.
Qué probé y cómo
Ocho pruebas contra un servidor local de laboratorio, porque lo que un cliente dice que hizo no prueba lo que realmente hizo. El servidor cuenta conexiones TCP —se incrementan una vez por cada socket aceptado, antes de analizar ninguna línea de petición— y rutas realmente solicitadas. La reutilización de conexiones y el seguimiento de redirecciones son afirmaciones sobre la red, y la red es donde se verifican.
httpx 0.28.1 con el extra http2, requests 2.34.2, Python 3.14.2, macOS arm64. Todo en un virtualenv nuevo para que uno no herede la traza del otro. Salida en bruto: httpx-probes.json.
Seis predicciones entraron al harness antes de la primera ejecución y siguieron ahí después. Tres acertaron, dos fallaron, una acertó en el caso que tenía en mente pero pasó por alto el caso que importaba. prediction-scorecard.json contiene el desglose.
Los valores por defecto que te cambian bajo los pies

| Comportamiento | requests 2.34.2 | httpx 0.28.1 |
|---|---|---|
| Sigue redirecciones por defecto | sí | no |
| La llamada a nivel de módulo reutiliza conexiones | no | no |
| Bloqueo antes de las cabeceras | ReadTimeout | ReadTimeout |
| Bloqueo a mitad del cuerpo | ConnectionError | ReadTimeout |
| Sin charset declarado | ISO-8859-1 | utf-8 |
| HTTP/2 | no disponible | opcional, funciona |
| Timeouts separados para connect/read/write/pool | no | sí |
Las redirecciones, la reutilización de sockets, los resultados de excepción, la decodificación y la negociación de protocolo se observaron en las pruebas. La forma de la API de timeout y la ausencia de un flag HTTP/2 en requests son observaciones sobre capacidades de la API. httpx-probes.json.
Tres de esas filas cambiarán lo que hace tu código el día que migres, sin avisar.
Redirecciones: desactivadas por defecto, y el servidor lo demuestra
Una cadena de redirección de cuatro saltos que termina en /ok:
| Cliente | Lo que vio el servidor | Estado devuelto |
|---|---|---|
| requests | 5 solicitudes | 200 |
| httpx | 1 solicitud | 302 |
httpx, follow_redirects=True | 5 solicitudes | 200 |
El cinco es cuatro saltos más el destino. Mi predicción decía cuatro, que fue una aritmética que no comprobé; la dirección era la afirmación importante y aquí corrijo el conteo en vez de dejarlo mal en el texto.
Este es el comportamiento documentado de httpx y además tiene sentido de diseño: una redirección es algo que quien llama quizá quiera ver. También es la forma más probable en que una migración se rompa sin lanzar nada. Tu código recibe un 302, response.text llega vacío, el parser no encuentra filas y tus logs dicen 200 OK… salvo que en realidad dicen 302, y nadie estaba mirando el código de estado porque con requests nunca hubo nada que mirar.
El hallazgo del timeout, que es precisamente el que entendí al revés
Predije que httpx nombraría la fase que falló y que requests mezclaría ambas en una sola clase. En realidad es al revés.
Referencia oficial: Documentación de timeouts de Requests.

Referencia oficial: Documentación de timeouts de HTTPX.
| Bloqueo | requests | httpx |
|---|---|---|
| Antes de la línea de estado | ReadTimeout | ReadTimeout |
| A mitad del cuerpo, después de enviar cabeceras | ConnectionError | ReadTimeout |
httpx da el mismo nombre, y además el correcto, a ambos casos. requests los separa —y los separa justo en la frontera contra la que se escriben los reintentos.
La consecuencia no es una inferencia a partir de la jerarquía de clases. Ejecuté el guardián:
| Bloqueo | except requests.exceptions.Timeout | except httpx.TimeoutException |
|---|---|---|
| Antes de la línea de estado | captura | captura |
| A mitad del cuerpo | se escapa como ConnectionError | captura |
timeout-retry-guard.json. requests.exceptions.ConnectionError no es subclase de requests.exceptions.Timeout; httpx.ReadTimeout sí es subclase de httpx.TimeoutException.
El mensaje de excepción de requests dice Read timed out. dentro de un ConnectionError. La librería sabe lo que pasó. Solo que no se lo comunica al sistema de tipos, y el sistema de tipos es lo que consulta tu cláusula except.
El caso medido es específico: llegan las cabeceras y luego el progreso del cuerpo se detiene el tiempo suficiente como para superar el timeout de lectura. Una respuesta que siga entregando fragmentos dentro de la ventana de timeout, incluso un stream intencional, puede comportarse distinto y no se probó aquí.
Pooling de conexiones: la API del cliente lo es todo
Diez GET, cuatro formas, sockets contados del lado del servidor:
| Forma | Sockets abiertos |
|---|---|
httpx.get() × 10 | 10 |
requests.get() × 10 | 10 |
httpx.Client() | 1 |
requests.Session() | 1 |
Idéntico, y vale la pena decirlo porque es la idea más extendida sobre esta pareja: que httpx agrupa conexiones y requests no. Ninguno agrupa a nivel de módulo. Ambos reutilizan conexiones a través del objeto cliente. Si hoy llamas requests.get() en un bucle, pasar a httpx.get() en un bucle no cambia nada del churn de sockets.

HTTP/2 es explícito y requiere el extra
Contra un endpoint público de HTTP/2 registrado en el artefacto:
Referencia oficial: RFC 9113: HTTP/2.
| Cliente | Negociado |
|---|---|
httpx.Client(http2=True) | HTTP/2 |
httpx.Client(http2=False) | HTTP/1.1 |
| requests | HTTP/1.1, no existe bandera |
Necesitas el extra httpx[http2]. Yo asumí que con un simple pip install httpx ya tendrías un cliente que negociaría 1.1 en silencio, y fui a comprobarlo antes de escribirlo:
ImportError: Using http2=True, but the 'h2' package is not installed.
Make sure to install httpx using `pip install httpx[http2]`.
Falla al construir el Client, antes de hacer una sola solicitud, y el mensaje te dice cómo arreglarlo. Esa es la buena versión de este fallo, y yo la tenía al revés (http2-extra-missing.json).
Esta prueba confirma una negociación de protocolo exitosa en ese endpoint. No demuestra una mejora de velocidad para scraping; no se probó ninguna carga HTTP/1.1 equivalente.
Rendimiento secuencial frente a concurrente en el fixture
Veinte solicitudes a un endpoint que duerme 0,3 s:
| Modo | Tiempo total | Sockets |
|---|---|---|
Síncrono, un Client | 6.138 s | 1 |
Asíncrono, un AsyncClient | 0.357 s | 20 |
La ejecución concurrente terminó en 0.357 segundos frente a 6.138 segundos de la ejecución secuencial. También abrió veinte conexiones mientras que el cliente síncrono reutilizó una sola, así que el experimento cambia el modelo de ejecución y la concurrencia efectiva en vez de aislar la velocidad de la librería.
Ese es el marco honesto para el número. Es una medición de concurrencia contra un endpoint deliberadamente lento, no una medición de httpx. Cualquier cliente con una implementación async funcional aterriza en una zona parecida, y con un endpoint rápido la diferencia se reduce mucho.
El caso del charset que no pensé predecir
Predije que una respuesta cuyo encabezado miente —charset=iso-8859-1 sobre bytes utf-8— produciría mojibake idéntico en ambos. Y así es. Los dos devuelven Café Ubersetzung â naïve résumé donde la fuente dice Café Ubersetzung — naïve résumé.
El caso que no predije es el importante:
| Respuesta | requests decodifica | httpx decodifica |
|---|---|---|
charset=utf-8, bytes utf-8 | correcto | correcto |
charset=iso-8859-1, bytes utf-8 | mojibake | mojibake |
| sin charset | mojibake | correcto |
requests vuelve a ISO-8859-1 cuando la cabecera no dice nada, mientras que httpx usa utf-8 por defecto. En el fixture sin charset, por tanto, los clientes produjeron texto decodificado distinto a través de .text; quienes consuman response.content conservan los mismos bytes originales.
Memoria, ya que cuesta poco medirla
RSS máximo, /usr/bin/time -l, un proceso nuevo por celda:
| Celda | requests | httpx |
|---|---|---|
| Solo importar | 36.0 MiB | 30.6 MiB |
| Importar + un GET | 35.8 MiB | 40.7 MiB |
Son instantáneas de un solo proceso, y que el valor de requests con un GET sea ligeramente inferior al de solo importar deja ver ruido de ejecución. No permiten sacar una conclusión direccional sobre memoria; harían falta muestras repetidas y rangos.
Qué significa esto a la hora de elegir
¿Buscas bugs en código existente con requests? Revisa las suposiciones sobre redirecciones, los handlers que solo capturan requests.exceptions.Timeout pero esperan cubrir un bloqueo a mitad del cuerpo, y los consumidores de .text que reciben respuestas sin charset.
¿Vas a migrar mecánicamente a httpx? Cambia los espacios de nombres de excepciones a httpx.TimeoutException o a las clases más específicas por fase, decide si activar follow_redirects, y vuelve a probar las suposiciones de decodificación. El handler de requests que ya tenías se pierde igualmente el caso de mitad del cuerpo; la migración no crea ese bug en particular.
¿Vas a escribir algo nuevo que descargue muchas URLs? httpx es candidato cuando necesitas AsyncClient y timeouts separados para connect, read, write y pool. Esas fases te dicen dónde ocurrió la espera —establecimiento de conexión, progreso del cuerpo de respuesta, subida de la petición o adquisición del pool local— no por qué se comportó así el host remoto.
¿Vas a escribir algo pequeño y síncrono? requests está bien y está en todas partes. La razón para cambiar no es la velocidad.
Elijas lo que elijas, usa el objeto cliente en lugar de la función a nivel de módulo. Ese es el único cambio de esta lista que es una mejora clara en ambas librerías.
Dónde encaja una API gestionada
Todo lo anterior es la capa de fetching, y la capa de fetching es la parte fácil. Nada de eso renderiza JavaScript, nada maneja un desafío anti-bot y nada convierte HTML en las filas que querías.
Nota del autor: Thunderbit es nuestra opción gestionada para renderizar y extraer contenido a partir de una URL. No se probó en este harness de clientes HTTP. Considera esa categoría solo cuando el problema que quieres quitar sea la adquisición de páginas o la extracción estructurada, no la semántica del cliente HTTP.
Si solo estás recuperando páginas normales y las analizas tú mismo, cualquiera de los dos clientes sigue siendo una opción. Un servicio gestionado es una decisión aparte de construir o comprar, no una prueba para elegir entre estas librerías.
Para una visión más amplia, nuestro resumen de APIs de web scraping cubre las opciones alojadas y el pilar de scrapers open source las autoalojadas.
Prueba Thunderbit para extraer datos web
Veredicto
Para una nueva capa de fetch en Python que necesite concurrencia async, timeouts específicos por fase y un fallback a UTF-8, httpx es mi opción por defecto dentro de las condiciones probadas aquí. requests sigue siendo válido para código síncrono maduro cuando el riesgo de migración pesa más que esas ventajas. No se probaron proxies, políticas de reintento, fingerprinting TLS, streaming, subidas ni variación realista de red, así que esto no es un ranking universal de clientes de scraping.
La razón para ir con cuidado es el valor por defecto de las redirecciones, y es un riesgo real precisamente porque es una buena decisión de diseño. Lo explícito es mejor que lo implícito… hasta que lo implícito era una pieza crítica del código que ya habías desplegado.
La hoja de predicciones preregistrada terminó con tres aciertos, dos errores y una predicción incompleta. La corrección útil fue la clase de excepción a mitad del cuerpo; el resto de la decisión debería salir del comportamiento observado y no del relato de la scorecard.
Prueba Thunderbit para extraer datos web Get Started Free
Preguntas frecuentes
¿De verdad httpx no sigue redirecciones?
No por defecto. El servidor contó una solicitud para una cadena de cuatro saltos, y la respuesta volvió como 302. Pasa follow_redirects=True en cada llamada, o configúralo una vez en el Client. Está documentado y es intencional; aun así, sigue siendo lo más probable que se rompa en silencio durante una migración, porque el fallo es un parseo vacío y no una excepción.
¿except requests.exceptions.Timeout realmente no basta?
No para un servidor que se queda colgado después de enviar cabeceras. Ese caso lanza ConnectionError, que no es subclase de Timeout, así que el guardia no lo atrapa; esto se demostró directamente, no se infirió. Captura requests.exceptions.RequestException si quieres cubrir ambos casos, y acepta que también atraparás cosas que no son timeouts.
¿httpx es realmente más rápido que requests? No de forma significativa para una sola petición; no está pensado para eso. El 17,2× de esta prueba mide veinte solicitudes concurrentes contra un endpoint de 0,3 s, así que mide concurrencia. Si tu carga es secuencial, no esperes una mejora de velocidad y elige por los valores por defecto.
¿Necesito el extra http2?
Solo si quieres HTTP/2; y si pones http2=True sin él, httpx lanza ImportError al construir el Client, con un mensaje que te dice que instales httpx[http2]. No hay degradación silenciosa que te preocupe. Yo esperaba una y comprobé antes de escribirlo.
¿Qué no se probó aquí? El comportamiento de proxies, que es muy importante para scraping y necesita su propio harness. Los reintentos: httpx no trae lógica de retry y requests la obtiene de urllib3, así que una comparación justa sería en realidad una comparación de dos librerías de reintentos. El fingerprinting TLS, que es el eje que realmente observan los sistemas anti-bot y que ninguna de las dos librerías resuelve. Streaming y cargas de archivos. Y todo esto se ejecutó en una sola máquina, una sola versión de Python y localhost en seis de las ocho pruebas: las cifras de latencia de un servidor de laboratorio miden el diseño, no tu red.


