๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Guides

Rotating Proxy Not Rotating? Fix Same-IP Responses

Rotating proxy same IP every request? Your client reuses one CONNECT tunnel or a sticky setting survived. Fix it in requests, httpx, Node, Playwright.

S SparkProxy 3 17 min read
Share

A rotating proxy same IP every request problem is rarely the gateway's fault. In most cases your HTTP client opened one connection to the proxy, kept it alive, and sent every request down it, or a sticky setting from an old example is still in your config. This guide gives a cheap-first diagnosis order, a way to count the tunnels your code actually opens, and the specific change that forces a fresh connection in Python requests, httpx, Node.js and Playwright.

Same Exit IP on Every Request: What You'll See

You point a scraper at the rotating port, print the exit address on every call, and get this:

203.0.113.47
203.0.113.47
203.0.113.47
203.0.113.47
203.0.113.47

Variations of the same complaint:

  • A quick curl test from the terminal shows a new IP each time, but the Python script shows one IP for the whole run.
  • Exits change between runs of the program, never within a run.
  • Rotation looks fine for http:// test URLs and stops for https:// targets.
  • A Node.js crawler shows two addresses taking turns, which is even more confusing than one.
  • In Playwright, every page in a context comes from one IP, and a new context brings a new one.

All five patterns point at the same layer: how your client manages connections. The gateway can only hand out a new exit when it gets a new decision point, and your code may never be giving it one.

One Tunnel, One Exit: Why Reuse Pins Your IP

For an HTTPS URL, your client doesn't send the request to the proxy in readable form. It sends CONNECT target:443, the proxy opens a TCP connection to the target from some exit address, and from then on the proxy is a pipe. RFC 9110, section 9.3.6, describes exactly that: after the tunnel is established the proxy will "restrict its behavior to blind forwarding of data, in both directions, until the tunnel is closed."

Blind forwarding is the whole story. Inside the tunnel, TLS encrypts your requests, so the gateway can't see where one request ends and the next begins. It picked an exit when the tunnel opened, and it can't swap that exit for request number two without breaking the TLS session. So the exit lives exactly as long as the tunnel.

Your client, meanwhile, wants to keep tunnels open. HTTP/1.1 connections are persistent by default, and pooled clients return each finished connection to a pool for the next request to the same host. The requests documentation says it plainly: "keep-alive is 100% automatic within a session". Result: rotation per connection, reuse per request, one IP.

Plain http:// is different. There the client sends each request to the proxy with the full URL in it, so a gateway can see every request and can choose a new exit for each one even on a kept-alive connection. That's why a quick test against an http:// echo page can look healthy while your HTTPS scraping is pinned. If you want the full background on pooling, idle timeouts and the throughput cost of fresh handshakes, our piece on TCP keepalive and connection pooling for proxies covers it; this article only deals with the rotation side effect.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

A Five-Step Triage, Cheapest First

#CheckCostA positive result means
1Compare the "rotating" IP with your own public IP2 requestsYour traffic isn't going through the proxy at all
2Read the port and username in your configZero requestsYou're on a sticky endpoint or a fixed session token
3Run separate `curl` processes against the rotating portAbout 5 requestsIf these rotate, the gateway is fine and your client is pinning
4Count tunnels per request in your clientAbout 5 requestsConfirms keep-alive reuse and tests your fix
5Look for causes outside the proxyVariesCaching, a small filtered pool, or a probe that lies

Stop at the first positive. Most people stop at 2 or 4.

Is Traffic Even Going Through the Proxy?

One IP on every request is also exactly what you see when no proxy is applied, because then you're looking at your own server's address. Compare:

curl -s https://api.ipify.org; echo
curl -s -x http://USER:PASS@gateway.sparkproxy.io:11000 https://api.ipify.org; echo

If your script prints the first address, the proxy setting is being dropped. Common ways that happens:

  • httpx 0.28 removed proxies=. We checked against httpx 0.28.1 on 2026-10-30: passing it raises TypeError: Client.__init__() got an unexpected keyword argument 'proxies'. The changelog for 0.28.0 says "The deprecated proxies argument has now been removed." People who "fix" that error by deleting the argument end up with no proxy. Use proxy= instead.
  • requests with only an http key. proxies={"http": PROXY} leaves every https:// URL going direct. Set both keys.
  • Node's built-in fetch and environment variables. It doesn't read HTTPS_PROXY unless proxy support is switched on; the Node CLI docs list NODE_USE_ENV_PROXY=1 for that.
  • A NO_PROXY match. requests and httpx both honour it by default, so a broad value set for internal services can catch your target.

Sticky Leftovers in the Port or Username

The second cheap check is to read your own config. On SparkProxy, rotation mode is chosen by port on gateway.sparkproxy.io:

PortModeWhat you should expect
11000Random Rotation, HTTP/HTTPSA new exit per new connection
11002Sticky SessionsThe same exit held for up to 5 minutes
13000SOCKS5Rotating, over SOCKS5

Copying a snippet from a sticky example is the usual way 11002 ends up in a crawler that's meant to rotate. If your output shows one IP for roughly five minutes and then a new one, you're on the sticky port and it's doing its job. The proxy rotation interval guide explains how to pick between the two modes; here, just make sure the port matches the intent.

Usernames carry the same risk on gateways that encode a session in them, typically as a token appended to the account name. A token hard-coded in a config file means every request asks for the same session, which is sticky behaviour by design. Strip it for rotation, or generate a new token per logical task when you want controlled stickiness. Our explainer on what a sticky session proxy is describes the model.

Then run the gateway on its own, outside your code. Each curl process opens its own connection:

for i in 1 2 3 4 5; do
  curl -s -x http://USER:PASS@gateway.sparkproxy.io:11000 https://api.ipify.org; echo
done

If this loop rotates and your scraper doesn't, the gateway is cleared. Note that passing several URLs to a single curl command reuses the connection: in our lab run below, curl 8.15.0 sent three URLs from one command through one tunnel and got one exit, while three separate commands got three.

Counting Tunnels per Client: Our Lab Results

We wanted numbers rather than folklore, so on 2026-10-30 we built a small lab on one Windows Server 2022 machine. A local HTTPS server echoes the source address it sees. In front of it, a local CONNECT proxy imitates a per-connection rotating gateway: every new tunnel dials out from a different loopback address (127.0.0.3, 127.0.0.4, and so on) and logs one line. We sent five HTTPS requests per configuration. Versions: Python 3.14.2, requests 2.32.5 with urllib3 2.6.3, httpx 0.28.1, Node.js 24.22.0, undici 8.12.3, Playwright 1.61.1 with its bundled Chromium.

Client configurationDistinct exits over 5 requests
`requests.Session()`, defaults1
`requests.Session()` + `Connection: close` header5
`requests.get()` per call, no Session5
`httpx.Client()`, defaults1
`httpx.Client()` with `max_keepalive_connections=0`5
`httpx.Client()` + `Connection: close` header5
undici `fetch`, one shared `ProxyAgent`2, alternating
undici `fetch`, new `ProxyAgent` per request5
undici `fetch`, shared agent + `connection: close`5 new after the 2 pooled ones
Playwright, one context, 5 ร— `page.goto`1
Playwright, new context per request5
`curl` 8.15.0, 3 URLs in one command1 (of 3)

This is a lab measurement against a proxy that rotates per tunnel, not a test of any commercial gateway. It shows what your client does, which is the part you control. The undici result is worth a second look: its pool kept two connections open and alternated between them, so a log would show two IPs taking turns and look almost like rotation.

You can repeat the measurement against the real gateway with no lab at all. Compare one pooled client with fresh connections:

import requests

PROXY = "http://USER:PASS@gateway.sparkproxy.io:11000"
P = {"http": PROXY, "https": PROXY}
PROBE = "https://api.ipify.org"

def exits(fetch, n=5):
    return [fetch().text.strip() for _ in range(n)]

with requests.Session() as s:
    s.proxies.update(P)
    pooled = exits(lambda: s.get(PROBE, timeout=20))

with requests.Session() as s:
    s.proxies.update(P)
    closed = exits(lambda: s.get(PROBE, headers={"Connection": "close"}, timeout=20))

print("pooled :", len(set(pooled)), pooled)
print("closed :", len(set(closed)), closed)

One distinct value on the first line and several on the second is the keep-alive pin, confirmed on your own traffic.

Forcing a New Connection in Python requests and httpx

There are three ways to get a fresh tunnel, and which one is right depends on whether you need anything else from the pooled client.

Option A: send Connection: close. You keep one Session (shared headers, retries, adapters) and the server closes the connection after each response, so the next request dials again. It's the smallest change:

import requests

ROTATE = "http://USER:PASS@gateway.sparkproxy.io:11000"

s = requests.Session()
s.proxies.update({"http": ROTATE, "https": ROTATE})
s.headers["Connection"] = "close"          # one tunnel per request, one exit per request

for url in ("https://www.sparkproxy.io/blog/", "https://www.sparkproxy.io/docs"):
    r = s.get(url, timeout=30)
    print(r.status_code, url)

Watch the cookie jar. A Session keeps cookies across requests, so with a new IP per request the target now sees one cookie travelling between many addresses. For independent page fetches, clear it (s.cookies.clear()) or skip the Session.

Option B: no Session. requests.get() builds and discards a session per call, which our lab showed opens a new tunnel each time. Fine for scripts, wasteful for big crawls because you lose adapter configuration.

Option C: a Session per identity. When you want a few requests to share an IP (a listing page and its detail pages, say), create a Session for that unit of work and close it afterwards. The pool pins the exit for the life of that Session, which you're now using on purpose.

httpx has the same three options plus a pool setting that disables reuse outright:

import httpx

ROTATE = "http://USER:PASS@gateway.sparkproxy.io:11000"

# No idle connections are kept, so every request opens a new CONNECT tunnel.
no_reuse = httpx.Limits(max_keepalive_connections=0, max_connections=50)

with httpx.Client(proxy=ROTATE, limits=no_reuse, timeout=30) as client:
    for _ in range(5):
        print(client.get("https://api.ipify.org").text)

The httpx docs give the defaults as 20 keep-alive connections and a keepalive_expiry of 5 seconds. That expiry explains a pattern people report as "rotation is random": a crawler that sleeps longer than 5 seconds between requests gets a new tunnel each time, and one that runs faster doesn't. Same code, different exits, purely down to timing. httpx.AsyncClient takes the same proxy= and limits= arguments.

Forcing a New Connection in Node.js and Playwright

With undici, the pool lives in the dispatcher. A shared ProxyAgent reuses its connections; a new agent per request starts clean. Close the agent afterwards or you'll leak sockets:

import { fetch, ProxyAgent } from 'undici';

const PROXY = 'http://USER:PASS@gateway.sparkproxy.io:11000';

async function fetchFreshExit(url) {
  const agent = new ProxyAgent(PROXY);          // its own pool, so a new tunnel
  try {
    const res = await fetch(url, { dispatcher: agent });
    return await res.text();
  } finally {
    await agent.close();
  }
}

for (let i = 0; i < 5; i++) {
  console.log(await fetchFreshExit('https://api.ipify.org'));
}

If you'd rather keep one agent, our lab showed that sending a connection: close header through undici's fetch also produced a new tunnel per request once the two already pooled connections were used up.

Playwright pins harder, because Chromium keeps connections per browser context and a single page load pulls many resources over them. Navigating one page five times gave one exit in our lab. Give each unit of work its own context with its own proxy setting:

const { chromium } = require('playwright');

const PROXY = {
  server: 'http://gateway.sparkproxy.io:11000',
  username: 'USER',
  password: 'PASS',
};

(async () => {
  const browser = await chromium.launch();
  for (let i = 0; i < 5; i++) {
    const context = await browser.newContext({ proxy: PROXY });   // fresh connections
    const page = await context.newPage();
    await page.goto('https://api.ipify.org');
    console.log(await page.innerText('body'));
    await context.close();                                        // drop the tunnel
  }
  await browser.close();
})();

Don't try to force per-request rotation inside one page. A rendered page fetches its HTML, scripts, styles and XHR calls together, and spreading them across exits makes the session look stranger, not more natural. One exit per page load, a new context for the next page, is the sensible granularity. Our walkthrough on web scraping with Playwright and proxies covers the rest of the setup. One constraint from the SparkProxy docs applies if you switch to port 13000: Playwright "will connect to a SOCKSv5 proxy but silently ignores username and password", so use IP whitelist authentication with SOCKS5 in Playwright.

Causes No Proxy Setting Will Fix

Some same-IP reports survive every fix above, because the repeated address isn't coming from the proxy layer.

What you seeReal causeCan a proxy change fix it?
The probe page shows one IP while traffic logs show manyThe IP page is cached by your client, a CDN or a service workerNo. Add a cache-busting query or use a non-cached echo
Same IP inside a rendered page despite new contextsOne context reused by your framework's page poolNo. Fix context lifetime in your code
A handful of IPs repeating over hundreds of requestsA narrow country or city filter leaves a small slice of the poolPartly. Widen the location filter or accept repeats
Your own IP keeps appearingProxy not applied (triage step 1) or a `NO_PROXY` matchNo. It's client configuration
The target says "same visitor" though exits differIt's tracking a cookie, account or fingerprint, not your IPNo. Rotate the identity, not just the address

The last row is the one people misread most often. Rotation changes one attribute of your traffic. If the site's complaint is about something else, more rotation won't make a difference, and the general concepts behind that are in what proxy rotation is and how it works.

Repeats in a random draw are also normal. Random Rotation picks from a pool; it doesn't promise to avoid an address you had a few requests ago, and the smaller the slice you filter to, the more repeats you'll see.

Keeping Rotation Honest in Production

A fix that worked on Monday can regress when someone wraps your fetch function in a shared client "for performance". Make rotation a measured property. Sample the exit every N requests and alert when diversity collapses:

import collections
import logging

import requests

ROTATE = "http://USER:PASS@gateway.sparkproxy.io:11000"
PROBE = "https://api.ipify.org"

class RotationMonitor:
    """Samples the exit IP every `every` requests and warns when one IP dominates."""

    def __init__(self, every=50, window=20, max_share=0.5):
        self.every, self.max_share = every, max_share
        self.seen = collections.deque(maxlen=window)
        self.count = 0

    def tick(self, session):
        self.count += 1
        if self.count % self.every:
            return
        ip = session.get(PROBE, timeout=20).text.strip()
        self.seen.append(ip)
        top, hits = collections.Counter(self.seen).most_common(1)[0]
        if len(self.seen) >= 5 and hits / len(self.seen) > self.max_share:
            logging.warning("rotation collapsed: %s in %d of last %d samples", top, hits, len(self.seen))

monitor = RotationMonitor()
s = requests.Session()
s.proxies.update({"http": ROTATE, "https": ROTATE})
s.headers["Connection"] = "close"

Call monitor.tick(s) after each real request. With the Connection: close header in place the warning stays quiet; remove that header and it fires on the fifth sample. The probe costs one request per 50, which is negligible.

If you'd rather not manage connections at all, the SparkProxy Scraping API takes the proxy choice out of your client. You call https://scrape.sparkproxy.io/api/v1 with an X-API-Key header, and the exit for each job is picked on SparkProxy's side from its rotating pool, so keeping your connection to the API alive doesn't pin the target-facing IP:

import requests

API = "https://scrape.sparkproxy.io/api/v1"

with requests.Session() as api:                  # reuse is fine here: the API picks the exit
    api.headers["X-API-Key"] = "YOUR_API_KEY"
    for page in range(1, 4):
        r = api.get(API, params={
            "url": f"https://www.sparkproxy.io/blog/?page={page}",
            "render_js": "false",                # defaults to true (5 credits); false = 1 credit
            "proxy_type": "premium",
        }, timeout=90)
        print(page, r.status_code, len(r.text))

The body comes back as the page HTML. Add json_response=true only if you want the envelope with job_id, status_code and credits_used, and remember its body field is base64. New accounts get 1,000 free credits; country_code adds 5 credits per request.

For proxy plans, pricing is "Starting from $75/month" (USD, read from sparkproxy.io on 2026-10-30), sold as packs with unlimited bandwidth, so forcing fresh connections costs handshake time but not extra billed traffic. The network is 1M+ premium IPs in 80+ countries on 1 Gbps ports, with HTTP, HTTPS and SOCKS5, user:pass or IP whitelist authentication, and a 24-hour free trial with no credit card. Longer terms take 5% off for 3 months, 10% for 6 and 15% for 12.

Frequently asked questions

FAQ

Usually because your HTTP client is reusing one keep-alive connection, and for HTTPS the gateway picks the exit once per CONNECT tunnel. The other common cause is a sticky setting, such as the Sticky Sessions port or a fixed session token in the username, left over from an example.

It can. A requests.Session pools connections, so through a per-connection rotating gateway it keeps sending HTTPS requests down the same tunnel. In our lab, five Session requests produced one exit; adding a Connection: close header produced five.

Create the client with httpx.Limits(max_keepalive_connections=0) so no idle connection is kept, or send a Connection: close header. Both gave a new tunnel per request in our test with httpx 0.28.1.

Each curl command is a new process with a new connection, so each one gets a fresh exit. Your script's client keeps its connection open between requests. Passing several URLs to one curl command reuses the connection too.

No. That's the behaviour of a 5-minute sticky mode. On SparkProxy, Sticky Sessions run on port 11002; switch to port 11000 for Random Rotation.

Not sensibly. Chromium reuses connections inside a browser context, and a single page load should come from one exit anyway. Open a new context, with its own proxy setting, for each page or task you want on a fresh IP.

Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and runs SparkProxy's datacenter proxy network and the SparkProxy Scraping API. We write troubleshooting guides from the gateway side of the connection, measure client behaviour ourselves where we can, and say clearly when a symptom is something no proxy setting can change.

Keep reading

Related articles