The IP is not changing on rotation: keep-alive is the cause, not the proxy
The most common complaint about a rotating proxy goes like this: “rotation is per request, yet the address is always the same.” In the overwhelming majority of cases the proxy is working correctly and the address stays put because of how your HTTP client reuses connections. It is a one-line fix, but the mechanism is worth understanding first — otherwise the fix looks like superstition.
When exactly the exit address is chosen
The key point: with per-request rotation the exit is chosen when the TCP connection is established — and for HTTPS, when the CONNECT tunnel is opened. Not when you write a request into that connection. After that the tunnel is open, and everything sent through it leaves from the same address.
The consequence: a client that keeps a connection pool reuses one exit for as long as that connection lives in the pool. From the outside this looks exactly like broken rotation, although neither side did anything wrong.
With HTTPS this is especially visible. The client sends the proxy a CONNECT command once, the proxy opens a tunnel to the target host, and from then on an encrypted stream runs inside that tunnel, which the proxy no longer touches. It physically cannot swap the exit address mid-tunnel — not because it will not, but because by that point the tunnel is established and bound to a specific exit.
requests: a Session reuses connections
A Session keeps a pool through HTTPAdapter, and in ordinary work that is its main advantage. To get a new exit you either stop reusing the connection or stop reusing the session.
import requests
proxies = {"http": PROXY, "https": PROXY}
# Option 1: ask for the connection to be closed after the response
with requests.Session() as s:
s.headers["Connection"] = "close"
for url in urls:
s.get(url, proxies=proxies)
# Option 2: a new session means a new tunnel and a new exit
for url in urls:
with requests.Session() as s:
s.get(url, proxies=proxies)httpx: empty the idle pool
In httpx the pool limits live in a Limits object. Setting max_keepalive_connections=0 means no idle connection is retained, so the next request opens a new tunnel. When a proxy is configured the limits apply to the proxy's connection pool — which is exactly where you want them.
import httpx
# max_keepalive_connections=0 -> nothing is kept in reserve
limits = httpx.Limits(max_keepalive_connections=0)
with httpx.Client(proxy=PROXY, limits=limits) as client:
for url in urls:
client.get(url)aiohttp: force_close and the reuse timeout
TCPConnector has a force_close parameter, documented as “close underlying sockets after connection releasing”; it defaults to False. Next to it sits keepalive_timeout at 15 seconds — how long a connection waits to be reused after being released. Those fifteen seconds are why an async scraper holds one address in bursts.
import aiohttp
# force_close=True: the socket closes as soon as the connection is released,
# so the next request opens a new tunnel and gets a new exit.
connector = aiohttp.TCPConnector(force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get(url, proxy=PROXY,
proxy_auth=aiohttp.BasicAuth(LOGIN, PASSWORD)) as r:
await r.text()What is not the cause
- Pool size: the address is not staying put because there are few of them, but because no new connection is being opened.
- The rotation setting on our side: it applies when a connection is established and cannot affect an already-open tunnel — by the design of the protocol.
- DNS caching: it decides which gateway you connect to, not which exit the gateway supplies.
- HTTP/2: here it makes things worse, not better — multiplexing exists precisely to keep one connection open.
Why this cannot be fixed on the proxy side
People sometimes ask us to “make the address change anyway”. No provider can: swapping the exit in the middle of an established TCP connection would mean tearing down the connection, losing the TLS session and aborting the request. What looks to you like one logical visit to a site is, to the network, a stateful connection — and the address is part of that state.
So the fix always lives on the client side: ask it to open a new connection. That is not a workaround or a hack — it is the only place where such a decision is made at all.
When there is nothing to fix
It is worth stopping to ask why you want a new address per request. If the job is a chain of steps inside one session — signing in, cookie-based pagination, a multi-step form — then a stable address helps you rather than hinders. That is what timed rotation is for: the address is held for a set interval deliberately, not as a side effect of a connection pool.
The difference matters: a pool gives you a stable address by accident and for an unpredictable stretch; a timer gives it to you on purpose and for a known one. If you need stability, it is safer to ask for it explicitly.
Per-request rotation or timed
Both modes are available on rotating plans and switch in the dashboard. IPv6 from 650 ₽ for 50 threads, IPv4 from 1375 ₽ for 100.
View pricingProxies for this job
Check it with our tools
Read next
- Per-request or timed rotation: which to chooseThe two modes solve different problems. Where per-request rotation breaks things, and how to work out the interval you need.
- How to use proxies in Python: requests, Scrapy and SeleniumWorking examples for the three most common cases, the difference between socks5 and socks5h, and why IP authentication is easier in a browser.
- One gateway instead of a proxy list: configuring software that expects a listScrapers measure scale by proxy count, and a rotating gateway is one line. Program by program: which mode to switch, where to duplicate the line, and where the honest answer is to buy a list.
