Redis-Based Rate Limiting — Atomic Counters for Production Systems
In this tutorial, you will learn about Redis. We cover key concepts, practical examples, and best practices to help you master this topic.
Redis provides the atomic operations, data structures, and Lua scripting needed for distributed Rate Limiting across multiple server instances, with INCR/EXPIRE for fixed window and sorted sets for Sliding Window.
What You'll Learn
- Redis atomic operations for rate limit counters
- Fixed window with INCR and EXPIRE
- Sliding window with sorted sets (ZADD, ZREMRANGEBYSCORE)
- Lua scripting for atomic multi-key operations
Why It Matters
In-memory rate limiting breaks when you run multiple server instances. A client can exceed the limit by hitting different servers. Redis provides a single source of truth that all instances share, with atomic operations that prevent race conditions.
Real-World Use
Durga Antivirus Pro runs 8 gateway instances behind a load balancer. Each instance checks Redis for rate limit state. If a partner hits their 1000 req/min limit on instance 3, instance 7 also knows because both reference the same Redis counter.
flowchart LR
Client["Client"] --> LB["Load Balancer"]
LB --> GW1["Gateway 1"]
LB --> GW2["Gateway 2"]
LB --> GW3["Gateway N"]
GW1 --> Redis["Redis\nRate Limit State"]
GW2 --> Redis
GW3 --> Redis
style Redis fill:#dbeafe,stroke:#2563eb
Fixed Window with INCR and EXPIRE
import redis
import time
redis_client = redis.Redis(host="redis", port=6379, db=0)
def check_rate_limit_fixed(client_id, max_requests, window_seconds):
now = int(time.time())
window = now - (now % window_seconds)
key = f"rl:fixed:{client_id}:{window}"
count = redis_client.incr(key)
if count == 1:
redis_client.expire(key, window_seconds * 2)
remaining = max(0, max_requests - count)
reset_time = window + window_seconds
if count > max_requests:
return False, 0, reset_time
return True, remaining, reset_time
Sliding Window with Redis Sorted Sets
def check_rate_limit_sliding(client_id, max_requests, window_seconds):
now = time.time()
key = f"rl:sliding:{client_id}"
cutoff = now - window_seconds
# Atomic Lua script
lua = """
local key = KEYS[1]
local cutoff = ARGV[1]
local now = ARGV[2]
local max_req = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, 0, cutoff)
local count = redis.call('ZCARD', key)
if count < max_req then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, ttl)
return {1, max_req - count - 1, cutoff + max_req}
end
return {0, 0, cutoff + max_req}
"""
result = redis_client.eval(
lua, 1, key, cutoff, now,
max_requests, window_seconds * 2
)
allowed = result[0] == 1
remaining = result[1]
reset_time = int(result[2])
return allowed, remaining, reset_time
Token Bucket with Redis
def check_token_bucket(client_id, rate, capacity):
key = f"rl:tb:{client_id}"
now = time.time()
lua = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local capacity = tonumber(ARGV[3])
local ttl = tonumber(ARGV[4])
local data = redis.call('GET', key)
if data then
local tokens, last_refill = unpack(cjson.decode(data))
local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= 1 then
tokens = tokens - 1
redis.call('SET', key, cjson.encode({tokens, now}), 'EX', ttl)
return {1, tokens}
else
return {0, tokens}
end
else
local tokens = capacity - 1
redis.call('SET', key, cjson.encode({tokens, now}), 'EX', ttl)
return {1, tokens}
end
"""
result = redis_client.eval(lua, 1, key, now, rate, capacity, 3600)
return result[0] == 1, int(result[1])
Rate Limit Middleware with Redis
from flask import Flask, request, jsonify, g
import redis
app = Flask(__name__)
redis_client = redis.Redis(host="redis", port=6379, db=0)
def get_client_id():
api_key = request.headers.get("X-API-Key")
if api_key:
return f"apikey:{api_key}"
return f"ip:{request.remote_addr}"
@app.before_request
def rate_limit_middleware():
client_id = get_client_id()
route = request.path
# Different limits per route
limits = {
"/api/login": (5, 60), # 5 req/min
"/api/scan": (100, 60), # 100 req/min
"/api/report": (1000, 3600), # 1000 req/hour
}
max_req, window = limits.get(route, (100, 60))
allowed, remaining, reset_time = check_rate_limit_sliding(
f"{client_id}:{route}", max_req, window
)
g.rate_limit_remaining = remaining
g.rate_limit_reset = reset_time
if not allowed:
return jsonify({
"error": "Rate limit exceeded",
"retry_after": int(reset_time - time.time())
}), 429
Common Mistakes
1. Not Using Atomic Operations
INCR + check separately allows race conditions. Use INCR and check in one step, or use Lua scripting.
2. Not Setting TTL
Without TTL, Redis fills with stale keys for inactive clients. Always set TTL to 2x the window.
3. Using a Single Redis Instance as SPOF
If Redis goes down, rate limiting stops working. Use Redis Sentinel or Cluster for high availability.
4. High Network Round-Trips
Each Redis call adds latency. Use pipelining or Lua scripts to reduce round-trips. Consider local Caching.
5. Ignoring Redis Performance Under Load
A single Redis instance handles ~100K ops/sec. At high scale, use Redis Cluster to shard rate limit keys.
Practice Questions
- Why is Redis necessary for distributed rate limiting?
- How does INCR + EXPIRE implement fixed window rate limiting?
- Why must Redis operations be atomic for accurate rate limiting?
- How does Lua scripting improve Redis rate limiting performance?
- What happens to rate limits when Redis is unavailable?
Answers:
- Multiple server instances need a shared state. Redis provides atomic operations and persistence that all instances access.
- INCR increments the counter atomically, and EXPIRE sets TTL to auto-cleanup the key after the window expires.
- Without atomicity, two instances can read the same counter, both see room available, and both allow a request, exceeding the limit.
- Lua scripts execute atomically on the Redis server, eliminating round-trips and race conditions between multiple commands.
- Rate limit enforcement stops. Clients may exceed limits. Implement a fallback (allow requests) or fail-closed (deny requests).
Challenge: Implement a Redis rate limiter with local caching that checks the cache first (fast path) and only queries Redis when the cache suggests the limit is near. Handle cache invalidation.
FAQ
Mini Project
Build a Flask middleware with Redis-backed rate limiting. Implement both fixed window (INCR/EXPIRE) and sliding window (ZSET) algorithms. Allow per-route limits. Return X-RateLimit-* headers. Add a /rate-limit-status admin endpoint to view current limits.
What's Next
Continue with Distributed Rate Limiting for multi-region systems, or explore IP-Based Rate Limiting for per-address enforcement.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro