Saltar al contenido

Capítulo 5 de 12

Hablar con servicios reales

Introduce HTTPX desde cero y controla conexiones, estados HTTP, lectura de cuerpos y diferencias entre los distintos timeouts.

#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.

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar