Python Web App Rate Limiting With Redis

Written by

in

When you build a Python web application that serves thousands—or even millions—of requests, protecting your API from abuse is essential. Rate limiting is the go‑to strategy for throttling traffic, preventing denial‑of‑service attacks, and ensuring a fair usage policy for all users. Among the many back‑ends you can choose, Redis stands out for its speed, atomic operations, and built‑in data structures that make implementing robust rate limits both simple and scalable. In this post we’ll explore why Redis is ideal for rate limiting, walk through several popular algorithms, and provide ready‑to‑use code snippets for Flask, Django, and FastAPI.

Why Use Redis for Rate Limiting?

Redis offers a unique blend of features that make it perfect for throttling requests in real time:

  • In‑memory speed: Sub‑millisecond latency ensures your rate‑limit checks don’t become a bottleneck.
  • Atomic commands: Operations like INCR, EXPIRE, and Lua scripts run atomically, eliminating race conditions in high‑concurrency environments.
  • TTL support: Expiration times let you automatically reset counters without extra cleanup logic.
  • Distributed safety: Multiple web workers can share the same Redis instance, guaranteeing consistent limits across a cluster.
  • Rich data structures: Sorted sets, hashes, and strings enable sophisticated algorithms such as sliding windows or token buckets.

Choosing the Right Rate‑Limiting Algorithm

Not every algorithm fits every use case. Below are the three most common patterns you can implement with Redis, along with their trade‑offs.

1. Fixed Window Counter

The simplest approach: count requests in a fixed time bucket (e.g., per minute). If the count exceeds the allowed threshold, reject the request.

  • Pros: Easy to understand, minimal Redis commands.
  • Cons: Bursts at bucket boundaries can exceed the limit (known as the “thundering herd” problem).

2. Sliding Window Log

Store timestamps of each request in a sorted set and prune entries older than the window. This yields a true sliding window behavior.

  • Pros: Precise enforcement; no burst spikes.
  • Cons: Higher memory usage and more Redis operations per request.

3. Token Bucket (or Leaky Bucket)

Maintain a bucket that refills at a steady rate. Each request consumes a token; if the bucket is empty, the request is throttled.

  • Pros: Allows short bursts while preserving an average rate.
  • Cons: Slightly more complex logic, but still a perfect fit for Redis Lua scripts.

Implementing Fixed Window Counter in Flask

Below is a minimal Flask middleware that limits each IP address to 100 requests per minute using Redis.

from flask import Flask, request, jsonify
import redis
import time

app = Flask(__name__)
r = redis.StrictRedis(host='localhost', port=6379, db=0)

LIMIT = 100          # requests
WINDOW = 60          # seconds

def is_allowed(ip):
    key = f"rl:{ip}"
    # Increment the counter and set expiry if this is the first hit
    current = r.incr(key)
    if current == 1:
        r.expire(key, WINDOW)
    return current <= LIMIT

@app.before_request
def rate_limit():
    ip = request.remote_addr
    if not is_allowed(ip):
        return jsonify({"error": "Rate limit exceeded"}), 429

@app.route('/')
def hello():
    return "Hello, world!"

if __name__ == '__main__':
    app.run()

This example demonstrates the core Redis commands (INCR and EXPIRE) that keep the implementation atomic and fast.

Sliding Window Log with Redis Sorted Sets (Django)

Django developers can leverage a custom middleware that uses a sorted set (ZSET) to store request timestamps. The following snippet shows how to limit a user (identified by user.id) to 20 actions per 10 seconds.

import time
import redis
from django.utils.deprecation import MiddlewareMixin
from django.http import JsonResponse

r = redis.StrictRedis(host='localhost', port=6379, db=0)

class SlidingWindowRateLimitMiddleware(MiddlewareMixin):
    LIMIT = 20
    WINDOW = 10  # seconds

    def process_request(self, request):
        if not request.user.is_authenticated:
            return  # skip unauthenticated users or handle differently

        user_key = f"rl:user:{request.user.id}"
        now = int(time.time() * 1000)  # milliseconds

        # Add current timestamp to the sorted set
        r.zadd(user_key, {now: now})
        # Remove entries older than the window
        r.zremrangebyscore(user_key, 0, now - self.WINDOW * 1000)

        # Get the count of timestamps in the window
        count = r.zcard(user_key)
        # Set a TTL so the key eventually disappears
        r.expire(user_key, self.WINDOW + 1)

        if count > self.LIMIT:
            return JsonResponse(
                {"error": "Too many requests"},
                status=429
            )

Because ZADD, ZREMRangeByScore, and ZCARD are executed sequentially, you should wrap them in a Lua script for absolute atomicity in a production environment. The next section shows exactly that.

Atomic Token Bucket with a Redis Lua Script (FastAPI)

FastAPI’s async nature pairs nicely with Redis’ aioredis client. The Lua script below implements a token bucket that refills at rate tokens per second and allows up to capacity tokens.

import asyncio
from fastapi import FastAPI, Request, HTTPException
import aioredis

app = FastAPI()
redis = None

TOKEN_BUCKET_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- Get the current bucket state
local data = redis.call('HMGET', key, 'tokens', 'timestamp')
local tokens = tonumber(data[1]) or capacity
local timestamp = tonumber(data[2]) or now

-- Refill tokens based on elapsed time
local elapsed = now - timestamp
local refill = elapsed * refill_rate
tokens = math.min(capacity, tokens + refill)

if tokens < 1 then
    -- Not enough tokens, reject request
    redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now)
    redis.call('EXPIRE', key, math.ceil(capacity / refill_rate))
    return 0
else
    -- Consume a token and allow request
    tokens = tokens - 1
    redis.call('HMSET', key, 'tokens', tokens, 'timestamp', now)
    redis.call('EXPIRE', key, math.ceil(capacity / refill_rate))
    return 1
end
"""

@app.on_event("startup")
async def startup():
    global redis
    redis = await aioredis.create_redis_pool('redis://localhost')

@app.on_event("shutdown")
async def shutdown():
    redis.close()
    await redis.wait_closed()

async def is_allowed(client_id: str, capacity: int = 10, rate: float = 1.0) -> bool:
    now = int(asyncio.get_event_loop().time())
    result = await redis.eval(
        TOKEN_BUCKET_SCRIPT,
        keys=[f"rl:bucket:{client_id}"],
        args=[capacity, rate, now]
    )
    return bool(result)

@app.middleware("http")
async def rate_limit(request: Request, call_next):
    client_id = request.headers.get("X-API-Key", request.client.host)
    allowed = await is_allowed(client_id, capacity=10, rate=1.0)  # 10 req/min
    if not allowed:
        raise HTTPException(status_code=429, detail="Rate limit exceeded")
    response = await call_next(request)
    return response

@app.get("/")
async def root():
    return {"message": "Welcome to a fast, rate‑limited API!"}

Because the entire logic lives inside a Lua script, the check is atomic—even under heavy concurrency. Adjust capacity and rate to suit your API’s SLA.

Best Practices for Production‑Ready Rate Limiting

  1. Centralize configuration: Keep limits (requests per window, burst size, etc.) in a single config file or environment variables so you can change them without redeploying code.
  2. Use consistent keys: Prefix keys (e.g., rl:user:, rl:ip:) to avoid collisions with other Redis data.
  3. Monitor Redis health: Rate limiting adds write load; set up alerts for latency, memory usage, and eviction events.
  4. Graceful degradation: When Redis is unavailable, decide whether to fail open (allow traffic) or fail closed (reject traffic). A common pattern is to let a limited “fallback” bucket operate locally for a short window.
  5. Cache static limits: If you have per‑plan limits (free vs. premium), cache the plan data in Redis or in‑process memory to avoid extra DB lookups on every request.
  6. Log throttled attempts: Recording rejected requests helps you spot abusive patterns and fine‑tune limits.

Testing Your Rate Limiter

Before shipping, verify that the limiter

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *