`httpx`: cliente HTTP sync y async con HTTP/2 y timeouts granulares

httpx no es un reemplazo cosmético de requests — es una decisión de diseño diferente desde los cimientos. Mientras requests fue construido en una era pre-async con una API síncrona bloqueante, httpx nació sabiendo que tenía que vivir en ambos mundos: código síncrono tradicional y corrutinas asyncio, con la misma API y sin sorpresas.

El núcleo de la diferencia está en el transport layer. requests delega todo a urllib3, que no tiene concepto de event loop. httpx implementa su propio sistema de transporte que puede conectarse tanto a un pool de hilos síncronos como a un event loop de asyncio, y sobre eso agrega soporte nativo de HTTP/2 — multiplexación real sobre una sola conexión TCP, sin que tengas que hacer nada especial más allá de instalar httpx[http2].

Usas httpx.Client() (el equivalente exacto de requests.Session()) cuando tu código es síncrono y quieres reutilizar conexiones, manejar cookies persistentes o aplicar configuración base. Para código async, cambias a httpx.AsyncClient() y lo envuelves en async with. La API es prácticamente idéntica — esa es la apuesta de httpx para facilitar migración gradual.

Donde httpx supera claramente a requests es en el manejo de timeouts estructurados. Con requests tienes un timeout único que aplica a toda la operación, lo que es ambiguo y propenso a sorpresas. Con httpx.Timeout puedes ser quirúrgico: cuánto esperar para establecer la conexión (connect), cuánto para leer datos (read), cuánto para escribir el request body (write), y un pool timeout para obtener una conexión libre del pool. Si defines httpx.Timeout(5.0) pones el mismo valor en todas las fases; si defines httpx.Timeout(connect=3, read=60, write=10) estás expresando exactamente la semántica que quieres.

Lo que se rompe si lo ignorás: si no usás un context manager (ni síncrono ni async), las conexiones del pool no se liberan correctamente. Y si no definís timeouts explícitos, httpx no tiene ninguno por defecto — algo que sorprende a migrantes de requests que asumían comportamiento conservador por defecto.

import asyncio
import httpx

# Timeout granular: conexión rápida, lectura generosa para endpoints lentos
TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=2.0)

BASE_URL = "https://httpbin.org"


# ── SÍNCRONO ──────────────────────────────────────────────────────────────────

def fetch_sync(paths: list[str]) -> list[dict]:
    """Múltiples GETs síncronos reutilizando el pool de conexiones."""
    results = []
    with httpx.Client(base_url=BASE_URL, timeout=TIMEOUT, http2=True) as client:
        for path in paths:
            response = client.get(path)
            response.raise_for_status()
            results.append(response.json())
    return results


def stream_large_file_sync(url: str, chunk_size: int = 8192) -> int:
    """Streaming síncrono: descarga sin cargar todo en memoria."""
    total_bytes = 0
    with httpx.Client(timeout=TIMEOUT) as client:
        # client.stream() devuelve un context manager, no una Response directa
        with client.stream("GET", url) as response:
            response.raise_for_status()
            for chunk in response.iter_bytes(chunk_size=chunk_size):
                total_bytes += len(chunk)
    return total_bytes


# ── ASÍNCRONO ─────────────────────────────────────────────────────────────────

async def fetch_concurrently(paths: list[str]) -> list[dict]:
    """
    HTTP/2 multiplexado: todas las requests van sobre la misma conexión TCP.
    Sin HTTP/2, necesitarías asyncio.gather + múltiples conexiones.
    """
    async with httpx.AsyncClient(
        base_url=BASE_URL,
        timeout=TIMEOUT,
        http2=True,
    ) as client:
        # Con HTTP/2 activo, httpx multiplexa estas requests automáticamente
        tasks = [client.get(path) for path in paths]
        responses = await asyncio.gather(*tasks)

    # Procesamos fuera del context manager; las conexiones ya están cerradas
    return [r.raise_for_status() or r.json() for r in responses]


async def stream_large_file_async(url: str, chunk_size: int = 8192) -> int:
    """Streaming asíncrono: no bloquea el event loop mientras llegan los datos."""
    total_bytes = 0
    async with httpx.AsyncClient(timeout=TIMEOUT) as client:
        async with client.stream("GET", url) as response:
            response.raise_for_status()
            async for chunk in response.aiter_bytes(chunk_size=chunk_size):
                total_bytes += len(chunk)
    return total_bytes


# ── MIGRACIÓN GRADUAL DESDE requests ─────────────────────────────────────────

def demonstrate_requests_compat() -> None:
    """
    La API de httpx.Client es casi un drop-in de requests.Session.
    Diferencias reales: timeouts estructurados, no acepta verify=False sin
    advertencia, y algunos hooks de requests no existen.
    """
    with httpx.Client(timeout=TIMEOUT) as client:
        # Estos patrones son idénticos a requests.Session
        response = client.get(
            f"{BASE_URL}/get",
            params={"key": "value"},
            headers={"X-Custom": "header"},
        )
        print(f"Status: {response.status_code}")
        print(f"HTTP version: {response.http_version}")  # "HTTP/1.1" o "HTTP/2"
        print(f"Elapsed: {response.elapsed.total_seconds():.3f}s")


if __name__ == "__main__":
    paths = ["/get", "/headers", "/ip"]

    print("=== Sync fetch ===")
    sync_results = fetch_sync(paths)
    print(f"Got {len(sync_results)} responses")

    print("\n=== Async concurrent fetch ===")
    async_results = asyncio.run(fetch_concurrently(paths))
    print(f"Got {len(async_results)} responses")

    print("\n=== Compat demo ===")
    demonstrate_requests_compat()

Qué hace cada decisión aquí

El httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=2.0) global refleja una realidad de producción: el tiempo aceptable para establecer una conexión es diferente al tiempo aceptable para leer una respuesta de un endpoint que procesa datos. Hardcodear un solo valor de 30 segundos significaría que un host caído puede bloquear tu hilo durante medio minuto; este esquema falla rápido en conexión y es generoso en lectura.

El http2=True en el Client activa multiplexación en cuanto el servidor lo soporta (mediante ALPN en el handshake TLS). La diferencia práctica en fetch_concurrently es significativa: sin HTTP/2, asyncio.gather sobre AsyncClient abre conexiones separadas; con HTTP/2, esas requests viajan como streams distintos sobre la misma conexión TCP, eliminando el overhead del handshake repetido.

El streaming merece atención especial. Cuando llamás client.stream(), la respuesta no se materializa en memoria — el body llega como chunks. El context manager interno (with client.stream(...) as response) es lo que mantiene viva la conexión durante la lectura iterativa. Si salieras del bloque sin consumir todos los chunks, httpx cierra la conexión correctamente de todas formas, pero perderías datos. Lo mismo aplica en la versión async con async with y aiter_bytes.

La línea response.http_version no existe en requests — es una adición que permite introspección real del protocolo negociado, útil para verificar que HTTP/2 realmente se está usando en producción.

Errores que debes conocer

Error: instanciar httpx.Client() o httpx.AsyncClient() sin context manager y no llamar .close() explícitamente — el pool de conexiones queda abierto indefinidamente.

# ❌ Wrong
client = httpx.Client()
response = client.get("https://example.com/api")
data = response.json()
# el pool queda vivo, los file descriptors se acumulan

# ✅ Right
with httpx.Client() as client:
    response = client.get("https://example.com/api")
    data = response.json()

Usar el context manager garantiza que client.__exit__ llame a client.close(), que drena el pool y cierra los sockets subyacentes.


Error: asumir que httpx tiene timeouts por defecto como requests — en realidad no tiene ninguno, y en producción eso significa requests que cuelgan indefinidamente.

# ❌ Wrong
with httpx.Client() as client:
    # sin timeout: si el servidor no responde, esto bloquea para siempre
    response = client.get("https://slow-api.example.com/data")

# ✅ Right
with httpx.Client(timeout=httpx.Timeout(connect=5.0, read=30.0)) as client:
    response = client.get("https://slow-api.example.com/data")

El timeout se puede pasar al Client como default para todas las requests, o sobreescribirse por llamada pasando timeout= al método .get() directamente.


Error: usar asyncio.gather con AsyncClient y construir los awaitables fuera del context manager — si el client ya cerró sus conexiones, las corrutinas fallan al ejecutarse.

# ❌ Wrong
async with httpx.AsyncClient() as client:
    tasks = [client.get(url) for url in urls]
# client ya está cerrado aquí
results = await asyncio.gather(*tasks)  # RuntimeError o ConnectionError

# ✅ Right
async with httpx.AsyncClient() as client:
    tasks = [client.get(url) for url in urls]
    results = await asyncio.gather(*tasks)  # dentro del context manager

Las corrutinas de client.get() no ejecutan hasta el await — si el client se cierra antes del gather, no hay conexión disponible para hacer el request.


httpx también expone httpx.MockTransport y httpx.MockAsyncTransport para tests sin red real, lo que hace que el testing de código que usa HTTP sea considerablemente más limpio que mockear requests con responses o unittest.mock.

155

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio