Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes

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

Sponsored

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:

  1. Identify the client — Extract from request (e.g., request.remote_addr, X-Forwarded-For, or API key).
  2. Build the Redis key — Typically ratelimit:{client_id}:{endpoint} so different endpoints have separate limits.
  3. Clean old entries — Use ZREMRANGEBYSCORE key -inf (now - window_seconds) to evict expired timestamps.
  4. Count current windowZCARD key returns how many timestamps remain.
  5. Check limit — If count >= max_requests, reject with 429 Too Many Requests.
  6. Allow request — If under limit, ZADD key now now to record the request. Optionally set EXPIRE for TTL cleanup.
  7. Return headers — Include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset for 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 LOAD to 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 + EVALSHA for atomicity, or accept the rare false positive.
  • Forgetting to decode responsesr.get(key) returns bytes if decode_responses=False. Always set decode_responses=True or 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/EXPIRE for 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 EXPIRE on sorted set keys — the key grows unboundedly and eventually consumes all Redis memory.
  • Hardcoding request.remote_addr behind a reverse proxy — must parse X-Forwarded-For header 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

  1. Use INCR with EXPIRE for a fixed-window limiter — simpler but less accurate at window boundaries.
  2. Implement a token bucket algorithm in Lua for smoother throttling than any sliding window.
  3. Use Python's functools.lru_cache locally 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.