Implementing a Proxy‑Aware Token Bucket Rate Limiter in Python for Distributed Scrapers
September 20, 2026
Why a Token Bucket for Proxy‑Aware Scrapers
When you run a distributed scraper behind a rotating proxy pool, each IP address has its own rate‑limit ceiling. A naïve time.sleep() between requests creates bursty traffic: a fast proxy may fire dozens of requests in a second while a slower one sits idle, quickly triggering bans. A token‑bucket algorithm solves this by granting each proxy a steady stream of "tokens" (request credits) that refill at a configurable rate. The scraper only sends a request when a token is available, guaranteeing smooth, predictable traffic per IP.
Key benefits:
- Per‑proxy fairness – every proxy obeys its own limit, preventing a single hot IP from exhausting the pool.
- Burst tolerance – buckets can hold a small surplus, allowing short spikes (e.g., handling a redirect chain) without immediate throttling.
- Composability – the limiter works with any async or sync HTTP client and plugs into existing proxy‑rotation logic.
Core Concepts
Token Bucket Algorithm
A token bucket has three parameters:
capacity– maximum tokens the bucket can hold (burst allowance).refill_rate– tokens added per second (steady‑state throughput).tokens– current available tokens (float for sub‑second precision).
On each request the bucket attempts to consume(1). If tokens >= 1, the request proceeds and tokens -= 1. Otherwise the caller waits until enough tokens accumulate (wait_time = (1 - tokens) / refill_rate).
Mapping Buckets to Proxies
Each proxy in the pool gets its own bucket instance. When the rotation logic selects a proxy, the scraper also fetches the associated bucket. This design keeps the limiter stateless from the scraper’s perspective – it simply asks "can I use this proxy now?".
Implementation Overview
We’ll build four small, testable components:
ProxyPool– yields(proxy_url, proxy_id)pairs and reports health.TokenBucket– core algorithm with asynctake()method.BucketManager– creates, stores, and retrieves buckets keyed byproxy_id.ScraperWorker– main loop that acquires a proxy, waits for its bucket, performs the request, and returns the proxy to the pool.
All code uses Python 3.11+, asyncio, and aiohttp for async HTTP. The same ideas translate to synchronous code or other languages.
Step‑by‑Step Implementation
1. Proxy Pool Abstraction
# proxy_pool.py
import random
from typing import AsyncIterator, Tuple
class ProxyPool:
"""Simple round‑robin pool with health tracking."""
def __init__(self, proxies: list[str]):
self._proxies = [{'url': p, 'id': i, 'healthy': True} for i, p in enumerate(proxies)]
self._index = 0
async def acquire(self) -> Tuple[str, int]:
"""Return next healthy proxy (url, id)."""
for _ in range(len(self._proxies)):
proxy = self._proxies[self._index]
self._index = (self._index + 1) % len(self._proxies)
if proxy['healthy']:
return proxy['url'], proxy['id']
raise RuntimeError('No healthy proxies available')
def mark_unhealthy(self, proxy_id: int):
for p in self._proxies:
if p['id'] == proxy_id:
p['healthy'] = False
break
def mark_healthy(self, proxy_id: int):
for p in self._proxies:
if p['id'] == proxy_id:
p['healthy'] = True
break
2. Token Bucket Class
# token_bucket.py
import asyncio
import time
from dataclasses import dataclass, field
@dataclass
class TokenBucket:
capacity: float # max tokens
refill_rate: float # tokens per second
_tokens: float = field(init=False)
_last_refill: float = field(init=False)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
def __post_init__(self):
self._tokens = self.capacity
self._last_refill = time.monotonic()
def _refill(self):
now = time.monotonic()
elapsed = now - self._last_refill
new_tokens = elapsed * self.refill_rate
self._tokens = min(self.capacity, self._tokens + new_tokens)
self._last_refill = now
async def take(self, tokens: float = 1.0) -> None:
"""Block until `tokens` are available, then consume them."""
async with self._lock:
while True:
self._refill()
if self._tokens >= tokens:
self._tokens -= tokens
return
# wait just enough for the missing tokens
deficit = tokens - self._tokens
wait = deficit / self.refill_rate
await asyncio.sleep(wait)
3. Bucket Manager
# bucket_manager.py
from token_bucket import TokenBucket
class BucketManager:
"""Holds a TokenBucket per proxy_id."""
def __init__(self, capacity: float, refill_rate: float):
self._capacity = capacity
self._refill_rate = refill_rate
self._buckets: dict[int, TokenBucket] = {}
def get_bucket(self, proxy_id: int) -> TokenBucket:
if proxy_id not in self._buckets:
self._buckets[proxy_id] = TokenBucket(self._capacity, self._refill_rate)
return self._buckets[proxy_id]
def remove_bucket(self, proxy_id: int):
self._buckets.pop(proxy_id, None)
4. Scraper Worker Loop
# scraper_worker.py
import asyncio
import aiohttp
from proxy_pool import ProxyPool
from bucket_manager import BucketManager
class ScraperWorker:
def __init__(
self,
pool: ProxyPool,
bucket_mgr: BucketManager,
target_urls: list[str],
concurrency: int = 10,
):
self.pool = pool
self.bucket_mgr = bucket_mgr
self.target_urls = target_urls
self.semaphore = asyncio.Semaphore(concurrency)
self.session: aiohttp.ClientSession | None = None
async def _fetch(self, url: str, proxy_url: str, proxy_id: int) -> dict:
bucket = self.bucket_mgr.get_bucket(proxy_id)
await bucket.take() # rate‑limit per proxy
async with self.semaphore:
try:
async with self.session.get(url, proxy=proxy_url, timeout=10) as resp:
data = await resp.text()
return {'url': url, 'status': resp.status, 'len': len(data)}
except Exception as exc:
# on network error, mark proxy unhealthy and retry later
self.pool.mark_unhealthy(proxy_id)
raise
async def run(self):
self.session = aiohttp.ClientSession()
tasks = []
for url in self.target_urls:
tasks.append(asyncio.create_task(self._process_url(url)))
await asyncio.gather(*tasks, return_exceptions=True)
await self.session.close()
async def _process_url(self, url: str):
while True:
proxy_url, proxy_id = await self.pool.acquire()
try:
result = await self._fetch(url, proxy_url, proxy_id)
print(f"[OK] {url} via proxy {proxy_id} -> {result['status']}")
self.pool.mark_healthy(proxy_id)
break
except Exception:
# simple retry with next proxy
await asyncio.sleep(0.5)
continue
5. Wiring It Together
# main.py
import asyncio
from proxy_pool import ProxyPool
from bucket_manager import BucketManager
from scraper_worker import ScraperWorker
PROXIES = [
"http://user:pass@proxy1.example.com:8000",
"http://user:pass@proxy2.example.com:8000",
"http://user:pass@proxy3.example.com:8000",
]
TARGETS = [f"https://httpbin.org/get?page={i}" for i in range(100)]
async def main():
pool = ProxyPool(PROXIES)
# 5 req/s per proxy, burst of 10
buckets = BucketManager(capacity=10, refill_rate=5)
worker = ScraperWorker(pool, buckets, TARGETS, concurrency=20)
await worker.run()
if __name__ == "__main__":
asyncio.run(main())
Handling Edge Cases
| Situation | Strategy |
|---|---|
| Proxy becomes permanently banned | After N consecutive failures, call pool.mark_unhealthy(proxy_id) and bucket_mgr.remove_bucket(proxy_id) to free memory. |
| Dynamic proxy addition | BucketManager.get_bucket lazily creates a bucket, so new proxies work instantly. |
| Unequal proxy performance | Tune refill_rate per‑proxy by storing a custom rate in BucketManager (e.g., a dict proxy_id -> rate). |
| Clock drift / async delays | time.monotonic() is immune to system‑time changes; the lock inside TokenBucket.take serialises refills for a given proxy. |
Testing the Limiter
A quick unit test verifies the bucket respects the configured rate.
# test_token_bucket.py
import asyncio
import pytest
from token_bucket import TokenBucket
@pytest.mark.asyncio
async def test_refill_rate():
bucket = TokenBucket(capacity=5, refill_rate=10) # 10 tokens/s
start = asyncio.get_event_loop().time()
for _ in range(5):
await bucket.take() # should be instant (burst)
await bucket.take() # 6th token -> wait ~0.1s
elapsed = asyncio.get_event_loop().time() - start
assert 0.09 < elapsed < 0.2 # allow scheduling jitter
Run with pytest -q.
Deploying in a Distributed Setting
When workers run on multiple machines, each process would otherwise maintain its own bucket state, breaking the per‑IP contract. Two practical approaches:
- Centralised Redis buckets – store
tokensandlast_refillas a hash perproxy_id. Use a Lua script for atomictake(check‑and‑decrement). Workers callEVALSHAinstead of localTokenBucket.take(). - Proxy‑side rate limiting – many premium proxy providers (including RoProxy) enforce per‑IP limits upstream. In that case the local bucket acts only as a client‑side guard to avoid unnecessary 429 responses.
Example Redis Lua snippet (atomic take):
-- KEYS[1] = proxy_id, ARGV[1] = capacity, ARGV[2] = refill_rate, ARGV[3] = now_ms
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last')
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local last = tonumber(bucket[2]) or tonumber(ARGV[3])
local elapsed = (tonumber(ARGV[3]) - last) / 1000
tokens = math.min(tonumber(ARGV[1]), tokens + elapsed * tonumber(ARGV[2]))
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last', ARGV[3])
return 1
else
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last', ARGV[3])
return 0
end
Workers retry on 0 with a short asyncio.sleep.
Performance Tips
- Batch refill – instead of refilling on every
take(), schedule a background task that updates all buckets every 100 ms. Reduces lock contention. - Use
asyncio.Queuefor URL distribution – decouples URL production from consumption and naturally limits memory. - Reuse
aiohttp.ClientSession– connection pooling across proxies (viaProxyConnector) cuts TLS handshake overhead. - Monitor bucket health – export
tokensandrefill_ratevia Prometheus (gaugeper proxy) to spot mis‑configured limits early.
Conclusion
A token‑bucket rate limiter gives you deterministic, per‑proxy throughput control without the guesswork of fixed sleeps. By coupling a lightweight TokenBucket class with a BucketManager keyed to proxy identifiers, you can drop the component into any async scraper, scale it across machines with Redis, and keep your IP reputation intact. The pattern works equally well for SEO SERP monitoring, price‑intelligence pipelines, or ad‑verification bots – wherever you need high‑volume, polite traffic from a rotating proxy fleet.
Give the code a spin, adjust capacity and refill_rate to match your provider’s limits, and watch the 429 errors disappear.