~/blog/rotating-proxies.md

Rotating Proxies Without Rotating Your Sanity

· #scraping #infrastructure

I bought a residential proxy pool, wired up round-robin rotation, and my error rate went up. Turned out about 15% of the pool was dead on arrival — proxies that had been recycled by the provider, IPs already flagged by the target, endpoints that just timed out. Round-robin doesn’t care. It hands you the next proxy in line whether it works or not, so a sixth of my requests were doomed before they left the machine.

Rotation alone isn’t a strategy, it’s a shuffle

The naive version treats the pool as an undifferentiated list:

proxies = ["http://p1:8080", "http://p2:8080", "http://p3:8080", ...]

def get_proxy():
    return random.choice(proxies)

This assumes every proxy in the list is equally good, right now. They aren’t. Datacenter proxies get IP-banned by aggressive targets in hours. Residential proxies drop offline when the device they’re routed through goes to sleep. A provider’s pool churns underneath you constantly. A list with no feedback loop just means you keep drawing dead cards.

Health as a score, not a switch

The fix that actually held up: stop treating proxies as up/down and track a rolling success rate instead, so a proxy that fails once isn’t thrown away, but one that fails consistently drops out of rotation on its own.

from collections import deque
import time

class ProxyHealth:
    def __init__(self, window=20):
        self.window = window
        self.results = deque(maxlen=window)  # True/False per attempt
        self.cooldown_until = 0.0

    def record(self, ok: bool):
        self.results.append(ok)
        if not ok and self.success_rate() < 0.5:
            self.cooldown_until = time.monotonic() + 60  # sit out a minute

    def success_rate(self) -> float:
        if not self.results:
            return 1.0  # optimistic default for an untested proxy
        return sum(self.results) / len(self.results)

    def available(self) -> bool:
        return time.monotonic() >= self.cooldown_until

Weighted selection then favors proxies with a good recent track record without permanently blacklisting one that had a bad minute — a proxy that was down for a network blip recovers on its own once its window fills back up with successes:

def pick_proxy(pool: dict[str, ProxyHealth]) -> str:
    candidates = [p for p, h in pool.items() if h.available()]
    if not candidates:
        # everyone's in cooldown — pick the least-bad option rather than fail outright
        candidates = list(pool)
    weights = [pool[p].success_rate() + 0.05 for p in candidates]  # floor so dead isn't zero
    return random.choices(candidates, weights=weights, k=1)[0]

Sticky sessions, deliberately

Some targets key rate limits or session state to the client IP — a login flow, a shopping cart, anything with server-side state tied to your apparent address. Rotating the proxy mid-session there doesn’t help you, it breaks you: the target sees a session that suddenly jumped IP and either drops it or flags it as suspicious.

The fix is sticky-by-key: hash whatever identifies the logical session (account id, cart id, crawl job id) to a proxy, and hold that mapping for the session’s lifetime instead of re-rotating on every request.

def sticky_proxy(session_key: str, pool: list[str]) -> str:
    idx = hash(session_key) % len(pool)
    return pool[idx]

Combine the two: sticky assignment for anything stateful, weighted rotation by health for anything stateless like a plain page fetch. Using one strategy everywhere is the mistake — pure rotation breaks sessions, pure stickiness means one bad proxy tanks every request tied to it.

What I learned

A proxy pool needs the same failure handling as any other dependency you don’t control: track health per-endpoint, decay bad scores instead of hard-banning on one failure, and give state-carrying flows a sticky assignment instead of rotating them into a broken session. The 15% dead pool didn’t get better because I complained to the provider — it got better because the client stopped sending traffic to the dead 15% on its own, and that’s the only fix that scales past however many proxies you can manually babysit.

cd .. cd ~ (back to terminal)