Rotación de proxies asíncrona en Python: httpx y aiohttp (Guía 2026)

Publicado el 12 de junio de 2026 · ≈10 min de lectura

El scraping asíncrono es donde las configuraciones de proxy se desmoronan silenciosamente. Con requests rotas una IP por llamada y sigues adelante; con asyncio de repente tienes 200 solicitudes en vuelo, todas necesitando su propio exit, su propia afinidad de sesión y su propio reintento cuando un objetivo lanza un 403. Hazlo mal y o machacas una IP hasta el baneo o destrozas cada sesión de inicio de sesión al rotar a mitad del flujo.

Esta guía muestra los patrones que de verdad aguantan en concurrencia: rotación por solicitud en httpx y aiohttp, un pool de workers acotado, sesiones sticky para flujos con estado y reintentos conscientes de bloqueos — con código funcional que puedes pegar.

Por qué async cambia el problema del proxy

Un scraper síncrono toca un proxy a la vez, así que "rota en cada solicitud" es trivialmente correcto. Async rompe tres supuestos a la vez:

httpx: rotación de proxy por solicitud

httpx.AsyncClient vincula un proxy por cliente, así que para rotar eliges el proxy por solicitud y enrutas a través de un pequeño pool de clientes indexados por exit:

import asyncio, itertools, httpx

PROXIES = [
    "socks5h://USERNAME:[email protected]:913",
    "socks5h://USERNAME:[email protected]:913",
    "socks5h://USERNAME:[email protected]:913",
]
pool = itertools.cycle(PROXIES)

# One reusable client per exit (connection pooling stays intact)
clients = {p: httpx.AsyncClient(proxy=p, timeout=30) for p in PROXIES}

async def fetch(url):
    proxy = next(pool)
    r = await clients[proxy].get(url)
    return r.status_code, r.text

async def main(urls):
    results = await asyncio.gather(*(fetch(u) for u in urls))
    for client in clients.values():
        await client.aclose()
    return results

Reutilizar un cliente por exit mantiene vivos HTTP/2 y el pooling de conexiones en lugar de pagar un handshake nuevo en cada solicitud. Ten en cuenta que httpx>=0.28 renombró proxies= a proxy= en el cliente.

aiohttp: rotación vía la solicitud

aiohttp es más sencillo aquí — una sola ClientSession acepta un argumento proxy= por solicitud, así que rotas en línea:

import asyncio, itertools, aiohttp

pool = itertools.cycle(PROXIES)  # http:// or socks5h:// (needs aiohttp-socks for SOCKS)

async def fetch(session, url):
    proxy = next(pool)
    async with session.get(url, proxy=proxy, timeout=aiohttp.ClientTimeout(total=30)) as r:
        return r.status, await r.text()

async def main(urls):
    async with aiohttp.ClientSession() as session:
        return await asyncio.gather(*(fetch(session, u) for u in urls))

aiohttp habla proxies HTTP de forma nativa; para socks5h:// añade aiohttp-socks y pasa un ProxyConnector.

Limita la concurrencia con un semáforo

Un gather sin límites es la forma más rápida de que todas las IPs de tu pool sean marcadas a la vez. Acótalo para que cada exit lleve un número de solicitudes paralelas plausible para un humano:

sem = asyncio.Semaphore(10)   # at most 10 in flight

async def fetch_capped(session, url):
    async with sem:
        return await fetch(session, url)

Regla general: mantén la concurrencia en o por debajo del número de exits distintos que tienes, para no apilar muchas solicitudes paralelas sobre una única IP residencial.

Sesiones sticky para flujos con estado

La rotación es para entrar; las sesiones sticky son para quedarte. Un inicio de sesión, un carrito, cualquier cosa atada a cookies debe mantener un exit durante toda la secuencia. Fija el proxy por tarea en lugar de por solicitud:

async def run_account(account, proxy):
    # Same exit IP for every step of this account's flow
    async with httpx.AsyncClient(proxy=proxy, timeout=30) as client:
        await client.post("https://target.site/login", data=account.creds)
        await client.get("https://target.site/dashboard")
        await client.post("https://target.site/cart", json=account.order)

async def main(accounts):
    # One sticky exit per account, accounts run concurrently
    await asyncio.gather(*(run_account(a, p)
                           for a, p in zip(accounts, itertools.cycle(PROXIES))))

Usa un proveedor que admita sesiones residenciales sticky para que el mismo exit se mantenga durante toda la vida de la tarea, en lugar de rotar silenciosamente bajo tus pies.

Reintentos conscientes de bloqueos

En concurrencia no puedes confiar en un 200 — los sistemas antibot devuelven un 200 con un cuerpo de desafío. Detecta los bloqueos y reintenta la única tarea fallida en un exit nuevo:

BLOCK_MARKERS = ("just a moment", "/cdn-cgi/challenge-platform",
                 "datadome", "px-captcha", "access denied")

def looks_blocked(status, text):
    return status in (403, 429, 503) or any(m in text[:4000].lower() for m in BLOCK_MARKERS)

async def fetch_retry(url, attempts=4):
    for _ in range(attempts):
        proxy = next(pool)
        async with httpx.AsyncClient(proxy=proxy, timeout=30) as c:
            r = await c.get(url)
            if not looks_blocked(r.status_code, r.text):
                return r
        await asyncio.sleep(0.5)
    raise RuntimeError(f"blocked after {attempts} exits: {url}")

La huella todavía tiene que coincidir

Una pega que async no resuelve: httpx y aiohttp se apoyan ambos en la pila TLS de Python, así que envían un JA3 que ningún navegador real produce. Un pool de rotación perfecto sobre una IP residencial limpia sigue siendo identificado como automatización en el handshake. Combina la rotación asíncrona con la suplantación de TLS — consulta Evade el fingerprinting de TLS con curl_cffi, y verifica el JA3 + ASN de tu exit en www.jibaoproxy.com/tools/fingerprint.html.

Lista de comprobación

Para todos los productos IP · un enorme pool de nodos disponibles en cualquier momento

Regístrate ahora y obtén hasta un 100% de reembolso en tu recarga

Los nuevos usuarios reciben 500MB al registrarse, más un bono en la primera recarga. Oferta por tiempo limitado.