DEV Community

Cover image for Rotating Residential Proxy Evaluation Mini-Lab You Can Run in 90 Minutes
gabriele wayner
gabriele wayner

Posted on

Rotating Residential Proxy Evaluation Mini-Lab You Can Run in 90 Minutes

This is a runnable mini-lab for evaluating rotating residential proxies for scraping and monitoring. You’ll generate evidence in 60–90 minutes: rotation proof, sticky-session proof, pool collision metrics under concurrency, a ramp-and-soak signal report, and CP1K. The deeper acceptance gates live in the hub: Rotating Residential Proxies Evaluation Playbook for Web Scraping in 2026

Run the same harness against every provider you’re considering, including MaskProxy, so your results compare cleanly. Define “success” as what your job needs (not just status 200), and set a hard request budget so you don’t burn time chasing noisy runs.

Set scope, budget, and evidence fields

Pick two targets you are allowed to test:

Easy target: stable baseline for exit IP and latency (an IP echo endpoint works).

Defended target: a real site that matches your production workflow (price intel, availability checks, SERP monitoring), tested within policy and terms.

Write down a request budget and stop conditions:

Stop if 403 or 429 stays high for 2–3 minutes.

Stop if p95 latency doubles and stays there.

Stop if challenge pages dominate your “success” definition.

Keep your terms precise. “Rotating” can mean per-request rotation, per-time-window rotation, or sticky sessions with a TTL. Align your test to the rotation mode you intend to ship: Rotating Proxies

Log one JSON record per request with stable fields so you can compute metrics without hand-waving:

ts, test, target, url, status, latency_ms, exit_ip, session, bytes, retry, sig

Build the tiny harness with JSONL logs

Create a timestamped run folder and write one JSON line per request. This makes the lab reproducible and reviewable.

lab.py
import os, json, time, uuid, asyncio
from typing import Optional, Dict, Any
import httpx

RUN_ID = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]
OUTDIR = f"./runs/{RUN_ID}"
os.makedirs(OUTDIR, exist_ok=True)
LOG_PATH = f"{OUTDIR}/requests.jsonl"

EASY_URL = os.getenv("EASY_URL", "https://api.ipify.org?format=json")
DEFENDED_URL = os.getenv("DEFENDED_URL", "https://example.com/")

MAX_REQUESTS = int(os.getenv("MAX_REQUESTS", "4000"))
MAX_MINUTES = int(os.getenv("MAX_MINUTES", "90"))

PROXY_URL = os.getenv("PROXY_URL") # http://user:pass@host:port
TIMEOUT_S = float(os.getenv("TIMEOUT_S", "20"))
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "2"))

def jlog(rec: Dict[str, Any]) -> None:
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")

Wrap requests with timing, retries, and signatures

You need three things on every request: exit IP, latency, and a lightweight signature that tags rate limiting, blocking, or challenge behavior. For HTTP behavior details that can matter during debugging (redirects, caching, semantics), RFC 9110 is the baseline: RFC 9110

CHALLENGE_MARKERS = ["captcha", "challenge", "cf-chl", "recaptcha", "px-captcha", "akamai"]

def classify(status: int, body_text: str) -> str:
lower = (body_text or "").lower()
if status == 429:
return "rate_limited"
if status == 403:
return "blocked"
if any(m in lower for m in CHALLENGE_MARKERS):
return "soft_challenge"
if status == 0:
return "error"
return "ok"

async def get_exit_ip(client: httpx.AsyncClient, session: Optional[str]) -> str:
headers = {"User-Agent": "eval-lab/1.0"}
if session:
headers["X-Session"] = session # map to your provider’s sticky-session mechanism
r = await client.get(EASY_URL, headers=headers, timeout=TIMEOUT_S)
return r.json().get("ip", "")

async def fetch(client: httpx.AsyncClient, test: str, target: str, url: str,
session: Optional[str]=None) -> Dict[str, Any]:
headers = {"User-Agent": "eval-lab/1.0"}
if session:
headers["X-Session"] = session

start = time.time()

for attempt in range(MAX_RETRIES + 1):
    try:
        r = await client.get(url, headers=headers, timeout=TIMEOUT_S, follow_redirects=True)
        latency_ms = int((time.time() - start) * 1000)
        body = (r.text[:2000] if "text" in (r.headers.get("content-type") or "") else "")
        sig = classify(r.status_code, body)

        rec = {
            "ts": int(time.time()),
            "test": test,
            "target": target,
            "url": url,
            "status": r.status_code,
            "latency_ms": latency_ms,
            "session": session,
            "bytes": len(r.content or b""),
            "retry": attempt,
            "sig": sig,
        }
        jlog(rec)
        return rec
    except Exception as e:
        if attempt == MAX_RETRIES:
            rec = {
                "ts": int(time.time()),
                "test": test,
                "target": target,
                "url": url,
                "status": 0,
                "latency_ms": int((time.time() - start) * 1000),
                "session": session,
                "bytes": 0,
                "retry": attempt,
                "sig": "error",
                "err": repr(e),
            }
            jlog(rec)
            return rec
        await asyncio.sleep(0.5 * (2 ** attempt))
Enter fullscreen mode Exit fullscreen mode

Prove rotation and sticky sessions with a repeatable test

This test answers two practical questions:

Does the pool rotate when you do not pin a session?

Does the exit IP stay stable when you do pin a session?

def uniq(seq): return len(set(seq))

async def test_rotation_and_sticky():
async with httpx.AsyncClient(proxies=PROXY_URL) as client:
rot_ips = [await get_exit_ip(client, session=None) for _ in range(30)]
sticky_a = [await get_exit_ip(client, session="A") for _ in range(15)]
sticky_b = [await get_exit_ip(client, session="B") for _ in range(15)]

print("rotation unique:", uniq(rot_ips), "of", len(rot_ips))
print("sticky A unique:", uniq(sticky_a), "of", len(sticky_a))
print("sticky B unique:", uniq(sticky_b), "of", len(sticky_b))
print("A vs B overlap:", len(set(sticky_a) & set(sticky_b)))
Enter fullscreen mode Exit fullscreen mode

Expected signals:

Rotation should produce meaningfully more unique IPs than sticky.

Sticky A should be mostly stable, and sticky B should differ from sticky A most of the time.

If rotation uniqueness is tiny, you’re effectively testing a small shared pool with heavy IP reuse.

When you interpret these results, keep the product category boundaries in mind for “rotating residential proxy free trial” comparisons: Rotating Residential Proxies

Measure pool collisions and IP reuse under concurrency

Collisions are the hidden throughput killer. If 100 workers share 10 exit IPs, one IP-level reputation event becomes a fleet-wide failure pattern.

Run a micro-burst at your expected in-flight concurrency (50–200). Keep it short and measurable.

async def burst_collisions(concurrency=80, total=400):
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(proxies=PROXY_URL) as client:
async def one():
async with sem:
ip = await get_exit_ip(client, session=None)
jlog({"ts": int(time.time()), "test": "burst_ip", "target": "easy", "exit_ip": ip})
return ip
ips = await asyncio.gather(*[one() for _ in range(total)])

uniq_ips = len(set(ips))
collision_rate = 1 - (uniq_ips / max(1, len(ips)))
print("total:", len(ips), "unique:", uniq_ips, "collision_rate:", round(collision_rate, 3))
Enter fullscreen mode Exit fullscreen mode

How to read it:

Collision rate rises with concurrency, but it should not instantly collapse into a handful of IPs.

If top-IP concentration is high, expect “shared fate” blocks during monitoring bursts and retry storms.

If you’re testing MaskProxy versus another pool, keep concurrency and total requests identical so collision curves are comparable.

Run a ramp-and-soak and collect p95, 429, 403, and challenge signals

Use a simple load shape: warm-up → ramp → soak. This makes stability problems show up quickly, including “looks fine at minute 2, fails at minute 20.”

When you interpret rate limiting, don’t guess semantics. RFC 6585 defines 429, and MDN summaries are handy for quick status checks: RFC 6585
and MDN 429
plus MDN 403

async def ramp_soak():
phases = [
("warmup", 2*60, 20),
("ramp", 8*60, 60),
("soak", 15*60, 60),
]
async with httpx.AsyncClient(proxies=PROXY_URL) as client:
for name, seconds, conc in phases:
end = time.time() + seconds
while time.time() < end:
sem = asyncio.Semaphore(conc)
async def one():
async with sem:
return await fetch(client, name, "defended", DEFENDED_URL, session=None)
await asyncio.gather(*[one() for _ in range(conc)])

What you’re looking for:

p95 latency drift during soak suggests pool saturation, retry amplification, or throttling.

Sustained 429 indicates a rate limit wall; sustained 403 indicates refusal or policy blocks.

“soft_challenge” should be treated as failure if your pipeline cannot solve it reliably.

Compute CP1K from collected numbers

CP1K is cost per 1,000 successful requests. Define success as what your pipeline needs. For many scraping jobs: “2xx and not a challenge page.”

Start with a simple run-cost model (plan proration + traffic charges if applicable), then compute CP1K from your log counts. When you plug in pricing, use the correct unit basis so CP1K does not lie: Rotating Residential Proxies Pricing

import json

def compute_cp1k(log_path: str, total_cost_usd: float) -> None:
attempts = 0
successes = 0

with open(log_path, "r", encoding="utf-8") as f:
    for line in f:
        rec = json.loads(line)
        if rec.get("test") not in ("warmup", "ramp", "soak"):
            continue
        attempts += 1
        status = rec.get("status", 0)
        sig = rec.get("sig")
        if 200 <= status < 300 and sig == "ok":
            successes += 1

cp1k = (total_cost_usd / (successes / 1000)) if successes else float("inf")
print("attempts:", attempts, "successes:", successes, "CP1K_USD:", round(cp1k, 2))
Enter fullscreen mode Exit fullscreen mode

This is also where business reality shows up. If MaskProxy gives you stable soak signals at your target concurrency but a higher CP1K than a cheaper pool, you now have a concrete tradeoff discussion instead of vibes.

Wrap-up

You should now have JSONL evidence for rotation and sticky behavior, collision rate under concurrency, ramp-and-soak stability, and a CP1K number you can defend in a go or no-go review. If you want the decision structure that turns these signals into acceptance criteria, close the loop with the hub: Rotating Residential Proxies Evaluation Playbook for Web Scraping in 2026

Top comments (0)