Scraping Python asynchrone avec httpx, aiohttp et proxies
L'asynchrone transforme un scraper lent en un rapide — et un rapide en un bloqué, sauf si vous gérez correctement les proxies, les sémaphores et les délais d'attente. Voici le modèle complet pour httpx et aiohttp, avec du code.
Si votre scraper passe la plupart de son temps d'horloge à attendre le réseau, l'asynchrone est le plus grand gain de vitesse disponible — et les proxies sont ce qui empêche cette vitesse de vous faire bannir. Ce guide couvre le scraping Python asynchrone avec httpx et aiohttp plus les proxies de bout en bout : comment chaque bibliothèque définit un proxy, comment limiter la concurrence avec un sémaphore, comment définir des délais d'attente qui se déclenchent réellement, et comment faire tourner les IPs sans détruire vos sessions. Tout cela avec du code exécutable et des identifiants de remplacement que vous échangez pour les vôtres.
Pourquoi l'asynchrone, et où les proxies s'intègrent-ils
Le requests standard est bloquant : chaque appel attend la réponse avant que le suivant ne commence. Récupérez 500 pages et vous payez 500 allers-retours consécutifs. L'asynchrone les récupère simultanément sur une boucle d'événements, donc le temps total s'effondre vers la requête unique la plus lente au lieu de la somme. Le hic : une rafale de requêtes simultanées depuis une IP est exactement la signature que les systèmes anti-bot surveillent. La solution n'est pas de ralentir jusqu'à ramper — c'est de répartir le trafic sur un pool de proxies rotatifs et de réguler délibérément avec un sémaphore.
Définir un proxy dans httpx (asynchrone)
httpx est le choix pragmatique par défaut car un modèle client fait à la fois synchrone et asynchrone, et il parle HTTP/2. Notez l'API moderne : c'est un argument singulier proxy= sur le client, pas l'ancien dictionnaire proxies= — une source courante de confusion "pourquoi mon proxy est-il ignoré" après une mise à jour. Les identifiants vont directement dans l'URL du proxy.
import asyncio, httpx
PROXY = "http://USER:PASS@gate.quantumproxies.io:8000"
async def fetch(client, url):
r = await client.get(url, timeout=httpx.Timeout(20.0))
return url, r.status_code, r.text
async def main(urls):
async with httpx.AsyncClient(proxy=PROXY, http2=True) as client:
tasks = [fetch(client, u) for u in urls]
return await asyncio.gather(*tasks)
urls = ["https://httpbin.org/ip"] * 5
print(asyncio.run(main(urls)))
Un client, réutilisé pour chaque requête, est le point — il garde la piscine de connexions chaude pour éviter les poignées de main TLS répétées à travers le proxy. Créer un nouveau client par requête est le bug de performance asynchrone le plus courant : cela jette la réutilisation de la connexion et l'état des cookies à chaque appel.
Définir un proxy dans aiohttp (par requête)
aiohttp est natif d'asyncio et vous offre le meilleur contrôle de la concurrence, mais sa convention de proxy diffère : le proxy est passé par requête sur session.get(), pas sur la session. C'est en fait pratique pour la rotation. Deux choses mordent ici — le User-Agent par défaut est littéralement Python/3.x aiohttp/3.x, un signe révélateur que vous devez remplacer, et vous devez dimensionner explicitement la piscine de connexions.
import aiohttp, asyncio
PROXY = "http://USER:PASS@gate.quantumproxies.io:8000"
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"}
async def fetch(session, url):
timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=20)
async with session.get(url, proxy=PROXY, timeout=timeout) as resp:
return url, resp.status, await resp.text()
async def main(urls):
conn = aiohttp.TCPConnector(limit=20, limit_per_host=8)
async with aiohttp.ClientSession(connector=conn, headers=HEADERS) as session:
return await asyncio.gather(*(fetch(session, u) for u in urls))
print(asyncio.run(main(["https://httpbin.org/ip"] * 5)))
TCPConnector(limit=20, limit_per_host=8) limite le nombre total de connexions ouvertes et les connexions à un seul hôte — une première ligne de politesse qui empêche une cible d'absorber toute votre piscine.

Limiter la concurrence avec un sémaphore
Lancer un nombre illimité de requêtes simultanées est le moyen le plus rapide de brûler un pool de proxies et de déclencher des limites de taux. Un asyncio.Semaphore est le régulateur : il limite le nombre de requêtes en cours à la fois, quel que soit le nombre de tâches que vous mettez en file d'attente. Définissez une limite globale, et pour les tâches agressives une limite par domaine aussi.
sem = asyncio.Semaphore(15) # never more than 15 requests in flight
async def guarded_fetch(client, url):
async with sem:
return await fetch(client, url)
async def main(urls):
async with httpx.AsyncClient(proxy=PROXY) as client:
return await asyncio.gather(*(guarded_fetch(client, u) for u in urls))
Le bon nombre dépend de la tolérance de la cible et de la taille de votre pool, pas de la vitesse à laquelle votre machine peut aller. Commencez prudemment (10–20), surveillez votre taux de blocage, et augmentez-le seulement tant que le succès reste élevé. Sur une seule IP collante, gardez-le à un chiffre.
Délais d'attente : modéliser chaque phase
Une requête proxy échoue à plus d'endroits qu'une directe — la résolution DNS, la connexion au proxy, la configuration du tunnel, la connexion à la cible, l'attente des en-têtes, et la lecture du corps sont tous des blocages distincts. Un délai d'attente global unique cache quelle phase a été suspendue. ClientTimeout(total=, connect=, sock_read=) de aiohttp et Timeout() de httpx vous permettent de les limiter séparément. La seule règle sans exception : ne jamais émettre une requête sans délai d'attente, sinon une sortie morte suspendra une coroutine indéfiniment et affamera silencieusement votre boucle d'événements.
Faire tourner les proxies sans casser les sessions
Il existe deux stratégies de rotation et choisir la mauvaise corrompt vos données. La rotation aléatoire par requête est parfaite pour les récupérations de pages sans état. Mais elle détruit tout flux qui dépend des cookies, de la connexion ou de la localisation, car la deuxième requête atterrit sur une IP différente de la première. La séparation nette : tourner à la limite de l'unité logique — une IP par segment de crawl ou par compte — et utiliser une passerelle rotative qui vous fournit automatiquement une nouvelle sortie pour que votre code ne gère jamais une liste.
# rotating gateway: one endpoint, new exit IP per request
ROT = "http://USER:PASS@rotating.quantumproxies.io:8000"
# sticky session: same IP for a multi-step flow, tag the session id
STICKY = "http://USER-session-a1b2:PASS@gate.quantumproxies.io:8000"
async def crawl_segment(urls):
async with httpx.AsyncClient(proxy=ROT) as client: # rotates per call
return await asyncio.gather(*(fetch(client, u) for u in urls))
Une passerelle résidentielle rotative est le choix pragmatique par défaut pour le scraping à haute concurrence : rotation par requête sur plus de 90 millions d'IPs dans plus de 200 pays, avec des sessions collantes lorsqu'un panier ou une connexion nécessite la même sortie pendant quelques minutes. Si vous pesez le résidentiel contre l'ISP ou le datacenter pour le travail, notre guide sur quel type de proxy utiliser expose les compromis.
Obtenez une passerelle résidentielle rotative
Réessais, backoff et jitter
Les sorties mortes et les blocages transitoires sont normaux à grande échelle, pas exceptionnels. Mais les réessais naïfs aggravent les choses : lorsque 50 tâches asynchrones échouent au même instant et se réessaient immédiatement, vous déclenchez une rafale synchronisée qui martèle la cible plus fort que l'exécution originale. Ajoutez un backoff exponentiel plus un jitter aléatoire pour que les réessais se répartissent, limitez les tentatives, et faites tourner l'IP en cas d'échec plutôt que de réutiliser celle brûlée.
import random
from httpx import HTTPError
async def robust_fetch(client, url, tries=3):
for attempt in range(tries):
try:
r = await client.get(url, timeout=httpx.Timeout(20.0))
if r.status_code < 400 and looks_real(r.text):
return r
except HTTPError:
pass
# exponential backoff + jitter before the next attempt
await asyncio.sleep((2 ** attempt) + random.uniform(0, 1))
return None

Un 200 n'est pas un succès
Le bug de scraping asynchrone le plus subtil est de traiter un HTTP 200 comme terminé. Les systèmes anti-bot renvoient 200 avec une page CAPTCHA, un avis d'accès refusé, un ensemble de résultats vide ou un défi JS — donc un proxy qui marque "succès" uniquement sur le code de statut vous nourrit discrètement des pages bloquées. Validez le contenu : vérifiez un élément connu, une longueur minimale, ou l'absence de marqueurs de défi avant de faire confiance à une réponse. C'est la porte looks_real() dans la boucle de réessai ci-dessus.
Quand arrêter de rouler la pile à la main
Le modèle ci-dessus — client asynchrone, sémaphore, délais d'attente, rotation, validation de contenu — gère la plupart des cibles proprement. Mais une fois qu'un site ajoute Cloudflare, l'empreinte TLS ou un rendu client lourd, le HTTP asynchrone brut commence à échouer quel que soit votre proxy, car une poignée de main TLS Python ne ressemble en rien à celle de Chrome. À cette limite, une Scraper API qui porte une véritable empreinte de navigateur, fait tourner les IPs et rend le JavaScript à la demande est moins de code et un taux de succès plus élevé que de tout maintenir à la main. Notre article sur headless vs coût HTTP couvre où cette escalade en vaut la peine.
Questions fréquemment posées
Comment utiliser un proxy avec aiohttp ?
Passez l'URL du proxy par requête : session.get(url, proxy="http://user:pass@host:port"). Contrairement à requests, aiohttp ne prend pas de dictionnaire de proxies sur la session. Remplacez toujours le User-Agent par défaut (Python/3.x aiohttp/3.x est un signal évident de bot) et définissez un ClientTimeout pour qu'une sortie morte ne puisse pas suspendre la coroutine.
httpx ou aiohttp est-il meilleur pour le scraping asynchrone ?
Optez pour httpx par défaut : un client fonctionne en synchrone et asynchrone, HTTP/2 est intégré, et le proxy est un seul argument. Choisissez aiohttp lorsque vous voulez un contrôle maximal de la concurrence — les limites explicites de la piscine de connexions et les proxies par requête conviennent aux crawls larges et rapides. Les deux sont bien ; la stratégie de proxy compte plus que la bibliothèque.
Combien de requêtes simultanées devrais-je exécuter ?
Pas autant que votre machine le permet — autant que la cible et votre pool le tolèrent. Commencez avec un sémaphore de 10–20 en cours, surveillez le taux de blocage et d'erreur, et ne l'augmentez que tant que le succès reste élevé. Sur une seule IP collante, restez à un chiffre. Une rotation plus large des IPs vous permet d'exécuter une concurrence totale plus élevée en toute sécurité.
Pourquoi mon scraper asynchrone est-il bloqué alors que le synchrone ne l'était pas ?
Parce que la concurrence concentre le signal : de nombreuses requêtes simultanées depuis une IP est un modèle classique de bot. Répartissez la charge sur un pool rotatif, régulez avec un sémaphore, ajoutez un backoff avec jitter sur les réessais, et validez le contenu de la réponse — un 200 peut encore être une page de défi. La vitesse sans rotation est ce qui vous a bloqué.
C'est le modèle asynchrone complet : choisissez un client, définissez le proxy de la bonne manière pour lui, limitez la concurrence avec un sémaphore, limitez chaque phase de délai d'attente, tournez à la limite logique, et ne faites jamais confiance à un simple 200. Mettez d'abord en place la couche de proxy et la plupart de la liste de blocage disparaît avant que vous ne l'atteigniez.