aiohttp es el cliente que debes elegir cuando un rastreador necesita cientos de solicitudes en curso y quieres tener un control total sobre el bucle de eventos. Enrutarlo a través de un proxy requiere solo unos pocos argumentos clave, pero los valores predeterminados relativos a la autenticación, los límites de conexión y los tiempos de espera son diferentes de los de requests y de httpx, y ahí es donde fallan la mayoría de las configuraciones de proxy de aiohttp. Al finalizar esta guía, tendrás un ClientSession que se autentica en una puerta de enlace residencial rotativa, ejecuta solicitudes paralelas limitadas, agota el tiempo de espera de forma predecible e informa de los errores del proxy por tipo.
Los ejemplos están pensados para aiohttp 3.14 en Python 3.11 o posterior. Cuando el comportamiento ha cambiado en una versión reciente, se indica la versión correspondiente.
Configurar un proxy aiohttp en la sesión
La palabra clave proxy se acepta tanto por solicitud como a nivel de sesión. La configuración «por solicitud» corresponde a la API original y sigue funcionando; la configuración a nivel de sesión es la que te conviene para un rastreador, ya que elimina la posibilidad de que una sola llamada eluda el proxy.
import asyncio
import aiohttp
PROXY = "http://USERNAME:PASSWORD@premium-residential.geonode.com:9000"
async def main() -> None:
async with aiohttp.ClientSession(proxy=PROXY) as session:
async with session.get("https://api.ipify.org?format=json") as resp:
print(resp.status, await resp.json())
asyncio.run(main())
El puerto 9000 es la pasarela rotativa HTTP(S) de Geonode, por lo que cada nueva conexión sale desde una IP residencial diferente. Una llamada individual puede seguir anulando el valor predeterminado con su propio argumento proxy=, lo cual resulta útil cuando una solicitud debe realizarse directamente, por ejemplo, a un punto final de métricas interno.
Formato por solicitud
async with aiohttp.ClientSession() as session:
async with session.get("https://api.ipify.org?format=json", proxy=PROXY) as resp:
print(await resp.json())
Utilízalo cuando el proxy varíe de una llamada a otra, como cuando un gestor de grupos asigna una sesión «sticky» diferente por tarea.
Autenticación: credenciales de URL o proxy_headers
Hay dos formas compatibles de enviar credenciales de proxy y una forma obsoleta que aún se puede encontrar en tutoriales antiguos.
| Método | Ejemplo | Estado |
|---|
| Credenciales en la URL del proxy | proxy="http://user:pass@host:9000" | Compatible |
proxy_headers con encode_basic_auth | proxy_headers={"Proxy-Authorization": aiohttp.encode_basic_auth("user", "pass")} | Compatible, recomendado |
proxy_auth=aiohttp.BasicAuth("user", "pass") | | Obsoleto desde la versión 3.14, eliminado en la 4.0 |
El formato de URL es el más breve. El formato de encabezado mantiene el secreto fuera de la URL, lo cual es importante si las URL del proxy aparecen en los registros o en los mensajes de excepción. aiohttp.encode_basic_auth devuelve la cadena Basic ... ya preparada:
import asyncio
import os
import aiohttp
PROXY = "http://premium-residential.geonode.com:9000"
async def main() -> None:
proxy_headers = {
"Proxy-Authorization": aiohttp.encode_basic_auth(
os.environ["GEONODE_USER"], os.environ["GEONODE_PASS"]
)
}
async with aiohttp.ClientSession(proxy=PROXY, proxy_headers=proxy_headers) as session:
async with session.get("https://api.ipify.org?format=json") as resp:
print(await resp.json())
asyncio.run(main())
Si tu código sigue utilizando proxy_auth=aiohttp.BasicAuth(...), funciona en la versión 3.14 con un DeprecationWarning. Migra ahora: el parámetro y la clase se eliminan en aiohttp 4.0, y la sustitución supone un cambio de una sola línea.
Solicitudes paralelas con asyncio.gather y un Semaphore
La razón para utilizar aiohttp es la concurrencia, y la razón por la que la concurrencia provoca fallos en los proxies es que, por defecto, no hay nada que la limite. Un asyncio.gather con más de mil corrutinas intentará abrir mil conexiones; el conector limita ese número a 100, y el resto se pone en cola dentro de aiohttp, lo cual es mejor que nada, pero no te permite controlar el ritmo. Un Semaphore hace explícito el límite y te permite ajustarlo independientemente del conector.
import asyncio
import aiohttp
PROXY = "http://USERNAME:PASSWORD@premium-residential.geonode.com:9000"
CONCURRENCY = 20
async def fetch(session: aiohttp.ClientSession, sem: asyncio.Semaphore, url: str) -> tuple[str, int]:
async with sem:
async with session.get(url) as resp:
await resp.read()
return url, resp.status
async def main() -> None:
urls = [f"https://httpbin.org/get?i={i}" for i in range(100)]
sem = asyncio.Semaphore(CONCURRENCY)
connector = aiohttp.TCPConnector(limit=CONCURRENCY, ttl_dns_cache=300)
timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=20)
async with aiohttp.ClientSession(
proxy=PROXY, connector=connector, timeout=timeout
) as session:
results = await asyncio.gather(
*(fetch(session, sem, u) for u in urls), return_exceptions=True
)
ok = sum(1 for r in results if not isinstance(r, Exception))
print(f"{ok}/{len(urls)} succeeded")
asyncio.run(main())
return_exceptions=True evita que una sola solicitud fallida cancele el lote. Haz coincidir el semáforo con el límite del conector para que ninguna de las partes ponga en cola de forma silenciosa tareas que la otra no pueda procesar.
TCPConnector: limit y limit_per_host
El conector gestiona el grupo de sockets. Hay dos parámetros importantes para el tráfico del proxy aiohttp.
limit es el número total de conexiones simultáneas; el valor por defecto es 100. limit_per_host limita las conexiones a un triple (host, port, is_ssl); el valor por defecto es 0, lo que significa que no hay límite. A través de un proxy, todas las solicitudes a todos los destinos comparten el host y el puerto del proxy, por lo que el límite por host se aplica a la puerta de enlace, no a los sitios de destino. Para una carga de trabajo exclusivamente de proxy, configura limit con la concurrencia deseada y deja limit_per_host en 0; configura limit_per_host solo cuando la misma sesión también se comunique directamente con los orígenes y desees protegerlos.
connector = aiohttp.TCPConnector(limit=30, limit_per_host=10, ttl_dns_cache=300)
ttl_dns_cache=300 mantiene la respuesta DNS de la pasarela durante cinco minutos en lugar de los diez segundos predeterminados, lo que elimina un viaje de ida y vuelta del resolutor en la mayoría de las nuevas conexiones. La sesión toma el control del conector y lo cierra al salir; especifica connector_owner=False solo si compartes deliberadamente un conector entre sesiones.
ClientTimeout en lugar del valor predeterminado de 5 minutos
El tiempo de espera total predeterminado de aiohttp es de 300 segundos. Es un tiempo generoso para las solicitudes directas, pero demasiado largo para el tráfico de proxy, donde una salida residencial bloqueada debería fallar rápidamente para que puedas volver a intentarlo con otra IP.
timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=20)
total limita toda la solicitud, incluidas las redirecciones y la descarga del cuerpo. connect limita la adquisición de una conexión del grupo, más el protocolo de enlace TCP y el del proxy. sock_read limita el intervalo entre bytes consecutivos, lo que detecta aquellos objetivos que aceptan la conexión y luego envían los datos de forma goteo. Pasa el objeto a la sesión para utilizar el valor por defecto, o a una solicitud concreta para anularlo. La caducidad activa asyncio.TimeoutError, que se explica más adelante.
trust_env y los proxies de entorno
A diferencia de requests, aiohttp ignora HTTP_PROXY, HTTPS_PROXY y NO_PROXY a menos que se active con trust_env=True. Cuando está habilitado, utiliza urllib.request.getproxies(), respeta no_proxy y también lee las credenciales de proxy de ~/.netrc, si están presentes.
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get("https://api.ipify.org?format=json") as resp:
print(await resp.json())
Para un rastreador que deba utilizar siempre una puerta de enlace específica, desactiva trust_env y configura proxy= de forma explícita. De este modo, un proxy corporativo en un ejecutor de CI no podrá secuestrar el enrutamiento, y no_proxy no podrá excluir silenciosamente un objetivo.
SOCKS5 con aiohttp-socks
aiohttp no tiene compatibilidad integrada con SOCKS, por lo que un proxy aiohttp a través de SOCKS5 necesita un conector de terceros. El paquete aiohttp-socks proporciona un ProxyConnector que sustituye a TCPConnector y se encarga del protocolo de enlace SOCKS. Instálalo junto con pip install aiohttp-socks. El puerto rotativo SOCKS5 de Geonode es el 11000:
import asyncio
import aiohttp
from aiohttp_socks import ProxyConnector
SOCKS_URL = "socks5://USERNAME:PASSWORD@premium-residential.geonode.com:11000"
async def main() -> None:
connector = ProxyConnector.from_url(SOCKS_URL)
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get("https://api.ipify.org?format=json") as resp:
print(await resp.json())
asyncio.run(main())
No combines ProxyConnector con proxy= en la sesión; el conector ya se encarga de todo. ProxyConnector acepta las mismas palabras clave limit y limit_per_host que TCPConnector.
La limitación de TLS-in-TLS
aiohttp se comunica con los proxies HTTP y establece túneles hacia los destinos https:// a través de ellos mediante CONNECT. Lo que no hace de forma fiable es conectarse al propio proxy a través de TLS, es decir, a una URL de proxy https://, ya que TLS-in-TLS depende de la compatibilidad con el transporte asyncio, que ha variado entre las distintas versiones de Python. La documentación deja claro que no se debe dar esto por sentado. En la práctica, esto no tiene importancia para Geonode: se accede a la pasarela a través de HTTP sin cifrar en los puertos 9000/10000 y el tráfico https:// sigue estando cifrado de extremo a extremo dentro del túnel CONNECT, por lo que debes mantener el esquema de URL del proxy como http://.
Sesiones persistentes para flujos de varios pasos
El puerto rotativo asigna una nueva dirección IP por conexión. Para los flujos de inicio de sesión o los listados paginados, se necesita la misma dirección IP durante unos minutos, algo que Geonode permite en el puerto persistente HTTP(S) 10000 a través del nombre de usuario:
import secrets
import aiohttp
def sticky_proxy(country: str = "us", lifetime: int = 600) -> str:
session_id = secrets.token_hex(4) # 8 alphanumeric characters
user = f"USERNAME-country-{country}-session-{session_id}-lifetime-{lifetime}"
return f"http://{user}:PASSWORD@premium-residential.geonode.com:10000"
session = aiohttp.ClientSession(proxy=sticky_proxy())
La duración oscila entre 180 y 86 400 segundos. La segmentación por estado y ciudad no se puede combinar, y asn requiere un país. Para rotar bajo demanda, basta con generar un nuevo identificador de sesión.
Diagnóstico: qué significa cada excepción
| Excepción | Significado | Solución habitual |
|---|
aiohttp.ClientProxyConnectionError | No se ha podido abrir una conexión TCP con el servidor proxy | Comprueba el nombre del servidor, el puerto y el cortafuegos; aumenta el tiempo de espera de connect |
aiohttp.ClientHttpProxyError con .status == 407 | El proxy ha rechazado las credenciales durante CONNECT | Corrige el nombre de usuario o la contraseña; comprueba la sintaxis de la opción de nombre de usuario |
aiohttp.ClientHttpProxyError con 502/503 | La pasarela no ha podido establecer el túnel | Vuelve a intentarlo con un nuevo identificador de sesión; comprueba que el puerto coincida con el protocolo |
asyncio.TimeoutError | Un campo de ClientTimeout ha caducado | Vuelve a intentarlo con una nueva dirección IP; ajusta sock_read |
aiohttp.ClientConnectorError | Fallo genérico de conexión (DNS, rechazado) | Comprueba .os_error |
ClientProxyConnectionError es una subclase de ClientConnectorError, y ClientHttpProxyError es una subclase de ClientResponseError, así que detecta primero los específicos.
import asyncio
import aiohttp
PROXY = "http://USERNAME:PASSWORD@premium-residential.geonode.com:9000"
async def fetch_ip(session: aiohttp.ClientSession) -> str | None:
try:
async with session.get("https://api.ipify.org?format=json") as resp:
resp.raise_for_status()
return (await resp.json())["ip"]
except aiohttp.ClientHttpProxyError as e:
if e.status == 407:
print("proxy auth failed: check credentials")
else:
print("proxy returned", e.status)
except aiohttp.ClientProxyConnectionError as e:
print("cannot reach proxy:", e)
except asyncio.TimeoutError:
print("timed out through proxy")
return None
async def main() -> None:
timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=20)
async with aiohttp.ClientSession(proxy=PROXY, timeout=timeout) as session:
print(await fetch_ip(session))
await asyncio.sleep(0.25) # let SSL transports close before the loop stops
asyncio.run(main())
Ten en cuenta que, para un objetivo http:// sin modificaciones, el código 407 del proxy llega como una respuesta normal en lugar de como una excepción, así que comprueba también resp.status en ese caso.
Cierre ordenado
Cuando se cierra una sesión, es posible que asyncio aún esté desmontando los transportes SSL subyacentes. Si el bucle de eventos se detiene primero, se produce el error ResourceWarning: unclosed transport. La solución documentada consiste en una breve pausa tras el bloque async with de la sesión: await asyncio.sleep(0) para sesiones HTTP sin cifrar, y await asyncio.sleep(0.25) cuando la sesión ha realizado peticiones HTTPS, lo cual, al pasar por un túnel CONNECT, ocurre casi siempre. El ejemplo anterior lo incluye.
Errores comunes
Crear una sesión por cada solicitud
ClientSession gestiona el grupo de conexiones y el almacén de cookies. Abrir una sesión por cada solicitud anula la función «keep-alive» con la pasarela, multiplica los handshakes del proxy y activa la advertencia de cierre en cada llamada. Crea una sesión por cada trabajador y pásala de uno a otro.
Olvidar que el proxy es el único «host»
Si limit_per_host está configurado con un valor bajo, cada solicitud proxy cuenta para la única entrada (host, port) de la pasarela, por lo que un límite de 5 por host restringe todo tu rastreador a 5 conexiones. Configura limit en su lugar, o deja limit_per_host en 0.
Confiar en el tiempo de espera predeterminado
Cinco minutos es tiempo suficiente para que una salida residencial bloqueada ocupe una ranura de semáforo durante todo ese intervalo. Pasa siempre un ClientTimeout con valores para connect y sock_read.
Si se sigue pasando proxy_auth
Actualmente funciona con una advertencia, pero deja de funcionar en la versión 4.0. Sustitúyelo por proxy_headers={"Proxy-Authorization": aiohttp.encode_basic_auth(user, pw)} o introduce las credenciales en la URL del proxy.
Siguiente paso
Con la sesión anterior, has autenticado, vinculado y hecho observable el tráfico del proxy aiohttp; lo que queda por abordar es el grupo de proxies por el que sale. Geonode proxies residenciales dan servicio a los puntos finales utilizados aquí: 2,5 millones de direcciones IP en línea, entre 30 y 50 millones de direcciones IP únicas que rotan mensualmente en más de 195 países, con segmentación por país, estado, ciudad y ASN a través del nombre de usuario y sesiones persistentes de hasta 24 horas a través de HTTP, HTTPS y SOCKS5. Los precios comienzan en 0,27 $/GB por volumen; el plan básico es de 10 GB por 7,92 $ al mes, y la prueba de 3 días cuesta 5 $ sin compromiso de permanencia. Los casos de uso de web scraping y inteligencia de precios muestran cómo los equipos combinan esta configuración del conector con la lógica de reintentos y programación.
Preguntas frecuentes
¿Cómo configuro un proxy aiohttp para toda una sesión?
Pasa proxy= al constructor de ClientSession: aiohttp.ClientSession(proxy="http://user:pass@host:port"). Todas las solicitudes de esa sesión lo utilizarán, a menos que la llamada sobrescriba proxy= con otro valor. El proxy a nivel de sesión está disponible desde la versión 3.11 de aiohttp; en versiones anteriores, debes pasar proxy= en cada llamada a get() o post().
¿Por qué se ha dejado de recomendar el uso de proxy_auth en aiohttp y qué lo sustituye?
Desde la versión 3.14 de aiohttp, el parámetro proxy_auth y la clase aiohttp.BasicAuth han quedado obsoletos y está previsto su eliminación en la versión 4.0. El sustituto es un encabezado simple: proxy_headers={"Proxy-Authorization": aiohttp.encode_basic_auth("user", "pass")}. Realiza la misma codificación Base64 sin necesidad de una clase auxiliar y funciona tanto por solicitud como a nivel de sesión.
¿Admite aiohttp proxies HTTPS o SOCKS5?
aiohttp se comunica de forma nativa con los proxies HTTP y utiliza CONNECT para tunelizar los destinos https:// a través de ellos. La conexión al propio proxy a través de TLS (TLS-in-TLS) depende de la compatibilidad con asyncio en tu compilación de Python y no se debe dar por sentada. SOCKS4 y SOCKS5 requieren el paquete de terceros aiohttp-socks y su ProxyConnector.
¿Cuántas solicitudes simultáneas puedo ejecutar a través de un proxy aiohttp?
TCPConnector permite 100 conexiones simultáneas de forma predeterminada, sin límite por host. A través de un proxy, todas las solicitudes comparten el mismo host y puerto, por lo que debes configurar limit de forma explícita, añadir limit_per_host si también realizas llamadas directas y envolver las corrutinas de solicitud en un asyncio.Semaphore para que las tareas en cola no abran sockets todas a la vez.
¿Por qué mi solicitud aiohttp a través de un proxy se cuelga en lugar de fallar?
ClientSession tiene un tiempo de espera total de 5 minutos por defecto, lo que parece un bloqueo cuando se atasca una salida residencial. Pasa aiohttp.ClientTimeout(total=30, connect=10, sock_read=20) a la sesión o a la solicitud para que los atascos se detecten rápidamente como asyncio.TimeoutError; a continuación, vuelve a intentarlo con un nuevo ID de sesión en lugar de esperar.
¿Cuáles son los límites prácticos de enrutar los rastreadores a través de proxies residenciales?
Un proxy solo cambia la IP de origen. No concede permiso para ignorar el archivo robots.txt, las condiciones del sitio web ni los límites de frecuencia, y no hace que la recopilación de datos personales sea legal. Mantén una concurrencia razonable, retrocede ante los códigos 429 y recuerda que las sesiones persistentes Geonode duran como máximo 24 horas antes de que se libere la IP.