One Connector Per Exit: Organizing aiohttp Connection Pools Around Proxy Sessions
Most async scraping tutorials teach you aiohttp with a single global ClientSession and a single TCPConnector, then hand-wave the proxy part: "pass proxy= on each request and rotate away." That works fine for a hundred requests against a lenient target. It quietly falls apart at scale, and the reason is almost never documented: your connector topology is your identity topology, and when the two disagree, you pay for it in performance, in memory, and occasionally in blocked exits.
This article is about making the connection-pool layer of an async crawler proxy-aware. It's the piece I wish someone had explained to me when my first "just add proxy=" crawler started leaking sockets and recycling TLS handshakes at exactly the wrong moments.
What actually happens with one global connector
Inside aiohttp, TCPConnector maintains a keep-alive pool keyed by connection endpoint. When you send a request through a proxy, the underlying TCP connection goes to the proxy, not to the target — so each distinct proxy exit in your rotation becomes its own pool entry. Now consider the lifecycle mismatch:
- You rotate exits every request. Each new exit creates a new pool entry, performs a TCP handshake to the gateway, a TLS handshake, and forwards your request.
- The old entries sit in the pool waiting to be reused. With rotation, they never are — but they're held open until the keep-alive timeout expires, because aiohttp doesn't know your rotation policy.
- If you're using sticky sessions (the same exit for N consecutive requests), the pool entry is your session's transport. Recreating it means re-handshaking, which means your next request through that exit looks "colder" to the target than the previous one — new TCP source behavior, fresh handshake timing, a small but real fingerprint discontinuity.
The deeper issue is conceptual: a connection pool assumes connections are interchangeable. Through rotating proxies, they are not — each pooled connection is bound to a specific exit identity, and its lifetime should match that identity's session window, not a global keep-alive timer.
The fix is to stop treating the connector as global infrastructure and instead give each proxy session its own connector, with its own session object, sized for exactly one identity.
The session-manager pattern
Here's a compact implementation. The idea: a ProxySessionManager mints one ClientSession (with its own TCPConnector, limit=1) per sticky exit, hands them out to workers keyed by session ID, and evicts them — closing the connector cleanly — when the session window expires.
import asyncio
import time
import aiohttp
PROXY_TEMPLATE = "http://user:pass@gateway.thordata.com:port/session-{}"
SESSION_TTL = 300 # seconds: how long we hold one exit identity
MANAGER_LIMIT = 50 # max simultaneously open proxy sessions
class ProxySessionManager:
"""One aiohttp ClientSession per sticky proxy exit, TTL-evicted."""
def __init__(self, ttl: int = SESSION_TTL, limit: int = MANAGER_LIMIT):
self.ttl = ttl
self.sem = asyncio.Semaphore(limit) # backpressure: cap open exits
self._sessions: dict[str, tuple[aiohttp.ClientSession, float]] = {}
self._lock = asyncio.Lock()
async def acquire(self, session_id: str) -> aiohttp.ClientSession:
await self.sem.acquire()
async with self._lock:
entry = self._sessions.get(session_id)
now = time.monotonic()
if entry is not None and now - entry[1] < self.ttl:
entry = (entry[0], now) # touch
self._sessions[session_id] = entry
return entry[0]
if entry is not None: # expired but still cached: close it
await entry[0].close()
connector = aiohttp.TCPConnector(limit=1, ttl_dns_cache=300)
session = aiohttp.ClientSession(
connector=connector,
proxy=PROXY_TEMPLATE.format(session_id),
timeout=aiohttp.ClientTimeout(total=30),
)
self._sessions[session_id] = (session, now)
return session
async def release(self, session_id: str):
"""Give back one slot; eviction happens lazily on next acquire."""
async with self._lock:
entry = self._sessions.get(session_id)
if entry is not None:
self.sem.release() # slot accounting, session stays cached
async def sweep(self):
"""Periodic task: close and drop expired sessions."""
while True:
await asyncio.sleep(30)
now = time.monotonic()
async with self._lock:
expired = [k for k, (_, ts) in self._sessions.items()
if now - ts >= self.ttl]
for k in expired:
session, _ = self._sessions.pop(k)
await session.close()
self.sem.release()
A few engineering details that matter and are easy to miss:
TCPConnector(limit=1) is deliberate. Each session represents one exit identity; letting it open parallel connections means concurrent handshakes from the same exit, which is a classic session-integrity smell. If a logical task needs concurrency through one exit, set limit to that task's known width — but make it a decision, not a default.
Backpressure at the session layer, not just the request layer. The semaphore caps how many exit identities are open at once. This couples your pool size to your proxy spend and your origin-visible footprint — two things you actually want bounded. A crawler with 200 concurrent workers and 50 open exits naturally queues per-exit, which is exactly the polite shape you want.
ttl_dns_cache prevents a subtle bleed. By default aiohttp caches DNS aggressively; when a session is long-lived but its exits change underneath (some gateways remap sticky IDs), stale DNS can pin you to a dead path. Keep the cache shorter than your session TTL.
And usage looks like this:
async def fetch(manager: ProxySessionManager, sid: str, url: str):
session = await manager.acquire(sid)
try:
async with session.get(url) as resp:
return sid, resp.status, await resp.text()
finally:
await manager.release(sid)
async def main():
manager = ProxySessionManager()
sweeper = asyncio.create_task(manager.sweep())
work = [("task-%03d" % i, "https://example.com/page/%d" % i)
for i in range(200)]
results = await asyncio.gather(
*(fetch(manager, sid, url) for sid, url in work),
return_exceptions=True,
)
sweeper.cancel()
Why this shape pays off
TLS coherence. When one exit identity maps to one connector, the connection's lifetime equals the identity's lifetime. You stop re-handshaking mid-session, and you stop keeping sockets to dead exits warm. On targets that fingerprint handshake timing per IP, this measurably reduces the "first-request-through-a-new-connection" anomaly rate.
Memory stays flat. The unbounded-pool failure mode of naive per-request rotation — thousands of half-idle keep-alive connections — disappears, because eviction is explicit and tied to the session clock rather than aiohttp's default keep-alive (75 seconds, far longer than a rotation interval needs and far shorter than a sticky window wants).
Observability becomes trivial. Per-session connectors mean per-session counters. Wrap acquire/release with error tallies and you get exit-level health for free — which exit IDs fail, which succeed, which deserve a longer window next run. That's the data you need to size sticky windows with evidence instead of folklore.
It composes with graceful degradation. When a session's requests start failing, you don't retry harder through it — you close() it, drop the ID, and mint a fresh one. Failure handling becomes identity lifecycle management, which is the correct mental model for proxy-based crawling: you're not repairing connections, you're rotating personas.
One caveat worth knowing: this pattern multiplies ClientSession objects, and aiohttp (correctly) warns if sessions are garbage-collected unclosed. The sweep task plus explicit close() paths are not optional hygiene — they're the difference between a crawler that runs for a week and one that exhausts file descriptors by Tuesday.
Connection pooling looks like a transport concern, something beneath the dignity of a scraping architecture discussion. Through rotating proxies it isn't: the pool is where your exit identities live, and the pool's lifetime policy is your session policy. Align them deliberately, and a whole class of "why does my async crawler degrade after an hour" bugs simply stops existing.
Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele
Top comments (0)