Rate Limiting with Redis & Flask
Implement rate limiting with Redis on Flask. Master sliding-window counters using Redis sorted sets & INCR commands in a full walkthrough tutorial.
Focus: implement rate limiting with redis on flask
Your Flask API is humming in production when suddenly a single client fires 10,000 requests in one minute. Your database chokes, response times crater, and legitimate users get 503 errors. Without rate limiting, your app is defenseless against abusive traffic, accidental misconfiguration, or DDoS-like behavior. By the end of this lesson, you'll know how to implement a robust, production-grade rate limiter using Redis and Flask — protecting your endpoints while keeping latency low.
The problem this lesson solves
Without rate limiting, any client can hammer your API endpoints without restriction, causing resource exhaustion, degraded service for other users, and potentially massive cloud bills. Traditional solutions like Nginx rate limiting or IP-table rules are coarse and lack application-level awareness. You need a scalable, low-latency way to track and enforce per-user or per-IP request quotas — and Redis is the ideal tool for the job.
Common symptoms of missing rate limiting:
- Database connection storms — 10x normal load crashes your DB pool
- Uneven resource usage — one tenant consumes all compute
- Security exploits — credential stuffing attacks go unchecked
Redis solves this by providing an in-memory key-value store with atomic operations (INCR, EXPIRE) and sub-millisecond latency, making it perfect for counting requests per time window.
Core concept / mental model
Think of rate limiting like a coffee shop with a limit of one drink per customer per minute. The barista (Redis) keeps a ledger: for each customer, they note the timestamp of each order. If you try to order within the same minute, you're politely asked to wait.
Fixed window vs. Sliding window
- Fixed window: Count requests in discrete 60-second buckets (e.g., 00:00–00:59). Simple but can be bursty at boundaries (e.g., 100 requests at 00:59, then another 100 at 01:00).
- Sliding window: Count requests over the last 60 seconds at any point in time. Eliminates boundary bursts but requires tracking individual timestamps.
Redis makes sliding-window rate limiting practical using sorted sets—each user's request timestamps are stored as members sorted by score (timestamp). We periodically clean old entries and count the window size.
Key Redis commands
| Command | Role in rate limiting |
|---|---|
ZADD |
Adds a timestamp to a sorted set keyed by user/IP |
ZREMRANGEBYSCORE |
Removes timestamps older than the window |
ZCARD |
Returns the count of elements (current window) |
EXPIRE |
Auto-cleanup after idle period |
INCR / EXPIRE |
Simpler fixed-window alternative (atomic counter) |
How it works step by step
Here's the logical flow for a sliding-window rate limiter:
- Identify the client — Extract from request (e.g.,
request.remote_addr,X-Forwarded-For, or API key). - Build the Redis key — Typically
ratelimit:{client_id}:{endpoint}so different endpoints have separate limits. - Clean old entries — Use
ZREMRANGEBYSCORE key -inf (now - window_seconds)to evict expired timestamps. - Count current window —
ZCARD keyreturns how many timestamps remain. - Check limit — If count >=
max_requests, reject with 429 Too Many Requests. - Allow request — If under limit,
ZADD key now nowto record the request. Optionally setEXPIREfor TTL cleanup. - Return headers — Include
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Resetfor client visibility.
Pro tip: Use
time.time()for Unix timestamps. This ensures millisecond precision and avoids clock drift issues across servers.
Hands-on walkthrough
Let's build a reusable Flask decorator @rate_limit using Redis. We'll cover both sliding window (sorted sets) and fixed window (INCR) patterns.
Prerequisites
Install dependencies:
pip install flask redis
Example 1: Fixed window rate limiter (INCR + EXPIRE)
import time
from functools import wraps
from flask import Flask, request, jsonify
import redis
app = Flask(__name__)
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def rate_limit_fixed(max_requests=10, window_seconds=60):
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
client_id = request.remote_addr
key = f"ratelimit:fixed:{client_id}"
current = r.get(key)
if current is None:
# First request: set count=1, expire after window
r.setex(key, window_seconds, 1)
remaining = max_requests - 1
else:
current_count = int(current)
if current_count >= max_requests:
return jsonify({"error": "Rate limit exceeded"}), 429
r.incr(key)
remaining = max_requests - current_count - 1
return f(*args, **kwargs)
return decorated
return decorator
@app.route('/api/fixed')
@rate_limit_fixed(max_requests=5, window_seconds=10)
def fixed_endpoint():
return jsonify({"status": "ok", "limit_type": "fixed"})
Expected output (curl 6 times in 10 seconds):
{"status": "ok", "limit_type": "fixed"}
...
{"error": "Rate limit exceeded"}
Example 2: Sliding window rate limiter (sorted sets)
This is the production-grade approach:
def rate_limit_sliding(max_requests=10, window_seconds=60):
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
client_id = request.remote_addr
now = time.time()
window_start = now - window_seconds
key = f"ratelimit:sliding:{client_id}"
# Remove old entries outside the window
r.zremrangebyscore(key, '-inf', window_start)
# Count current entries in window
current_count = r.zcard(key)
if current_count >= max_requests:
# Get the oldest timestamp for TTR header
oldest = r.zrange(key, 0, 0, withscores=True)
if oldest:
reset_time = int(oldest[0][1] + window_seconds)
else:
reset_time = int(now + window_seconds)
return jsonify({
"error": "Rate limit exceeded",
"retry_after": reset_time - int(now)
}), 429
# Record this request
r.zadd(key, {now: now})
# Set TTL for automatic cleanup if key goes idle
r.expire(key, window_seconds * 2)
return f(*args, **kwargs)
return decorated
return decorator
@app.route('/api/sliding')
@rate_limit_sliding(max_requests=5, window_seconds=10)
def sliding_endpoint():
return jsonify({"status": "ok", "limit_type": "sliding"})
Expected output — same as fixed but with reset time header.
Example 3: Using Lua scripting for atomicity
To avoid race conditions between cleanup and check, use a Lua script executed atomically on the Redis server:
LUA_RATE_LIMIT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- Remove old entries
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
-- Count remaining
local count = redis.call('ZCARD', key)
if count >= limit then
return 0 -- blocked
end
-- Add current request
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window * 2)
return 1 -- allowed
"""
def rate_limit_lua(max_requests=10, window_seconds=60):
sha = r.script_load(LUA_RATE_LIMIT)
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
client_id = request.remote_addr
key = f"ratelimit:lua:{client_id}"
now = time.time()
result = r.evalsha(sha, 1, key, now, window_seconds, max_requests)
if result == 0:
return jsonify({"error": "Rate limit exceeded"}), 429
return f(*args, **kwargs)
return decorated
return decorator
Pro tip: Load the script once with
SCRIPT LOADto avoid re-sending the 100-byte Lua string on every request.
Including rate limit headers
To add headers, wrap the response object:
def decorated(*args, **kwargs):
# ... logic above ...
resp = f(*args, **kwargs)
if isinstance(resp, tuple):
body, status = resp
else:
body, status = resp, 200
response = jsonify(body)
response.status_code = status
response.headers['X-RateLimit-Limit'] = str(max_requests)
response.headers['X-RateLimit-Remaining'] = str(max(0, max_requests - current_count - 1))
return response
Compare options / when to choose what
| Approach | Complexity | Accuracy | Atomicity | Best for |
|---|---|---|---|---|
| INCR + EXPIRE (fixed window) | Low | Low (boundary bursts) | Yes (single command) | Simple scripts, prototyping |
| Sorted sets (sliding window) | Medium | High | No (room for race) | Production APIs, per-user limits |
| Lua script (sliding window) | Medium-High | High | Yes (server-side) | High-traffic, strict enforcement |
- Choose INCR if you accept boundary bursts and want minimal code.
- Choose sorted sets if you need accurate sliding windows and can tolerate rare race conditions (e.g., two concurrent requests may both pass just after window edge).
- Choose Lua for atomicity under heavy concurrency. The slight complexity pays off for mission-critical endpoints.
Troubleshooting & edge cases
Common mistakes
- Not calling EXPIRE — Sorted set keys grow forever if clients never clean up. Always set a TTL (e.g.,
window_seconds * 2). - Time zone confusion — Use
time.time()(Unix epoch, UTC), not local time. Redis doesn't care about timezones. - Race condition in Python — Without Lua, two concurrent requests can pass after both clean out the same old entries. Use
SCRIPT LOAD+EVALSHAfor atomicity, or accept the rare false positive. - Forgetting to decode responses —
r.get(key)returns bytes ifdecode_responses=False. Always setdecode_responses=Trueor explicitly decode. - Hardcoding request.remote_addr — Behind a reverse proxy, use
request.headers.get('X-Forwarded-For', request.remote_addr).split(',')[0].strip(). - Using a single key for all endpoints — Each endpoint should have its own key (e.g.,
ratelimit:{client}:{endpoint}) to allow different limits per route.
Edge case: Burst vs. sustained rate
Sliding window still allows bursts up to the limit within a sub-window. For smoother throttling, consider token bucket or leaky bucket algorithms (use a Lua script with INCR + EXPIRE to implement token refill).
What you learned & what's next
You've learned how to implement rate limiting with Redis on Flask using two production patterns:
- Fixed window with
INCR/EXPIREfor simplicity - Sliding window with sorted sets for accuracy
- Lua scripting for atomic operation
You can now protect any Flask endpoint by adding a @rate_limit decorator, and you understand the trade-offs between accuracy and complexity.
What's next: In the next lesson, you'll extend this pattern to distributed rate limiting across multiple Flask instances using Redis as a shared counter. You'll also explore token bucket algorithms and how to dynamically adjust limits per user tier.
Ready to harden your API? Run the example code against your local Redis instance and watch the 429s roll in.
Practice recap
Try extending the sliding window example to support per-endpoint limits (e.g., 30 req/min on /api/upload, 100 req/min on /api/list). Then add a Redis hash mapping route names to their limits, fetched dynamically in the decorator. Finally, stress-test with ab -n 200 -c 10 http://localhost:5000/api/sliding to confirm 429s appear after the limit.
Common mistakes
- Not calling
EXPIREon sorted set keys — the key grows unboundedly and eventually consumes all Redis memory. - Hardcoding
request.remote_addrbehind a reverse proxy — must parseX-Forwarded-Forheader instead. - Using a single Redis key for all endpoints — different routes need separate keys to support distinct rate limits.
- Forgetting to decode bytes responses when
decode_responses=False— causes TypeError in integer comparisons.
Variations
- Use
INCRwithEXPIREfor a fixed-window limiter — simpler but less accurate at window boundaries. - Implement a token bucket algorithm in Lua for smoother throttling than any sliding window.
- Use Python's
functools.lru_cachelocally for low-traffic endpoints, then switch to Redis when scaling horizontally.
Real-world use cases
- API gateway throttling per user API key — e.g., 1000 requests/hour for free tier, 10000 for premium tier.
- Login endpoint rate limiting to prevent credential stuffing — 5 attempts per IP per minute.
- Webhook delivery with backpressure — limit outbound retries to 10 per minute per recipient URL.
Key takeaways
- Rate limiting protects your API from abuse and resource exhaustion using Redis's fast in-memory operations.
- Sliding windows (sorted sets) are more accurate than fixed windows (INCR) and eliminate boundary bursts.
- Use Lua scripting for atomic rate limit checks to prevent race conditions under concurrent load.
- Always set EXPIRE on rate limit keys to avoid unbounded key growth and memory leaks.
- Include X-RateLimit headers in responses so clients can self-regulate their request rate.
- Behind proxies, identify the real client IP from X-Forwarded-For, not request.remote_addr.
Keep learning
Related tutorials, quizzes, and articles for this topic.
More in this track
Quizzes
Articles
- Why Chasing 'Perfect Code' Is Actually Making Your Team Slower and More Fragile
- The Rise of Python Notebooks as Operational Tools: Why Jupyter Is Moving Beyond Data Science Into DevOps and Monitoring
- The Legacy Database Problem: Why Python Teams Are Migrating to SQLite and DuckDB for Production Workloads
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.