Rate Limiting with Redis Token Bucket Algorithm
Implement a distributed token bucket rate limiter using Redis atomic operations for API throttling across multiple server instances
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Rate Limiting with Redis Token Bucket Algorithm
Rate limiting protects APIs from abuse and ensures fair resource allocation. The token bucket algorithm allows bursts up to a configured capacity while maintaining a steady refill rate. Using Redis with a Lua script makes the check-and-decrement atomic, so the limiter works correctly across multiple server instances.
When to Use This
- Public APIs that need per-client or per-IP rate limiting
- Login or password reset endpoints that need brute-force protection
- Any service where requests must be throttled across multiple instances
Prerequisites
- Python 3.10+
redispackage (pip install redis)
Solution
1. Install Dependencies
pip install redis
2. Implement the Token Bucket with Lua
import time
from redis import Redis
TOKEN_BUCKET_LUA = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- Refill tokens based on elapsed time
local elapsed = math.max(0, now - last_refill)
local refilled = elapsed * refill_rate
tokens = math.min(capacity, tokens + refilled)
if tokens < requested then
-- Not enough tokens — reject
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 0
end
-- Consume tokens
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 1
"""
class TokenBucketRateLimiter:
def __init__(
self,
redis_client: Redis,
capacity: int = 100,
refill_rate: float = 10.0,
):
self.redis = redis_client
self.capacity = capacity
self.refill_rate = refill_rate
self._script = self.redis.register_script(TOKEN_BUCKET_LUA)
def allow_request(
self,
identifier: str,
requested: int = 1,
) -> bool:
"""Check if a request is allowed under the rate limit.
Args:
identifier: Unique key (e.g., IP address, user ID, API key).
requested: Number of tokens to consume.
Returns:
True if allowed, False if rate limited.
"""
key = f"ratelimit:{identifier}"
now = time.time()
result = self._script(
keys=[key],
args=[self.capacity, self.refill_rate, now, requested],
)
return bool(result)
3. Use as Middleware (FastAPI)
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
limiter = TokenBucketRateLimiter(
redis_client=redis.Redis(host="localhost", port=6379),
capacity=100,
refill_rate=10.0, # 10 tokens per second
)
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
client_ip = request.client.host
if not limiter.allow_request(f"ip:{client_ip}"):
return JSONResponse(
status_code=429,
content={"error": "Rate limit exceeded", "retryAfter": 10},
headers={"Retry-After": "10"},
)
return await call_next(request)
@app.get("/api/data")
async def get_data():
return {"data": "success"}
4. Per-User Rate Limiting
@app.get("/api/profile")
async def get_profile(request: Request):
user_id = request.headers.get("X-User-ID", "anonymous")
if not limiter.allow_request(f"user:{user_id}"):
raise HTTPException(
status_code=429,
detail="Rate limit exceeded",
headers={"Retry-After": "10"},
)
return {"profile": fetch_profile(user_id)}
5. Tiered Rate Limits
Different API tiers with different limits:
class TieredRateLimiter:
TIERS = {
"free": {"capacity": 60, "refill_rate": 1.0}, # 60/min
"pro": {"capacity": 600, "refill_rate": 10.0}, # 600/min
"enterprise": {"capacity": 6000, "refill_rate": 100.0}, # 6000/min
}
def __init__(self, redis_client: Redis):
self.redis = redis_client
self._limiters = {
tier: TokenBucketRateLimiter(redis_client, **config)
for tier, config in self.TIERS.items()
}
def allow_request(self, identifier: str, tier: str = "free") -> bool:
limiter = self._limiters.get(tier, self._limiters["free"])
return limiter.allow_request(f"{tier}:{identifier}")
# Usage
tiered = TieredRateLimiter(redis_client)
@app.get("/api/search")
async def search(request: Request):
user_id = request.headers["X-User-ID"]
user_tier = get_user_tier(user_id) # "free", "pro", or "enterprise"
if not tiered.allow_request(user_id, tier=user_tier):
raise HTTPException(status_code=429, detail="Rate limit exceeded")
return search_results(query=request.query_params.get("q", ""))
How It Works
- Token bucket starts full with
capacitytokens. Each request consumes one or more tokens. - Refill happens continuously:
elapsed_time * refill_ratetokens are added, up tocapacity. This allows bursts up tocapacitywhile maintaining an average rate ofrefill_ratetokens/second. - Lua script makes the check-and-decrement atomic — no race condition between reading the token count and consuming a token, even with multiple server instances.
EXPIREsets a TTL on the bucket key so inactive identifiers are cleaned up automatically.- Identifier-based keys (
ratelimit:ip:192.168.1.1,ratelimit:user:123) allow independent limits per client, user, or API key.
Variants
Sliding Window Rate Limiter
An alternative that counts requests in a sliding time window:
SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- Remove old entries outside the window
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
end
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('EXPIRE', key, window / 1000 + 1)
return 1
"""
class SlidingWindowRateLimiter:
def __init__(self, redis_client: Redis, window_ms: int = 60000, limit: int = 100):
self.redis = redis_client
self.window = window_ms
self.limit = limit
self._script = redis_client.register_script(SLIDING_WINDOW_LUA)
def allow_request(self, identifier: str) -> bool:
key = f"ratelimit:sw:{identifier}"
now = int(time.time() * 1000)
return bool(self._script(
keys=[key],
args=[self.window, self.limit, now],
))
Fixed Window Rate Limiter
Simpler but less accurate — resets at fixed intervals:
class FixedWindowRateLimiter:
def __init__(self, redis_client: Redis, window_seconds: int = 60, limit: int = 100):
self.redis = redis_client
self.window = window_seconds
self.limit = limit
def allow_request(self, identifier: str) -> bool:
window_key = int(time.time()) // self.window
key = f"ratelimit:fw:{identifier}:{window_key}"
pipe = self.redis.pipeline()
pipe.incr(key)
pipe.expire(key, self.window)
count, _ = pipe.execute()
return count <= self.limit
Best Practices
-
For a deeper guide, see API Security Checklist — Authentication to Encryption.
-
Use Lua scripts for atomicity — without it, concurrent requests can consume more tokens than allowed
-
Set TTLs on bucket keys — prevents memory growth from inactive identifiers
-
Return
Retry-Afterheader — tells clients when to retry, improving UX -
Monitor rate limit hits — a sudden spike in 429s may indicate a misconfigured client or an attack
Common Mistakes
- Using
INCRwithout atomicity — reading the count and then deciding to allow/deny creates a race condition - Not setting a TTL — bucket keys for every IP/user accumulate without cleanup
- Using the same bucket for all clients — one slow client depletes the bucket for everyone
- Forgetting to refill — without the refill calculation, the bucket never recovers after depletion
FAQ
Q: Token bucket vs. sliding window — which should I use? A: Token bucket allows bursts up to capacity and is memory-efficient. Sliding window is more precise but uses more memory (one entry per request).
Q: How do I handle rate limits for authenticated vs. unauthenticated requests?
A: Use different identifier prefixes: ratelimit:ip:<ip> for unauthenticated, ratelimit:user:<id> for authenticated, with different capacities.
Q: What happens if Redis is down? A: The rate limiter fails. Decide on a fail-open (allow all) or fail-closed (deny all) strategy based on your security requirements.
Q: Can I use this with Redis Cluster?
A: Yes. The Lua script operates on a single key, so it works within a single shard. Use hash tags (ratelimit:{user_id}) to ensure keys for the same user land on the same shard.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Common Mistakes
- Using
INCRalone without a Lua script — creates race conditions between increment and expiry checks - Not setting a TTL on rate limit keys — memory grows unbounded for users who make a single request
- Using the same bucket for all endpoints — a burst to one API consumes tokens for unrelated endpoints
- Not handling Redis downtime gracefully — decide whether to fail open (allow request) or fail closed (deny)
- Using millisecond timestamps instead of seconds in the Lua script — Redis
TIMEreturns seconds and microseconds, not milliseconds - Not monitoring token bucket refill rate vs actual traffic — if refill rate is too low, legitimate users get rate limited
- Not using
EVALSHAinstead ofEVAL— sending the full Lua script on every request wastes bandwidth,EVALSHAcaches the script by hash - Not setting a maximum bucket capacity — unlimited capacity allows users to accumulate tokens during idle periods and burst past the intended rate limit
- Not using per-user and per-endpoint keys — a single user can exhaust their bucket on one endpoint while another endpoint remains unaffected, which may or may not be desired
- Not logging rate limit decisions — without logs of allow/deny per user, you cannot debug complaints about unexpected 429 responses
- Not using a consistent key naming convention — mixing
rate_limit:user:ipandrl:user:ippatterns makes monitoring and debugging harder - Not testing rate limiting under load — a rate limiter that works in development may fail under high concurrency due to Redis pipelining or network latency
What is the difference between token bucket and sliding window?
Token bucket refills tokens at a fixed rate and allows bursts up to the bucket capacity. Sliding window counts requests in a moving time window and rejects when the count exceeds the limit. Token bucket is better for bursty traffic. Sliding window is better for strict rate enforcement.
How do I test rate limiting locally?
Use a script that sends N concurrent requests and counts how many return 200 vs 429. Vary the burst size and refill rate to verify the bucket behavior. Use redis-cli MONITOR to observe the Lua script executions in real time.
Related Resources
Implement the Cache-Aside Pattern with Redis
Use the cache-aside pattern to read and write data through Redis, handling cache misses, stale reads, and write-through invalidation
RecipeBuild a Real-Time Leaderboard with Redis Sorted Sets
Use Redis sorted sets to implement real-time leaderboards with rank tracking, score updates, and top-N queries in O(log N) time
RecipeDistributed Locking with Redis and Redlock
Implement distributed locks with Redis for mutual exclusion across processes, using SET NX with TTL and the Redlock algorithm for reliability
RecipeDistributed Rate Limiting with FastAPI and Redis
Implement distributed rate limiting in FastAPI using Redis sliding window and token bucket algorithms with per-user, per-IP, and per-endpoint limits