Using SOCKS5 Proxies with aiohttp

aiohttp refuses a socks5:// URL outright. Here is the connector that fixes it, and the flag that decides where your DNS lookups happen.

If you have tried to hand aiohttp a SOCKS5 proxy the way you would hand it an HTTP one, you have already met the problem:

async with session.get(url, proxy="socks5://user:pass@host:1080") as r:
    ...
# ValueError: Only http proxies are supported

That error is not a bug and not a version problem. aiohttp's proxy= argument implements exactly one thing, the HTTP CONNECT tunnel, and SOCKS5 is a different protocol that happens to serve a similar purpose. Nothing in aiohttp speaks it. This guide covers the connector that does, the one option that decides whether your DNS queries leak, and the lifecycle mistakes that make an async client leak sockets instead.

Why the proxy argument cannot help you

An HTTP proxy is reached over HTTP. Your client opens a TCP connection to the proxy and sends CONNECT target.com:443, the proxy dials the target, and from that point it relays bytes without understanding them. The negotiation is text, and it lives in the same protocol aiohttp already implements.

SOCKS5 negotiates in binary before any of that. The client greets the proxy, the proxy names the authentication methods it will accept, the client authenticates, and only then does the client state the destination and receive a reply. It is a small protocol, but it is a protocol, and implementing it is not a matter of formatting a different request line. aiohttp declines rather than pretending, which is the right call and also why the fix has to come from outside.

The fix is aiohttp-socks, which supplies a connector. A connector is aiohttp's socket factory: it is the object responsible for producing a connected transport for a given host. Swapping it changes how every connection in that session is established, which is exactly the layer where SOCKS5 belongs.

pip install aiohttp-socks
import aiohttp
from aiohttp_socks import ProxyConnector

async def fetch(url):
    connector = ProxyConnector.from_url("socks5://USERNAME:PASSWORD@HOST:PORT")
    async with aiohttp.ClientSession(connector=connector) as session:
        async with session.get(url) as response:
            return await response.text()

Note what is absent: there is no proxy= on the request any more. The proxy is a property of the session's connector, so every request made through that session goes through it, and individual calls need no special handling.

The flag that decides where DNS happens

This is the detail that matters most and gets noticed least.

socks5:// and socks5h:// are not stylistic variants. With socks5://, your machine resolves the hostname and sends the proxy an IP address. With socks5h://, the hostname travels to the proxy and the proxy resolves it. In aiohttp-socks the same choice is available directly:

from aiohttp_socks import ProxyConnector, ProxyType

connector = ProxyConnector(
    proxy_type=ProxyType.SOCKS5,
    host="HOST",
    port=PORT,
    username="USERNAME",
    password="PASSWORD",
    rdns=True,   # resolve at the proxy, not here
)

rdns=True is the equivalent of socks5h://, and it is almost always what you want. Three reasons, in the order they tend to bite:

The first is disclosure. With local resolution, every hostname you visit is a query to whichever resolver your machine uses. The traffic is proxied and the lookups are not, so the list of destinations is readable by your network even though the requests are not.

The second is correctness. If the name resolves differently from where the proxy sits, and for anything geographically balanced it usually does, local resolution sends the proxy an address chosen for your location. You then reach a node picked for the wrong region while believing you tested the right one.

The third is simple breakage. A host that only resolves inside the network the proxy is in cannot be resolved by you at all.

The same distinction is covered protocol-first, with curl and Node examples, in the SOCKS5 setup guide.

Connector lifecycle, and the leak that follows from getting it wrong

A connector is not reusable across sessions once that session has closed it, and this catches people who build a session per request:

# Wrong: a fresh connector and a fresh session for every call.
async def fetch(url):
    connector = ProxyConnector.from_url(PROXY_URL)
    async with aiohttp.ClientSession(connector=connector) as session:
        ...

Written that way, every request performs a full SOCKS5 handshake and a fresh TCP connection, then discards both. Against a proxy that authenticates per connection, this multiplies your connection count by your request count. On a metered plan it also adds handshake bytes to every single call, and on a per-IP concurrency limit it is the fastest way to hit a ceiling that your actual concurrency is nowhere near.

Build the session once and pass it down:

import aiohttp
from aiohttp_socks import ProxyConnector

class Client:
    def __init__(self, proxy_url: str):
        self._proxy_url = proxy_url
        self._session: aiohttp.ClientSession | None = None

    async def __aenter__(self):
        connector = ProxyConnector.from_url(self._proxy_url, limit=20, ttl_dns_cache=300)
        self._session = aiohttp.ClientSession(
            connector=connector,
            timeout=aiohttp.ClientTimeout(total=45, connect=15, sock_read=30),
        )
        return self

    async def __aexit__(self, *exc):
        await self._session.close()

    async def get(self, url: str) -> str:
        async with self._session.get(url) as r:
            r.raise_for_status()
            return await r.text()

Two things in there are worth stating plainly.

limit=20 caps concurrent connections through this connector. Without a cap, asyncio.gather over a few thousand URLs will attempt a few thousand simultaneous connections, and the failure that follows looks like the proxy refusing you when it is really your own event loop opening more sockets than anything downstream agreed to.

The timeout is split rather than global. A single total timeout cannot distinguish a proxy that never completes its handshake from a target that is simply slow to send a large body. connect covers reaching the proxy and negotiating; sock_read covers silence between chunks once data is flowing. Through a proxy this distinction is the difference between a useful log line and a mystery, because there are two hops that can stall and only one of them is yours.

Rotating without rebuilding everything

If you have a pool of exits, the natural instinct is one session per proxy. That is right, and it is cheaper than it sounds, because a session is mostly its connector:

import itertools

class Pool:
    def __init__(self, proxy_urls: list[str]):
        self._sessions = [
            aiohttp.ClientSession(connector=ProxyConnector.from_url(u, limit=10))
            for u in proxy_urls
        ]
        self._cycle = itertools.cycle(self._sessions)

    def next(self) -> aiohttp.ClientSession:
        return next(self._cycle)

    async def close(self):
        for s in self._sessions:
            await s.close()

Round-robin is the correct default when the work is independent. It is the wrong default the moment a target sets a cookie you need to keep, because rotating the address underneath a session that the target believes is one visitor is a stronger signal than the address ever was. When state matters, pin work to one session for the life of that state. The reasoning is set out in sticky versus rotating sessions.

Errors, and what each one is telling you

ProxyConnectionError means the SOCKS5 endpoint did not accept a TCP connection. The proxy address or port is wrong, or something between you and it is filtering. This happens before authentication, so it says nothing about your credentials.

ProxyError after connecting is usually authentication. SOCKS5 has no equivalent of the HTTP 407 status, so there is no tidy code to match on; the negotiation simply fails. If the same credentials work over HTTP and fail over SOCKS5, check that you are pointing at the SOCKS port rather than the HTTP one, since they are different listeners.

ProxyTimeoutError means the handshake began and did not finish. Treat this as a bad exit rather than a bad request, and try a different one before retrying the same one.

A 407 from the target rather than the proxy means your request reached somewhere unexpected. That case, and the several ways credentials get mangled before they leave your process, are covered in fixing 407 Proxy Authentication Required.

Confirm it is actually working

The only check worth trusting is the address the destination reports, because every other signal can be produced by a misconfiguration that quietly bypassed the proxy:

import asyncio, aiohttp
from aiohttp_socks import ProxyConnector

async def main():
    connector = ProxyConnector.from_url("socks5://USERNAME:PASSWORD@HOST:PORT", rdns=True)
    async with aiohttp.ClientSession(connector=connector) as s:
        async with s.get("https://api.ipify.org?format=json") as r:
            print(await r.json())

asyncio.run(main())

If the address printed is your own, the connector was not applied. The most common cause is a session created somewhere else in the codebase without one, which is why a single client object is worth the small amount of structure it costs.

Node4 datacenter and residential proxies both accept SOCKS5, on a separate port from HTTP, with the same username and password. The connection string builder will assemble the exact URL for a given proxy, and the ports for each product are listed on our pricing page alongside what each one includes.