Building a Proxy-Aware Retry Queue in Python for Resilient Scraping
September 23, 2026
Why a Proxy-Aware Retry Queue Matters
When you scrape at scale, two failure modes dominate: transient HTTP errors (429 Too Many Requests, 503 Service Unavailable) and proxy-level failures (timeouts, connection resets, 407 auth errors). Most scrapers handle these poorly — they either retry blindly with the same proxy (guaranteeing another ban) or give up entirely.
A proxy-aware retry queue solves this by combining three techniques:
- Exponential backoff with jitter to avoid thundering-herd retries
- Automatic proxy rotation on each retry so you never hammer the same IP twice
- Error classification to distinguish retryable from permanent failures
The result is a scraper that self-heals through transient failures without burning your proxy budget on doomed requests.
Core Design Principles
1. Separate Retryable from Permanent Errors
Not every error deserves a retry. A 404 means the resource is gone — retrying with a new proxy wastes credits. A 503 likely means the server is temporarily overloaded and will recover.
Retryable status codes: 429, 500, 502, 503, 504 Permanent status codes: 400, 401, 403, 404, 410
For proxy errors, treat timeouts and connection resets as retryable, but 407 (bad credentials) as permanent — rotating won't fix a wrong password.
2. Rotate Proxies on Every Retry
The whole point of using proxies is distributing requests across IPs. If you retry with the same proxy that just got rate-limited, you're reinforcing the ban. Each retry attempt should pull a fresh proxy from your pool.
3. Use Decorrelated Jitter
Plain exponential backoff (1s, 2s, 4s, 8s...) causes synchronized retry storms when many requests fail simultaneously. Decorrelated jitter, popularized by AWS, adds randomness that spreads retries over time:
import random
def decorrelated_jitter(base: float, cap: float, previous_sleep: float) -> float:
"""AWS-style decorrelated jitter for backoff."""
sleep = min(cap, random.uniform(base, previous_sleep * 3))
return sleep
Implementation in Python
Here's a complete, production-ready retry queue using asyncio and aiohttp:
import asyncio
import random
from dataclasses import dataclass, field
from typing import Optional
import aiohttp
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
MAX_RETRIES = 5
BASE_DELAY = 1.0
MAX_DELAY = 60.0
@dataclass
class ProxyPool:
"""Simple rotating proxy pool with round-robin selection."""
proxies: list[str]
_index: int = 0
_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
async def next(self) -> str:
async with self._lock:
proxy = self.proxies[self._index % len(self.proxies)]
self._index += 1
return proxy
@dataclass
class RetryResult:
success: bool
status: Optional[int]
body: Optional[bytes]
attempts: int
error: Optional[str] = None
async def fetch_with_retry(
session: aiohttp.ClientSession,
url: str,
proxy_pool: ProxyPool,
method: str = "GET",
headers: Optional[dict] = None,
payload: Optional[dict] = None,
) -> RetryResult:
"""
Fetch a URL with proxy rotation and exponential backoff.
"""
delay = BASE_DELAY
last_error = None
for attempt in range(1, MAX_RETRIES + 1):
proxy = await proxy_pool.next()
proxy_url = f"http://{proxy}"
try:
async with session.request(
method,
url,
proxy=proxy_url,
headers=headers,
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as response:
if response.status == 200:
body = await response.read()
return RetryResult(
success=True,
status=200,
body=body,
attempts=attempt,
)
if response.status not in RETRYABLE_STATUS:
# Permanent error — don't retry
body = await response.read()
return RetryResult(
success=False,
status=response.status,
body=body,
attempts=attempt,
error=f"HTTP {response.status} — not retryable",
)
# Retryable status code
last_error = f"HTTP {response.status}"
except asyncio.TimeoutError:
last_error = "Timeout"
except aiohttp.ClientError as e:
last_error = f"ClientError: {e}"
# Calculate next delay with decorrelated jitter
delay = min(MAX_DELAY, random.uniform(BASE_DELAY, delay * 3))
if attempt < MAX_RETRIES:
await asyncio.sleep(delay)
return RetryResult(
success=False,
status=None,
body=None,
attempts=MAX_RETRIES,
error=last_error,
)
Running the Queue Concurrently
A single retry queue isn't enough — you need to process many URLs in parallel while respecting concurrency limits. Here's how to wire it up with a semaphore:
async def scrape_batch(
urls: list[str],
proxies: list[str],
concurrency: int = 20,
) -> list[RetryResult]:
proxy_pool = ProxyPool(proxies)
semaphore = asyncio.Semaphore(concurrency)
connector = aiohttp.TCPConnector(limit=concurrency * 2, force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
async def bounded_fetch(url: str) -> RetryResult:
async with semaphore:
return await fetch_with_retry(session, url, proxy_pool)
results = await asyncio.gather(*[bounded_fetch(u) for u in urls])
return results
# Example usage
if __name__ == "__main__":
proxy_list = [
"user:pass@proxy1.roproxy.com:8080",
"user:pass@proxy2.roproxy.com:8080",
"user:pass@proxy3.roproxy.com:8080",
]
target_urls = [
"https://httpbin.org/status/200",
"https://httpbin.org/status/429",
"https://httpbin.org/status/503",
"https://httpbin.org/status/404",
]
results = asyncio.run(scrape_batch(target_urls, proxy_list))
for url, result in zip(target_urls, results):
status = "OK" if result.success else "FAIL"
print(f"{status} | {url} | attempts={result.attempts} | error={result.error}")
Honoring Retry-After Headers
Many APIs and CDNs return a Retry-After header with 429 and 503 responses. It tells you exactly how long to wait. Ignoring it and using your own backoff is wasteful — you'll either wait too long (wasting time) or too short (getting blocked again).
Update the retry logic to respect this header:
from email.utils import parsedate_to_datetime
from datetime import datetime, timezone
def parse_retry_after(header_value: str) -> Optional[float]:
"""Parse Retry-After header (seconds or HTTP date)."""
if not header_value:
return None
# Numeric seconds
try:
return float(header_value)
except ValueError:
pass
# HTTP date format
try:
dt = parsedate_to_datetime(header_value)
if dt:
now = datetime.now(timezone.utc)
return max(0.0, (dt - now).total_seconds())
except (TypeError, ValueError):
pass
return None
Then inside fetch_with_retry, after detecting a retryable status:
retry_after = parse_retry_after(response.headers.get("Retry-After", ""))
if retry_after is not None:
delay = min(MAX_DELAY, retry_after)
else:
delay = min(MAX_DELAY, random.uniform(BASE_DELAY, delay * 3))
Tracking Proxy Health
Not all proxies perform equally. Some are slow, some get banned faster. Track per-proxy success rates and deprioritize underperformers:
@dataclass
class ProxyStats:
proxy: str
successes: int = 0
failures: int = 0
last_failure: float = 0.0
@property
def success_rate(self) -> float:
total = self.successes + self.failures
return self.successes / total if total > 0 else 1.0
class SmartProxyPool:
"""Proxy pool that tracks stats and deprioritizes bad proxies."""
def __init__(self, proxies: list[str], min_success_rate: float = 0.5):
self._stats = {p: ProxyStats(proxy=p) for p in proxies}
self._min_success_rate = min_success_rate
self._lock = asyncio.Lock()
async def next(self) -> str:
async with self._lock:
# Sort by success rate, pick from top performers
sorted_proxies = sorted(
self._stats.values(),
key=lambda s: s.success_rate,
reverse=True,
)
# Filter out underperformers if enough alternatives exist
good = [s for s in sorted_proxies if s.success_rate >= self._min_success_rate]
pool = good if len(good) > len(self._stats) // 2 else sorted_proxies
return random.choice(pool).proxy
async def record_success(self, proxy: str):
async with self._lock:
self._stats[proxy].successes += 1
async def record_failure(self, proxy: str):
async with self._lock:
self._stats[proxy].failures += 1
self._stats[proxy].last_failure = asyncio.get_event_loop().time()
Wire the stats recording into fetch_with_retry by calling record_success or record_failure after each attempt. This creates a feedback loop: proxies that consistently fail naturally fall out of rotation.
Common Pitfalls to Avoid
Don't Retry on 403
A 403 often means the target detected your scraper pattern (headers, TLS fingerprint, request cadence) — not your IP. Rotating proxies won't help. Instead, review your headers and consider a headless browser approach.
Don't Retry Infinite Loops
Always cap retries. Five attempts is a reasonable maximum. Without a cap, a permanently broken endpoint will consume proxy credits forever.
Don't Share Sessions Across Proxies
Each retry with a new proxy should use a fresh session or at least clear cookies. Carrying cookies from a banned IP to a new IP links the two and can get the new proxy banned too.
Don't Forget Force Close
Set force_close=True on your TCPConnector. Some proxies don't handle keep-alive well, and reusing connections across rotated proxies causes bizarre routing errors.
Conclusion
A proxy-aware retry queue is the difference between a scraper that survives transient failures and one that dies at the first 429. By combining error classification, decorrelated jitter, per-retry proxy rotation, and Retry-After header support, you get a system that's both resilient and respectful of target servers. Add per-proxy health tracking and you've got infrastructure that improves itself over time.