Peticiones HTTP concurrentes con asyncio.gather y aiohttp
Obtén múltiples endpoints HTTP concurrentemente con asyncio.gather y aiohttp, con manejo de errores, rate limiting, timeouts y pool de conexiones.
Pedir 100 endpoints uno tras otro te cuesta 100 viajes de latencia. asyncio.gather los lanza a la vez, así que el tiempo total se reduce aproximadamente al de la petición más lenta. Lo que sigue cubre el fetching concurrente con aiohttp: manejo de errores, un semáforo para el rate limiting, timeouts y pool de conexiones.
Cuándo Usar Esto
- Traerte datos de varias APIs en un mismo viaje
- Web scraping con descargas concurrentes de páginas
- Procesamiento por lotes de tareas basadas en HTTP (p. ej., llamar a 100 endpoints de LLM)
- Cualquier carga de trabajo I/O-bound donde las peticiones sean independientes
Requisitos Previos
- Python 3.10+
- El paquete
aiohttp(pip install aiohttp)
Solución
Una versión consolidada y ejecutable está en el repositorio complementario.
1. Instalar dependencias
pip install aiohttp
2. Fetch concurrente básico
import asyncio
import aiohttp
import time
async def fetch_url(session: aiohttp.ClientSession, url: str) -> dict:
"""Fetch a single URL and return status + content.
Args:
session: aiohttp client session.
url: URL to fetch.
Returns:
Dict with url, status, and text.
"""
async with session.get(url) as response:
return {
"url": url,
"status": response.status,
"text": await response.text(),
}
async def fetch_all(urls: list[str]) -> list[dict]:
"""Fetch all URLs concurrently.
Args:
urls: List of URLs to fetch.
Returns:
List of result dicts in the same order as input URLs.
"""
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
# Uso
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
start = time.time()
results = asyncio.run(fetch_all(urls))
elapsed = time.time() - start
print(f"Fetched {len(urls)} URLs in {elapsed:.2f}s (concurrent)")
# ~1.2s en lugar de ~5s en secuencial
3. Manejo de errores con return_exceptions
async def fetch_all_safe(urls: list[str]) -> list[dict | Exception]:
"""Fetch all URLs, capturing exceptions instead of failing.
Args:
urls: List of URLs.
Returns:
List of results or Exception objects.
"""
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
# return_exceptions=True evita que un fallo cancele a las demás
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
# Procesar resultados, manejando excepciones
results = asyncio.run(fetch_all_safe(urls))
for i, result in enumerate(results):
if isinstance(result, Exception):
print(f"URL {i} failed: {result}")
else:
print(f"URL {i}: status {result['status']}")
4. Limitar la concurrencia con un semáforo
async def fetch_with_limit(
urls: list[str],
max_concurrent: int = 10,
) -> list[dict | Exception]:
"""Fetch URLs with a concurrency limit to avoid overwhelming servers.
Args:
urls: List of URLs.
max_concurrent: Maximum simultaneous requests.
Returns:
List of results or exceptions.
"""
semaphore = asyncio.Semaphore(max_concurrent)
async def bounded_fetch(session: aiohttp.ClientSession, url: str) -> dict:
async with semaphore:
return await fetch_url(session, url)
async with aiohttp.ClientSession() as session:
tasks = [bounded_fetch(session, url) for url in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
# Limitar a 10 peticiones concurrentes
results = asyncio.run(fetch_with_limit(urls, max_concurrent=10))
5. Timeouts
import aiohttp
async def fetch_with_timeout(
session: aiohttp.ClientSession,
url: str,
timeout_seconds: float = 10.0,
) -> dict:
"""Fetch with a per-request timeout.
Args:
session: aiohttp session.
url: URL to fetch.
timeout_seconds: Timeout in seconds.
Returns:
Result dict or timeout error.
"""
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
try:
async with session.get(url, timeout=timeout) as response:
return {
"url": url,
"status": response.status,
"text": await response.text(),
}
except asyncio.TimeoutError:
return {"url": url, "status": 0, "error": "timeout"}
except aiohttp.ClientError as e:
return {"url": url, "status": 0, "error": str(e)}
async def fetch_all_with_timeouts(urls: list[str], timeout: float = 10.0) -> list[dict]:
"""Fetch all URLs with timeouts."""
async with aiohttp.ClientSession() as session:
tasks = [fetch_with_timeout(session, url, timeout) for url in urls]
return await asyncio.gather(*tasks)
6. Pool de conexiones con configuración de sesión
async def fetch_with_pool(
urls: list[str],
max_concurrent: int = 20,
) -> list[dict | Exception]:
"""Fetch with optimized connection pool settings.
Args:
urls: List of URLs.
max_concurrent: Max concurrent requests.
Returns:
List of results.
"""
# Configurar el pool de conexiones
connector = aiohttp.TCPConnector(
limit=max_concurrent, # Límite total de conexiones
limit_per_host=5, # Límite por host
ttl_dns_cache=300, # TTL de la caché DNS en segundos
enable_cleanup_closed=True,
)
timeout = aiohttp.ClientTimeout(
total=30, # Timeout total
connect=10, # Timeout de conexión
sock_read=10, # Timeout de lectura del socket
)
semaphore = asyncio.Semaphore(max_concurrent)
async def bounded_fetch(session, url):
async with semaphore:
return await fetch_url(session, url)
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
tasks = [bounded_fetch(session, url) for url in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
7. Seguimiento del progreso
async def fetch_with_progress(urls: list[str]) -> list[dict | Exception]:
"""Fetch URLs with real-time progress tracking."""
results = [None] * len(urls)
completed = 0
async def fetch_and_track(session: aiohttp.ClientSession, index: int, url: str):
nonlocal completed
try:
result = await fetch_url(session, url)
results[index] = result
except Exception as e:
results[index] = e
finally:
completed += 1
print(f"\rProgress: {completed}/{len(urls)}", end="", flush=True)
async with aiohttp.ClientSession() as session:
tasks = [fetch_and_track(session, i, url) for i, url in enumerate(urls)]
await asyncio.gather(*tasks)
print() # Línea nueva tras el progreso
return results
Cómo Funciona
asyncio.gather(*tasks)dispara todas las corrutinas a la vez. El event loop las intercala, suspendiendo cada tarea en susawaity retomándola cuando la I/O termina.aiohttp.ClientSessiones quien guarda el pool de conexiones. Con una única sesión para todas las peticiones, las conexiones TCP se reciclan en lugar de reabrirse en cada llamada.asyncio.Semaphorefija cuántas operaciones pueden estar en vuelo a la vez. Cuandomax_concurrenttareas ocupan slots, el resto espera a que se libere uno.return_exceptions=Trueconvierte los fallos en valores dentro de la lista de resultados en lugar de lanzarlos, así un endpoint caído no cancela las otras 99 peticiones en vuelo.ClientTimeoutdefine plazos por petición:totalacota todo el intercambio,connectel handshake TCP ysock_readla lectura de la respuesta.
Variantes
Procesamiento por lotes con chunks
async def fetch_in_batches(
urls: list[str],
batch_size: int = 50,
) -> list[dict | Exception]:
"""Fetch URLs in batches to control memory and rate."""
results = []
for i in range(0, len(urls), batch_size):
batch = urls[i:i + batch_size]
batch_results = await fetch_all_safe(batch)
results.extend(batch_results)
print(f"Completed batch {i // batch_size + 1}")
return results
Reintentos con backoff exponencial
async def fetch_with_retry(
session: aiohttp.ClientSession,
url: str,
max_retries: int = 3,
base_delay: float = 1.0,
) -> dict:
"""Fetch with retry and exponential backoff."""
for attempt in range(max_retries):
try:
async with session.get(url) as response:
if response.status == 429:
raise aiohttp.ClientError("Rate limited")
return {
"url": url,
"status": response.status,
"text": await response.text(),
}
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
if attempt == max_retries - 1:
return {"url": url, "error": str(e), "attempts": attempt + 1}
delay = base_delay * (2 ** attempt)
print(f"Retry {attempt + 1}/{max_retries} for {url} in {delay}s")
await asyncio.sleep(delay)
async def fetch_all_with_retry(urls: list[str]) -> list[dict]:
async with aiohttp.ClientSession() as session:
tasks = [fetch_with_retry(session, url) for url in urls]
return await asyncio.gather(*tasks)
Peticiones POST con JSON
async def post_json(
session: aiohttp.ClientSession,
url: str,
data: dict,
) -> dict:
"""Send a POST request with JSON body."""
async with session.post(url, json=data) as response:
return {
"url": url,
"status": response.status,
"json": await response.json(),
}
async def post_all(
url: str,
payloads: list[dict],
) -> list[dict]:
"""Send multiple POST requests concurrently."""
async with aiohttp.ClientSession() as session:
tasks = [post_json(session, url, payload) for payload in payloads]
return await asyncio.gather(*tasks)
Usar httpx (alternativa a aiohttp)
import httpx
import asyncio
async def fetch_httpx(urls: list[str]) -> list[dict]:
"""Concurrent fetch using httpx (syncs with requests API)."""
async with httpx.AsyncClient() as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
return [
{"url": str(r.url), "status": r.status_code, "text": r.text}
for r in responses
]
Mejores Prácticas
- Reutiliza
ClientSession: crear una nueva por cada petición anula por completo el pooling. - Define
limit_per_host: evita martillear un solo servidor con todo a la vez. - Acota la concurrencia con un semáforo: respeta el rate limit de la API, no solo tu throughput. La receta de rate limiting con semáforos profundiza en esto.
- Pon timeouts siempre: sin ellos, un servidor colgado puede paralizar toda la aplicación.
Errores Comunes
- Crear una
ClientSessionnueva por petición: anula el pooling; crea la sesión una vez y compártela. - Olvidar
return_exceptions=True: la primera petición que falla cancela todas las demás en vuelo. - No limitar la concurrencia: lanzar 10.000 peticiones a la vez sobrecarga tu máquina y el servidor.
- Llamar a
asyncio.run()dentro de un event loop activo (Jupyter, FastAPI): lanzaRuntimeError; hazawaita la corrutina directamente.
Ver También
- Documentación de asyncio.gather: la semántica oficial, incluido cómo funciona
return_exceptions. - Referencia del cliente aiohttp: todo sobre sesiones, conectores y timeouts.
- Guía async de httpx: el cliente alternativo usado en las variantes.
- Python Asyncio en Producción: la guía a fondo sobre supervisión, shutdown y observabilidad.
Preguntas frecuentes
¿Cuántas peticiones concurrentes debería hacer?
Empieza con 10-50. Si la API publica un rate limit, iguálalo (10 en vuelo para una API de 10 req/s). Contra tus propios servidores puedes subir más, pero mide en lugar de adivinar, porque la concurrencia extra deja de ayudar cuando el pool o la red se saturan.
¿asyncio.gather o asyncio.TaskGroup?
TaskGroup (Python 3.11+) es la opción moderna con manejo estructurado de errores, donde un fallo cancela el grupo entero limpiamente. Usa gather cuando quieras la semántica de return_exceptions o compatibilidad con Pythons antiguos. La receta de TaskGroup muestra el estilo alternativo.
¿aiohttp o httpx?
Ambos funcionan. aiohttp es el veterano maduro para async; httpx tiene una API tipo requests y cubre sync y async con un solo cliente. Si ya conoces requests, httpx te resultará familiar; para ajustar el conector en detalle, aiohttp te da TCPConnector directamente.
¿Puedo usar requests con asyncio?
No. requests es síncrono y bloquea el event loop, así que cada llamada "concurrente" en realidad correría de una en una. Para HTTP async lo que necesitas es aiohttp o httpx.AsyncClient.
¿Puedo ejecutar esto dentro de FastAPI o Jupyter?
Sí. Simplemente sáltate asyncio.run(), porque en ambos sitios ya hay un loop corriendo. Haz await fetch_all(urls) directamente en el handler de la ruta, o usa nest_asyncio en notebooks.
Recursos Relacionados
Rate limiting distribuido con FastAPI y Redis
Implementa rate limiting distribuido en FastAPI usando algoritmos de sliding window y token bucket con Redis para límites por usuario, IP y endpoint.
RecipeStream de salida LLM con Server-Sent Events (SSE)
Stream respuestas LLM a clientes en tiempo real usando Server-Sent Events con FastAPI, OpenAI streaming y async generators para salida token por token
RecipeRate limiting con Redis y algoritmo Token Bucket
Implementa un rate limiter distribuido con token bucket usando operaciones atomicas de Redis para throttling de API entre multiples instancias
RecipeEjecutar Tareas Async Concurrentes con asyncio.gather y
Ejecutar multiples operaciones async concurrentemente en Python usando asyncio.gather, asyncio.TaskGroup, manejo de errores con return_exceptions, timeouts y semaforos para rate limiting.
RecipeLimitar Llamadas API Concurrentes con asyncio.Semaphore
Usa asyncio.Semaphore en Python para acotar llamadas a API, consultas a base de datos y acceso a recursos con patrones prácticos de limitación de tasa.
GuidePython Asyncio en Producción
Ejecutar Python asyncio en produccion con confianza. Cubre event loops, gestion de tasks, debugging, cancellation, timeouts, backpressure y patrones para aplicaciones async de alta concurrencia.