#Qué viaja en una petición HTTP
Una petición HTTP incluye método, destino, cabeceras y, opcionalmente, cuerpo. GET /estado pide una representación; una respuesta contiene un código de estado, cabeceras y bytes. Que el servidor responda no significa que la operación haya tenido éxito: un 503 es una respuesta HTTP válida que comunica indisponibilidad.
Para enviar esa petición hay trabajo previo: resolver el nombre, abrir una conexión TCP y, con HTTPS, negociar TLS. Después se envía la petición y se recibe la respuesta. Repetir todo por cada consulta cuesta tiempo y recursos. Un cliente duradero puede reutilizar conexiones compatibles.
#Por qué usamos HTTPX
HTTPX es una biblioteca de cliente HTTP, no un framework para construir servidores. La elegimos porque presenta de forma explícita un cliente síncrono y otro asíncrono, límites de conexión, timeouts por fase y lectura progresiva del cuerpo. Eso permite aprender el mecanismo sin introducir una arquitectura web completa.
aiohttp también ofrece un cliente asíncrono y puede ser una opción adecuada si tu aplicación ya lo utiliza. No necesitamos aprender dos bibliotecas a la vez. Las decisiones de propiedad, plazos y capacidad son transferibles; los nombres concretos de sus opciones no siempre lo son.
python -m pip install httpx==0.28.1
Usamos una versión explícita para reproducir los ejemplos. Fijar una versión no sustituye actualizar dependencias y revisar cambios cuando mantengas el proyecto.
#Una primera consulta en un entorno controlado
Puedes arrancar un servidor local de la biblioteca estándar, desde un directorio temporal que no contenga documentos privados:
mkdir -p /tmp/async-http-demo
python -m http.server 8766 --bind 127.0.0.1 --directory /tmp/async-http-demo
Deja esa terminal abierta y ejecuta este script en otra. El servidor únicamente sirve para esta práctica; el mini proyecto tendrá otro servidor controlado con respuestas de error y lentitud. 127.0.0.1 es la interfaz local de tu máquina.
import asyncio
import httpx
async def main():
async with httpx.AsyncClient(trust_env=False) as cliente:
respuesta = await cliente.get("http://127.0.0.1:8766/")
respuesta.raise_for_status()
print(respuesta.status_code)
print(respuesta.headers["content-type"])
print("Bytes recibidos:", len(respuesta.content))
asyncio.run(main())
AsyncClient administra conexiones. async with establece cuándo cerrarlas. get espera red; raise_for_status comprueba el estado ya recibido y no lleva await. response.content son bytes; response.text interpreta bytes como texto y response.json() intenta interpretar JSON. Esa transformación es trabajo local y puede fallar aunque HTTP haya devuelto 200.
trust_env=False evita que las variables de proxy del equipo cambien esta prueba local. En una aplicación corporativa quizá necesites configurar un proxy deliberadamente. No desactives la verificación TLS para «arreglar» una conexión HTTPS: configura las autoridades de confianza necesarias.
#Un cliente compartido, varias consultas
El pool de conexiones conserva conexiones reutilizables y controla cuántas pueden estar abiertas. Comparte un cliente dentro del ciclo de vida del lote o servicio; crear uno por URL elimina buena parte de la reutilización. Compártelo entre tareas del mismo entorno asíncrono, no entre bucles que se crean y destruyen por separado.
# Fragmento dentro de main: dos consultas comparten el mismo cliente.
limites = httpx.Limits(max_connections=4, max_keepalive_connections=4)
async with httpx.AsyncClient(limits=limites, trust_env=False) as cliente:
async with asyncio.TaskGroup() as grupo:
primera = grupo.create_task(cliente.get("http://127.0.0.1:8766/"))
segunda = grupo.create_task(cliente.get("http://127.0.0.1:8766/no-existe"))
print(primera.result().status_code, segunda.result().status_code)
Este servidor devuelve 200 y 404. get no convierte automáticamente un estado HTTP de error en excepción. Eso permite inspeccionar el cuerpo o clasificar estados; llama a raise_for_status cuando tu contrato exija éxito. Si un estado inesperado debe convertirse en resultado individual, hazlo dentro de la tarea correspondiente.
HTTP/1.1 suele dedicar una conexión a cada respuesta en curso. HTTP/2 puede multiplexar varias respuestas sobre una conexión; por eso «cuatro conexiones» no es una definición universal de «cuatro operaciones». El número de workers seguirá controlando las operaciones de nuestro proyecto.
#Cuatro timeouts de transporte
| Opción | Qué limita | Qué te revela el fallo |
|---|---|---|
connect |
Espera al establecer conexión | Destino, red o apertura demasiado lenta |
read |
Espera de un siguiente bloque recibido | El flujo deja de entregar datos a tiempo |
write |
Espera al enviar un bloque | El receptor o la red no aceptan datos a tiempo |
pool |
Espera para obtener conexión del pool | Hay más demanda local que conexiones disponibles |
timeout = httpx.Timeout(connect=0.5, read=1.0, write=1.0, pool=0.2)
No sumes estos valores para deducir una duración máxima total. Un servidor que envía un bloque cada 0,9 segundos puede mantener satisfecha una espera de lectura de un segundo durante mucho tiempo. Para limitar la operación completa añade un deadline con asyncio.timeout o timeout_at.
Un PoolTimeout no demuestra que el servidor remoto esté caído. Puede significar que tus tareas consumen conexiones sin devolverlas o que configuraste un pool demasiado pequeño para tu patrón de uso.
#El cuerpo también necesita un límite
await cliente.get(...) recibe normalmente todo el cuerpo antes de devolverte el objeto. Comprobar len(response.content) después detecta un exceso, pero ya has pagado su memoria. Para controlar una respuesta grande debes consumirla progresivamente.
import asyncio
import httpx
async def contar_cuerpo(cliente, url, maximo=16_384):
total = 0
async with cliente.stream("GET", url) as respuesta:
respuesta.raise_for_status()
async for bloque in respuesta.aiter_raw(chunk_size=1024):
total += len(bloque)
if total > maximo:
raise ValueError("cuerpo por encima del límite")
return total
async def main():
async with httpx.AsyncClient(trust_env=False) as cliente:
async with asyncio.timeout(2):
print(await contar_cuerpo(cliente, "http://127.0.0.1:8766/"))
asyncio.run(main())
async for recibe bloques a medida que están disponibles. Aquí contamos bytes de transporte sin descompresión mediante aiter_raw; no guardamos el cuerpo. La aplicación conserva un bloque cada vez, más los buffers internos de biblioteca y sistema. El límite de 16 KiB expresa cuánto aceptamos procesar, no un techo exacto de memoria total ni de bytes que el kernel pueda recibir por adelantado.
Si necesitas JSON, exige un cuerpo pequeño, limita bytes y solo entonces decodifica. Si aceptas compresión, limita también la representación descomprimida: un cuerpo comprimido pequeño puede expandirse mucho. Content-Length permite un rechazo temprano cuando existe, pero no sustituye contar los bytes reales.
Salir del bloque de streaming cierra la respuesta incluso cuando hay excepción o cancelación. Consumirla completamente puede permitir reutilizar la conexión; abandonarla antes de terminar puede obligar a cerrarla. Lo importante es que no quede ocupada indefinidamente.
#Clasificar los fallos sin ocultarlos
# Fragmento de clasificación dentro de una corrutina.
try:
respuesta = await cliente.get(url)
respuesta.raise_for_status()
except httpx.TimeoutException as error:
print("Fase agotada:", type(error).__name__)
except httpx.RequestError as error:
print("Transporte:", type(error).__name__)
except httpx.HTTPStatusError as error:
print("Estado HTTP:", error.response.status_code)
La subclase de timeout va antes de RequestError, porque pertenece a esa familia. Un KeyError en tu transformación de datos no debería acabar etiquetado como «error de red». Tampoco un 401 suele corregirse reintentando sin cambiar credenciales.
Decide si sigues redirecciones. Nuestro proyecto utiliza follow_redirects=False: un endpoint que redirige recibe una clasificación explícita y no lleva la comprobación a otro destino. Aceptar URLs de terceros exige validar destinos y redirecciones, proteger redes internas y controlar DNS; no se convierte en seguro solo añadiendo un semáforo.
#Comprueba lo aprendido
Consulta /no-existe y distingue respuesta 404 de conexión rechazada al apagar el servidor. Reduce después max_connections a uno y mantén una respuesta de streaming abierta: una segunda consulta puede esperar por el pool, aunque el servidor esté sano. El laboratorio final hará reproducible esa comprobación.
Cierra el servidor de esta práctica con Ctrl+C. El siguiente capítulo explica por qué un pool de cuatro conexiones no basta para controlar un millón de entradas.
#Referencias
- Cliente asíncrono y streaming, HTTPX.
- Timeouts, límites del pool y jerarquía de excepciones, HTTPX.
- http.server, Python 3.12, alcance del servidor de laboratorio.