๐ŸŽซ Rate Limiting Under Load

Respect req/sec ceilings so you don't get 429'd โ€” or knocked over. Shape your traffic before someone else shapes it for you.

Rate Limit โ‰  Concurrency Limit

These two get conflated constantly, and mixing them up is how you either starve a fast downstream or flood a slow one. They bound different things.

Little's Law ties them together: concurrency = rate ร— latency. A 100 req/sec limit on a downstream with 200ms latency implies ~20 in flight. If you set a rate limit but no concurrency cap and latency spikes, in-flight count explodes โ€” you need both.

The Problem: A Slow-Motion Self-DDoS

Scenario: A partner API allows you 100 requests/sec. Your batch job fires 5,000 requests in a tight loop as fast as your event loop can dispatch them.

What happens: the first ~100 succeed, then you get a wall of 429 Too Many Requests. Your retry logic re-fires them โ€” now you're sending more than 100/sec, the provider's edge starts tarpitting or blocking your IP, and your "job" takes 40 minutes instead of 50 seconds. You needed to pace yourself, not sprint and crash.

The Two Classic Algorithms

Almost every limiter in the wild is one of these two. The difference is entirely about burst tolerance.

Token Bucket โ€” allows bursts

A bucket holds up to B tokens and refills at rate R tokens/sec. Each request spends one token. If the bucket is full (idle period), you can spend all B at once โ€” a burst. Once drained, you're throttled to the steady refill rate R.

flowchart LR REFILL["Refill at R tokens/sec
(up to capacity B)"] --> BUCKET["๐Ÿชฃ Token bucket
holds โ‰ค B tokens"] REQ["Incoming request"] --> TAKE{"Token
available?"} BUCKET --> TAKE TAKE -->|yes| PASS["โœ… spend 1 token
โ†’ allow"] TAKE -->|no| WAIT["โณ block or reject
(429 / backoff)"]

Leaky Bucket โ€” smooths to a constant drain

Requests pour into a queue (the bucket) and leak out at a fixed rate. Output is perfectly smooth โ€” no bursts ever reach the downstream. If the bucket overflows, requests are dropped. Think of it as a shock absorber that converts spiky input into a flat, constant output stream.

flowchart LR IN["Bursty requests
arrive"] --> Q["๐Ÿชฃ Bucket / queue
(capacity C)"] Q --> LEAK["๐Ÿ’ง Leak at constant
rate R req/sec"] LEAK --> OUT["Smooth, constant
output stream"] Q -->|overflow| DROP["โŒ drop request"]
AlgorithmBurst behaviorUse case
Token bucketAllows bursts up to bucket size B, then throttles to RPublic APIs, user-facing quotas โ€” bursts are fine, average is capped
Leaky bucketNo bursts ever โ€” output is a flat constant rateProtecting a fragile downstream that hates spikes (legacy DB, SMTP relay)
Fixed windowCheap, but a 2ร— burst at window boundariesCoarse quotas where the boundary spike is acceptable
Sliding windowSmooth, no boundary spike, more state to trackAccurate per-minute/hour billing quotas
When in doubt, use token bucket. It's the friendliest to real traffic (which is naturally bursty) while still capping the long-run average. It's what most cloud providers implement.

In Threading โ€” a thread-safe token bucket

The canonical implementation: refill lazily on each acquire using a monotonic clock (never time.time() โ€” it can jump backwards on NTP corrections), and do all mutation under a Lock. Callers can either block until a token frees up, or fail fast and reject.

import time
import threading

class TokenBucket:
    def __init__(self, rate: float, capacity: float):
        self.rate = rate                # tokens added per second
        self.capacity = capacity        # max tokens (== max burst)
        self._tokens = capacity         # start full
        self._last = time.monotonic()   # monotonic: immune to clock jumps
        self._lock = threading.Lock()

    def _refill(self) -> None:
        now = time.monotonic()
        elapsed = now - self._last
        # add tokens for the time that passed, clamp to capacity
        self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
        self._last = now

    def try_acquire(self, n: int = 1) -> bool:
        """Non-blocking: spend n tokens if available, else reject (โ†’ 429)."""
        with self._lock:
            self._refill()
            if self._tokens >= n:
                self._tokens -= n
                return True
            return False

    def acquire(self, n: int = 1) -> None:
        """Blocking: sleep until n tokens are available, then spend them."""
        while True:
            with self._lock:
                self._refill()
                if self._tokens >= n:
                    self._tokens -= n
                    return
                # how long until we'll have enough?
                deficit = n - self._tokens
                wait = deficit / self.rate
            time.sleep(wait)   # sleep OUTSIDE the lock so other threads can refill/check
# Usage from a thread pool: shared limiter, 100 req/sec, burst of 100
from concurrent.futures import ThreadPoolExecutor

limiter = TokenBucket(rate=100, capacity=100)

def call_api(url):
    limiter.acquire()            # blocks here until the bucket allows it
    return session.get(url, timeout=5).status_code

with ThreadPoolExecutor(max_workers=20) as pool:
    results = list(pool.map(call_api, urls))   # paced to ~100/sec, absorbs bursts
Sleep outside the lock. Holding a Lock across a time.sleep serializes every thread behind the sleeper โ€” you'd get 1 req/sec, not 100. Compute the wait under the lock, release, then sleep.

In Asyncio โ€” an async token-bucket limiter

Same math, but await asyncio.sleep() instead of blocking โ€” the event loop stays free to run thousands of other coroutines while one waits for a token. Wrap it around a gather to pace fan-out.

import asyncio
import time

class AsyncTokenBucket:
    def __init__(self, rate: float, capacity: float):
        self.rate = rate
        self.capacity = capacity
        self._tokens = capacity
        self._last = time.monotonic()
        self._lock = asyncio.Lock()     # protects the refill math across coroutines

    async def acquire(self, n: int = 1) -> None:
        while True:
            async with self._lock:
                now = time.monotonic()
                self._tokens = min(self.capacity,
                                   self._tokens + (now - self._last) * self.rate)
                self._last = now
                if self._tokens >= n:
                    self._tokens -= n
                    return
                wait = (n - self._tokens) / self.rate
            await asyncio.sleep(wait)   # yields to the loop โ€” nothing else blocks
import httpx

async def main(urls):
    limiter = AsyncTokenBucket(rate=100, capacity=100)
    async with httpx.AsyncClient(timeout=5) as client:
        async def fetch(url):
            await limiter.acquire()          # pace every call through the bucket
            r = await client.get(url)
            return r.status_code
        # 5,000 coroutines, but they leave at โ‰ค100/sec โ€” no 429 storm
        return await asyncio.gather(*(fetch(u) for u in urls))

asyncio.run(main(urls))
Pair it with a semaphore. The token bucket caps the rate; an asyncio.Semaphore caps concurrency. Real services use both: the bucket says "โ‰ค100/sec", the semaphore says "โ‰ค20 in flight" so a latency spike can't blow up your open sockets.

In Multiprocessing โ€” per-process buckets don't coordinate

Here's the trap: if each of your 4 worker processes owns its own TokenBucket(rate=100), your service actually sends 400 req/sec. Buckets are process-local state; they have no idea the others exist. You need a shared, centralized limiter.

# A crude shared bucket via multiprocessing.Manager (single machine only).
# The Manager hosts the state in one process; workers RPC into it under a lock.
import time
from multiprocessing import Manager, Process

def make_shared_bucket(mgr, rate, capacity):
    state = mgr.dict(tokens=capacity, last=time.monotonic())
    lock = mgr.Lock()
    return state, lock, rate, capacity

def acquire(state, lock, rate, capacity, n=1):
    while True:
        with lock:
            now = time.monotonic()
            state["tokens"] = min(capacity,
                                  state["tokens"] + (now - state["last"]) * rate)
            state["last"] = now
            if state["tokens"] >= n:
                state["tokens"] -= n
                return
            wait = (n - state["tokens"]) / rate
        time.sleep(wait)
A Manager works on one box; it does NOT scale out. The moment you have multiple machines/pods, the only correct answer is an external shared limiter โ€” Redis. Same reason DB pools must stay under a global cap: the limit is a property of the fleet, not the process.

Client-Side vs Server-Side Limiting

WhereWho it protectsTypical tool
Client-sideProtects the downstream from you (stay under their quota, avoid 429s)Your own token bucket, aiolimiter, respect Retry-After
Server-sideProtects your service from abusive/noisy callersAPI gateway, Nginx limit_req, Envoy, per-key Redis bucket
You almost always need both. Client-side keeps you a good citizen of the APIs you call; server-side keeps one bad tenant from starving everyone else on your own endpoints. And always honor a 429's Retry-After header โ€” it's the server telling you exactly how long to back off.

Distributed Rate Limiting โ€” one bucket for the whole fleet

With N stateless pods behind a load balancer, the rate limit must live outside any single process. Redis is the standard home for it: one shared bucket, mutated atomically. Do the refill + spend in a single Lua script so the check-and-decrement can't race across pods.

import redis, time

r = redis.Redis()

# Atomic token bucket in Redis. KEYS[1]=bucket key.
# ARGV: rate, capacity, now(seconds), requested tokens.
# Returns 1 if allowed, 0 if throttled.
TOKEN_BUCKET_LUA = """
local key      = KEYS[1]
local rate     = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local want     = tonumber(ARGV[4])

local data   = redis.call('HMGET', key, 'tokens', 'last')
local tokens = tonumber(data[1]) or capacity
local last   = tonumber(data[2]) or now

tokens = math.min(capacity, tokens + (now - last) * rate)  -- lazy refill
local allowed = 0
if tokens >= want then
    tokens = tokens - want
    allowed = 1
end
redis.call('HMSET', key, 'tokens', tokens, 'last', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) + 1)   -- self-cleaning
return allowed
"""

_take = r.register_script(TOKEN_BUCKET_LUA)

def allow(key: str, rate=100, capacity=100, want=1) -> bool:
    # every pod calls this against the SAME key โ†’ one true fleet-wide limit
    return bool(_take(keys=[key], args=[rate, capacity, time.time(), want]))
# Per-user (or per-API-key) limiting is just a key prefix:
def allow_user(user_id: str) -> bool:
    return allow(f"ratelimit:{user_id}", rate=10, capacity=20)

if not allow_user(request.user_id):
    raise HTTP429(retry_after=1)   # tell the caller to back off
Why Lua: "read tokens โ†’ check โ†’ write tokens" is a read-modify-write. Split across two round-trips and two pods will both see tokens available and both spend โ€” you overshoot the limit under load. A Lua script runs atomically on the Redis server, so the whole bucket update is one indivisible step. Battle-tested libs: redis-cell (a Redis module) or limits / slowapi for FastAPI.

Key Takeaways

ModelLimiterScope of truth
ThreadingTokenBucket with a Lock, sleep outside the lockOne shared instance across threads
AsyncioAsyncTokenBucket + await asyncio.sleep, pair with a semaphoreOne shared instance per event loop
MultiprocessingManager-hosted bucket (one box) โ€” per-process buckets multiply the rateOne centralized instance
Multi-instance / distributedRedis token bucket via atomic LuaOne key = the whole fleet's limit
Checklist: โ‘  monotonic clock, never wall clock โ‘ก mutate under a lock, sleep outside it โ‘ข token bucket for bursty real traffic, leaky bucket to protect fragile downstreams โ‘ฃ rate and concurrency limits together โ‘ค honor Retry-After โ‘ฅ one bucket per fleet in Redis, updated atomically.