If you scrape the web at any real scale, a single proxy will not cut it. IPs get rate-limited, temporarily banned, or simply time out. The durable fix is a pool of proxies plus logic that rotates through them and retries failed requests on a fresh IP.
Here is how to build one in Python that does not fall over the first time a proxy misbehaves.
Why one proxy is never enough
A single IP hitting a site hundreds of times per minute looks nothing like a human. Target sites respond with 429 Too Many Requests, throw CAPTCHAs, or quietly start returning empty pages. Even a "good" residential IP has a bad day: the upstream node drops, the TCP connection hangs, DNS flakes.
So two things are non-negotiable in production:
- Rotation — spread requests across many IPs.
- Retry on a different IP — when a request fails, don't just retry the same dead proxy; pick a new one.
The naive version (and why it breaks)
import requests
proxy = "http://user:pass@gw.roamproxy.com:41080"
r = requests.get("https://example.com", proxies={"http": proxy, "https": proxy})
One bad IP and this raises, with no rotation and no retry. Wrap it in a loop and you're already reinventing a pool — badly. Let's do it properly.
A resilient pool
The core idea: on every attempt, pick a proxy, and treat both exceptions and retryable status codes (429, 5xx) as "try again on a fresh IP," with exponential backoff.
import random
import time
import requests
class ProxyPool:
RETRYABLE = {429, 500, 502, 503, 504}
def __init__(self, proxies, max_retries=3, timeout=15):
self.proxies = list(proxies)
self.max_retries = max_retries
self.timeout = timeout
def _pick(self):
return random.choice(self.proxies)
def get(self, url, **kwargs):
last_exc = None
for attempt in range(self.max_retries):
proxy = self._pick()
try:
r = requests.get(
url,
proxies={"http": proxy, "https": proxy},
timeout=self.timeout,
**kwargs,
)
if r.status_code in self.RETRYABLE:
raise requests.HTTPError(f"retryable status {r.status_code}")
return r
except requests.RequestException as e:
last_exc = e
time.sleep(2 ** attempt) # 1s, 2s, 4s...
raise last_exc
pool = ProxyPool([
"http://user:pass@gw.roamproxy.com:41080",
# ...add as many gateway endpoints or credentials as you like
])
resp = pool.get("https://httpbin.org/ip")
print(resp.json())
A few things worth calling out:
- Rotate on every attempt, not just on success. The whole point is that attempt 2 uses a different IP than attempt 1.
-
Exponential backoff (
2 ** attempt) keeps you from hammering a struggling target. -
Treat
429/5xxas retryable, but let real client errors like404return normally — retrying them just wastes IPs.
With most gateway providers you don't even maintain a list of raw IPs. You point at one endpoint and get a new exit IP per request automatically. Rotation for free:
# rotating: a fresh IP on every request
proxy = "http://user-country-us:pass@gw.roamproxy.com:41080"
When you actually want the same IP
Rotation is the default, but some flows break if the IP changes mid-way: logins, add-to-cart, anything multi-step behind a session cookie. That's a sticky session — pin a session id in the username so the gateway keeps handing you the same exit IP until you rotate it:
# sticky: same IP while the session id stays constant
sticky = "http://user-country-us-session-abc123:pass@gw.roamproxy.com:41080"
s = requests.Session()
s.proxies = {"http": sticky, "https": sticky}
s.post("https://example.com/login", data=creds) # same IP...
s.get("https://example.com/dashboard") # ...as this one
Change the session id and you get a new IP. That single lever — rotate vs. pin — covers the vast majority of scraping needs.
Don't reinvent the whole thing
The pattern above (rotate, retry-on-failure with backoff, plus sticky sessions and a pool that recycles slots) is common enough that we packaged it into a tiny zero-dependency SDK: roamproxy-python. It gives you a Client, a StickySession context manager, and a SessionPool that retries on 5xx/429/connection errors out of the box:
from roamproxy import SessionPool
pool = SessionPool(retry_on_failure=True)
resp = pool.get("https://httpbin.org/ip")
It's MIT-licensed — copy the ideas, or pip install it, whatever's faster for you.
Wrapping up
A production scraper isn't "requests + a proxy." It's a pool that rotates, retries on a fresh IP, backs off, and can pin a sticky session when a flow needs one. Build those four behaviors in and most "the site keeps blocking me" problems quietly disappear.
If you'd rather not run your own IP infrastructure, that's the problem RoamProxy solves — pay-as-you-go residential and datacenter proxies from $2/GB, with the rotating/sticky gateway shown above. Either way, the engineering above is the part that matters.
Top comments (0)