4 Rate Limiting Algorithms Compared
1. Fixed Window
import time
import redis
def allow_fixed_window(client_id, max_per_sec=10):
window = int(time.time())
key = f"limit:{client_id}:{window}"
if redis.get(key):
return False
redis.setex(key, 1, 1)
return True
Pros: Trivially simple, 1 line of Redis.
Cons: Boundary case. 10 at 0.999s + 10 at 1.001s = 20 in 1 second.
Use when: Coarse limits, exact precision not required.
2. Sliding Window
def allow_sliding_window(client_id, max_per_sec=10):
now = time.time()
sub_windows = 10
total = 0
for i in range(sub_windows):
key = f"limit:{client_id}:{int(now * sub_windows) % sub_windows}"
total += int(redis.get(key) or 0)
return total < max_per_sec
Pros: Boundary case solved.
Cons: Multiple keys, slightly more memory.
Use when: Precise limits.
3. Token Bucket (Recommended)
def allow_token_bucket(client_id, capacity=100, refill_rate=10):
bucket_key = f"bucket:{client_id}"
bucket = redis.hgetall(bucket_key)
tokens = float(bucket.get(b"tokens", capacity))
last_refill = float(bucket.get(b"last_refill", time.time()))
elapsed = time.time() - last_refill
tokens = min(capacity, tokens + elapsed * refill_rate)
if tokens >= 1:
tokens -= 1
redis.hset(bucket_key, mapping={b"tokens": tokens, b"last_refill": time.time()})
redis.expire(bucket_key, 3600)
return True
return False
Pros: Allows burst (full bucket = capacity in single moment), long-term average โค refill_rate.
Cons: State persistence required.
Use when: B2B APIs (batch OK, average controlled).
4. Leaky Bucket
import collections
def allow_leaky_bucket(client_id, capacity=100, rate=10):
queue_key = f"queue:{client_id}"
queue = redis.lrange(queue_key, 0, -1)
now = time.time()
# Process queue
while queue and now - float(queue[0]) >= 1.0 / rate:
redis.lpop(queue_key)
queue.pop(0)
if len(queue) < capacity:
redis.rpush(queue_key, now)
return True
return False
Pros: Smooth, predictable downstream load.
Cons: No burst (batches queue).
Use when: Protecting downstream (DB, payment).
QPS Evolution Path
Stage 1: 0-10 QPS (in-process token bucket)
class TokenBucket:
def __init__(self, capacity, refill_rate):
self.capacity = capacity
self.refill_rate = refill_rate
self.tokens = capacity
self.last_refill = time.time()
self.lock = threading.Lock()
def acquire(self):
with self.lock:
now = time.time()
self.tokens = min(self.capacity, self.tokens + (now - self.last_refill) * self.refill_rate)
self.last_refill = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
bucket = TokenBucket(10, 10)
Stage 2: 10-50 QPS (Redis coordination)
In-process bucket isn't enough, add Redis:
def acquire_redis(redis_client, key, capacity, refill_rate):
bucket = redis_client.hgetall(key)
tokens = float(bucket.get("tokens", capacity))
last_refill = float(bucket.get("last_refill", time.time()))
elapsed = time.time() - last_refill
tokens = min(capacity, tokens + elapsed * refill_rate)
if tokens >= 1:
tokens -= 1
redis_client.hset(key, mapping={"tokens": tokens, "last_refill": time.time()})
redis_client.expire(key, 3600)
return True
return False
Stage 3: 50-200 QPS (queue + worker pool)
Burst absorption via queue:
queue = redis.Redis()
def producer(jobs):
for job in jobs:
queue.lpush("serp:jobs", json.dumps(job))
def worker():
while True:
job = queue.brpop("serp:jobs", timeout=5)
if not job:
continue
process_job(json.loads(job[1]))
# Launch 20 workers
if __name__ == "__main__":
with Pool(20) as pool:
pool.map(worker, range(20))
Stage 4: 200-1000 QPS (multi-vendor + smart routing)
VENDORS = {
"primary": {"url": "...", "key": "...", "qps_limit": 200},
"secondary": {"url": "...", "key": "...", "qps_limit": 200},
"tertiary": {"url": "...", "key": "...", "qps_limit": 100},
}
def call_with_failover(params):
for vendor in prioritized_vendors():
try:
return requests.post(vendor["url"], json=params, headers=...)
except (RateLimitError, TimeoutError):
continue
raise AllVendorsFailed()
4 Key Design Points
1. Always Keep 30% Buffer
# QPS limit 100, run at 70 max
# 30% buffer for burst and vendor switching
2. Client-Side Limiting Too
# Don't trust clients to "slow down"
# Add 2 layers of limiting: per-user + global
3. Handle 429 Properly
# Don't just throw 429 error, wait for Retry-After
if e.response.status_code == 429:
retry_after = int(e.response.headers.get("Retry-After", 1))
time.sleep(retry_after)
return retry()
4. Monitor QPS Approaching Limit
# Alert at 80%, throttle at 90%
if qps > limit * 0.8:
alert()
if qps > limit * 0.9:
throttle_new_requests()
90-Day Production Data
| Stage | QPS | Implementation | Monthly Cost |
|---|---|---|---|
| 1 | 2 | In-process token bucket | $5 |
| 2 | 30 | Redis coordination | $50 |
| 3 | 100 | Queue + 20 workers | $200 |
| 4 | 500 | Multi-vendor + routing | $1,000 |
Each stage upgrade took 1-2 weeks, mostly testing.
SERP API Rate Limiting Best Practices
- Check vendor docs first: SerpBase entry 10 QPS, Growth 50 QPS, Pro 200 QPS
- Always keep 30% buffer: Don't max out
- Client + server 2-layer limiting: Per-user + global
- Multi-vendor fallback: Switch when one fails
- Monitor + alert: QPS > 80% alert, > 90% throttle
100 free searches: serpbase.dev signup, no card required.
Top comments (0)