Building a Resilient Proxy Pool Manager in Python
A practical pattern for tracking proxy health, rotating on failure, and keeping bad IPs out of your request cycle

A proxy pool manager's job is simple: hand out a working proxy, notice when one stops working, and stop handing that one out for a while.
Most homemade pools skip the second and third parts. They just round-robin through a list and burn requests on dead IPs until someone notices the failure rate. Here's a pattern that fixes that, in plain Python with no extra dependencies beyond requests.
The core idea
Every proxy gets a small health record: how many times it's failed in a row, and when it's allowed back into rotation after being marked bad.
import time
import random
import requests
class Proxy:
def __init__(self, address):
self.address = address
self.fail_count = 0
self.cooldown_until = 0
def is_available(self):
return time.time() >= self.cooldown_until
def mark_success(self):
self.fail_count = 0
def mark_failure(self):
self.fail_count += 1
# Exponential backoff: 30s, 60s, 120s, 240s...
cooldown_seconds = 30 * (2 ** (self.fail_count - 1))
self.cooldown_until = time.time() + min(cooldown_seconds, 1800)
Each failure pushes the cooldown further out, capped at 30 minutes. A proxy that fails once gets a short timeout. A proxy that keeps failing gets pushed further and further to the back of the line without being permanently removed, it might recover.
The pool manager
class ProxyPool:
def __init__(self, addresses):
self.proxies = [Proxy(addr) for addr in addresses]
def get_proxy(self):
available = [p for p in self.proxies if p.is_available()]
if not available:
raise RuntimeError("No proxies available — all in cooldown")
return random.choice(available)
def request(self, url, **kwargs):
proxy = self.get_proxy()
try:
response = requests.get(
url,
proxies={"http": proxy.address, "https": proxy.address},
timeout=10,
**kwargs
)
if response.status_code >= 500 or response.status_code == 429:
proxy.mark_failure()
else:
proxy.mark_success()
return response
except requests.exceptions.RequestException:
proxy.mark_failure()
raise
get_proxy() only pulls from proxies that are currently out of cooldown. request() wraps the actual call, marking success or failure based on what comes back, including a distinct check for 429, since a rate-limit response means the proxy is fine but overused, not broken.
Why random selection instead of round-robin
Round-robin looks predictable to a target site: proxy A, then B, then C, then A again, on a fixed clock. Random selection from the available pool breaks that pattern without adding complexity.
If you need weighted selection later, favoring proxies with lower fail counts, for example, that's a small change to get_proxy(), not a rewrite.
Handling total pool exhaustion
If every proxy is in cooldown at once, get_proxy() raises instead of silently hanging. That's deliberate. A silent retry loop against an empty pool just burns CPU and time. Better to fail loud, log it, and let the caller decide whether to wait, alert, or fall back to a different pool.
try:
response = pool.request("https://example.com/data")
except RuntimeError:
print("Pool exhausted — pausing before retry")
time.sleep(60)
Where this goes from here
This version tracks failure and cooldown per proxy, which covers most small-to-medium scraping jobs. At higher volume, the next additions are usually:
Persisting proxy health to Redis so multiple workers share one pool state
Splitting the pool by target domain, since a proxy can be healthy for one site and banned on another
Swapping the fixed backoff formula for one tuned to your actual failure patterns
The pattern doesn't change. You're still just tracking who's healthy, who isn't, and how long the unhealthy ones sit out. Providers that expose large datacenter proxy blocks, like InstantProxies, give this kind of pool manager enough addresses to make the cooldown math actually work: a pool of five proxies doesn't have room to rotate anyone out.
