DEV Community

dodou
dodou

Posted on

SERP API Rate Limiting Deep Dive: From 10 QPS to 1000 QPS

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

4 Key Design Points

1. Always Keep 30% Buffer

# QPS limit 100, run at 70 max
# 30% buffer for burst and vendor switching
Enter fullscreen mode Exit fullscreen mode

2. Client-Side Limiting Too

# Don't trust clients to "slow down"
# Add 2 layers of limiting: per-user + global
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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

  1. Check vendor docs first: SerpBase entry 10 QPS, Growth 50 QPS, Pro 200 QPS
  2. Always keep 30% buffer: Don't max out
  3. Client + server 2-layer limiting: Per-user + global
  4. Multi-vendor fallback: Switch when one fails
  5. Monitor + alert: QPS > 80% alert, > 90% throttle

100 free searches: serpbase.dev signup, no card required.

Top comments (0)