Ротация прокси в async Python: httpx и aiohttp (2026)

Опубликовано 12 июня 2026 г. · ≈10 мин чтения

Асинхронный скрапинг — это место, где настройки прокси тихо разваливаются. С requests вы меняете один IP на вызов и идёте дальше; с asyncio у вас вдруг 200 запросов в полёте, каждому нужен свой выход, своя привязка сессии и свой повтор, когда цель отдаёт 403. Ошибётесь — и вы либо забьёте один IP до бана, либо порвёте каждую сессию входа, меняя IP посреди потока.

В этом руководстве — паттерны, которые действительно держатся при конкурентности: ротация на каждый запрос в httpx и aiohttp, ограниченный пул воркеров, sticky-сессии для потоков с состоянием и повторы с учётом блокировок — с рабочим кодом.

Почему async меняет задачу прокси

Синхронный скрапер трогает один прокси за раз, поэтому «менять на каждом запросе» тривиально верно. Async ломает три предположения сразу:

httpx: ротация прокси на каждый запрос

httpx.AsyncClient привязывает один прокси на клиент, поэтому для ротации вы выбираете прокси на запрос и идёте через небольшой пул клиентов по ключу-выходу:

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

Переиспользование одного клиента на выход сохраняет HTTP/2 и пул соединений вместо нового рукопожатия на каждом запросе. Учтите: httpx>=0.28 переименовал proxies= в proxy= у клиента.

aiohttp: ротация на уровне запроса

aiohttp здесь проще — одна ClientSession принимает аргумент proxy= на каждый запрос, так что ротация идёт инлайн:

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 понимает HTTP-прокси нативно; для socks5h:// добавьте aiohttp-socks и передайте ProxyConnector.

Ограничьте конкурентность семафором

Неограниченный gather — самый быстрый способ получить бан по всем IP пула сразу. Ограничьте так, чтобы каждый выход нёс правдоподобное для человека число параллельных запросов:

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

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

Правило: держите конкурентность на уровне числа ваших отдельных выходов или ниже, чтобы не складывать много параллельных запросов на один резидентный IP.

Sticky-сессии для потоков с состоянием

Ротация — чтобы войти; sticky-сессии — чтобы остаться. Вход, корзина, всё, что привязано к cookie, должно держать один выход на всю последовательность. Закрепляйте прокси на задачу, а не на запрос:

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))))

Используйте провайдера со sticky резидентными сессиями, чтобы один выход держался на всё время жизни задачи, а не ротировался незаметно.

Повторы с учётом блокировок

При конкурентности нельзя доверять 200 — антибот-системы отдают 200 с телом-челленджем. Распознавайте блок и повторяйте одну упавшую задачу на свежем выходе:

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}")

Отпечаток всё ещё должен совпадать

Одно async не решает: httpx и aiohttp сидят на TLS-стеке Python, поэтому шлют JA3, который не производит ни один реальный браузер. Идеальный пул ротации на чистом резидентном IP всё равно опознаётся как автоматизация по рукопожатию. Сочетайте async-ротацию с имперсонацией TLS — см. Обход фингерпринтинга TLS с curl_cffi и проверьте JA3 и ASN выхода на check.jibaoproxy.com.

Чек-лист

Все IP-продукты · огромный пул узлов, доступных в любой момент

Зарегистрируйтесь сейчас и получите до 100% кэшбэка на пополнение

Новым пользователям — 5U при регистрации, бонус к первому пополнению. Акция ограничена по времени.