v1.10.90-0e025b8
Skip to main content
TutorialPythonCode

Python Async Proxy Rotation With httpx: A Production Pool

12 min read

By Hex Proxies Engineering Team

Async Proxy Rotation in Python With httpx

Python's httpx library is the right tool for async HTTP in 2026. It supports HTTP/2, connection pooling per proxy, and plays nicely with asyncio. Combined with a semaphore-guarded pool and a circuit breaker, you can push thousands of concurrent requests through rotating proxies without tripping detection or exhausting file descriptors.

This guide walks through a production pool with four properties that matter: (1) one client per proxy so HTTP/2 connections are reused, (2) a per-proxy semaphore to cap concurrency, (3) exponential backoff with jitter on retry, and (4) a per-proxy circuit breaker that takes bad endpoints out of rotation for a cooldown window.

Why one client per proxy

The common mistake is to create a new httpx.AsyncClient for every request. Every new client opens a new TCP connection and, for HTTPS targets, a new TLS handshake. At 500 requests per second, TLS setup alone can cost more than the actual fetch. httpx keeps a connection pool per client, so the fix is simple: one client per proxy endpoint, reused for the lifetime of the pool.

The trade-off: if you have 100 proxies and 100 clients, you hold 100 TCP sockets idle during quiet periods. That is fine for servers but wasteful on laptops. Cap max_keepalive_connections in httpx.Limits to control the idle footprint.

Dependencies

# requirements.txt
httpx[http2]==0.27.2
tenacity==9.0.0

Proxy state and endpoints

Endpoints are immutable value objects. State (failures, circuit status, the client itself) lives on a separate mutable record. This separation makes it trivial to swap endpoints at runtime without tearing down state, and it keeps your configuration serializable.

import asyncio
import random
import time
from dataclasses import dataclass, field
from typing import Optional

import httpx


@dataclass(frozen=True)
class ProxyEndpoint:
    """Immutable proxy endpoint description."""
    url: str               # e.g. "http://user:pass@gate.hexproxies.com:7777"
    label: str             # human-readable name for logs
    max_concurrent: int = 25


@dataclass
class ProxyState:
    """Mutable runtime state for a single proxy endpoint."""
    endpoint: ProxyEndpoint
    client: httpx.AsyncClient
    semaphore: asyncio.Semaphore
    failures: int = 0
    successes: int = 0
    opened_at: float = field(default_factory=time.monotonic)
    circuit_open_until: float = 0.0

    def record_success(self) -> None:
        self.successes += 1
        self.failures = 0

    def record_failure(self) -> None:
        self.failures += 1

    def is_available(self) -> bool:
        return time.monotonic() >= self.circuit_open_until

    def trip_circuit(self, cooldown_s: float) -> None:
        self.circuit_open_until = time.monotonic() + cooldown_s

The pool

The pool picks a proxy with weighted random selection: proxies with fewer recent failures are picked more often. On a 5xx or 429 response the pool treats it as a failure and retries against a different proxy. After five consecutive failures the circuit opens for 30 seconds.

class ProxyPool:
    """Async proxy pool with per-proxy connection reuse and circuit breaking."""

    FAILURE_THRESHOLD = 5
    COOLDOWN_SECONDS = 30.0
    TIMEOUT = httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=5.0)
    LIMITS = httpx.Limits(max_connections=50, max_keepalive_connections=25)

    def __init__(self, endpoints: list[ProxyEndpoint]) -> None:
        if not endpoints:
            raise ValueError("at least one proxy endpoint is required")
        self._states: list[ProxyState] = [
            ProxyState(
                endpoint=ep,
                client=httpx.AsyncClient(
                    proxy=ep.url,
                    timeout=self.TIMEOUT,
                    limits=self.LIMITS,
                    http2=True,
                    follow_redirects=True,
                ),
                semaphore=asyncio.Semaphore(ep.max_concurrent),
            )
            for ep in endpoints
        ]

    async def aclose(self) -> None:
        await asyncio.gather(
            *(s.client.aclose() for s in self._states),
            return_exceptions=True,
        )

    def _pick(self) -> Optional[ProxyState]:
        candidates = [s for s in self._states if s.is_available()]
        if not candidates:
            return None
        # Weighted pick: prefer proxies with fewer recent failures.
        weights = [1.0 / (1 + s.failures) for s in candidates]
        return random.choices(candidates, weights=weights, k=1)[0]

    async def request(
        self,
        method: str,
        url: str,
        *,
        max_attempts: int = 4,
        **kwargs,
    ) -> httpx.Response:
        last_exc: Optional[BaseException] = None
        for attempt in range(1, max_attempts + 1):
            state = self._pick()
            if state is None:
                # All proxies are in cooldown; wait and retry.
                await asyncio.sleep(1.0)
                continue

            async with state.semaphore:
                try:
                    response = await state.client.request(method, url, **kwargs)
                    if response.status_code >= 500 or response.status_code == 429:
                        raise httpx.HTTPStatusError(
                            f"retryable status {response.status_code}",
                            request=response.request,
                            response=response,
                        )
                    state.record_success()
                    return response
                except (httpx.HTTPError, httpx.HTTPStatusError) as exc:
                    state.record_failure()
                    last_exc = exc
                    if state.failures >= self.FAILURE_THRESHOLD:
                        state.trip_circuit(self.COOLDOWN_SECONDS)
                    # Exponential backoff with jitter.
                    delay = min(2 ** attempt, 10) + random.uniform(0, 0.5)
                    await asyncio.sleep(delay)

        assert last_exc is not None
        raise last_exc

Driving the pool

The public API is a single pool.request() call. Fan out with asyncio.gather to issue hundreds of concurrent requests; the per-proxy semaphore prevents any one endpoint from being hammered.

async def fetch_all(urls: list[str]) -> list[httpx.Response]:
    pool = ProxyPool([
        ProxyEndpoint(
            url="http://USER:PASS@gate.hexproxies.com:7777",
            label="hex-us-rotating",
            max_concurrent=30,
        ),
        ProxyEndpoint(
            url="http://USER:PASS@gate-eu.hexproxies.com:7777",
            label="hex-eu-rotating",
            max_concurrent=30,
        ),
    ])
    try:
        tasks = [pool.request("GET", u) for u in urls]
        return await asyncio.gather(*tasks, return_exceptions=True)
    finally:
        await pool.aclose()


if __name__ == "__main__":
    sample = [f"https://httpbin.org/ip?n={i}" for i in range(100)]
    results = asyncio.run(fetch_all(sample))
    ok = sum(1 for r in results if isinstance(r, httpx.Response) and r.status_code == 200)
    print(f"success: {ok}/{len(results)}")

When not to use this pattern

If your workload is below 10 requests per second, a single httpx.AsyncClient with a single upstream proxy is simpler and fast enough. The pool only earns its keep when you need to (a) distribute load across multiple gateway URLs, (b) tolerate endpoint failures without stopping, or (c) run the pool for hours without leaking file descriptors.

For browser-level stealth (headers, TLS fingerprinting, JA3), httpx alone is not sufficient. Pair it with a headless browser for targets that check TLS fingerprints. See our anti-bot detection guide for the full picture.

Tuning notes

max_concurrent per proxy: start at 25 for residential, 50 for ISP. Too high triggers per-IP rate limits; too low leaves throughput on the table. COOLDOWN_SECONDS: 30s works for transient target blocks; drop to 5s if you have many gateways and want faster recovery. http2=True: critical for multiplexing; most modern proxy gateways (including Hex) support it end-to-end.

Hex Proxies residential and ISP gateways support HTTP/2 and rotating session stickiness. Drop the gateway URL into ProxyEndpoint.url and the pool handles the rest. Pricing.