Python Web App Rate Limiting With Redis

Written by

in

When you build a Python web application that serves thousands of requests per second, protecting your APIs from abuse and ensuring fair usage become critical. Rate limiting—the practice of restricting the number of requests a client can make in a given time window—helps prevent denial‑of‑service attacks, reduces server load, and improves overall user experience. Among the many tools available, Redis stands out as a fast, in‑memory data store that can act as a central, distributed counter for rate‑limiting logic. In this guide we’ll walk through the why, what, and how of implementing robust rate limiting for a Python web app using Redis, complete with code snippets, best‑practice tips, and performance considerations.

Why Rate Limiting Matters for Python Web Apps

  • Security: Stops brute‑force login attempts, API scraping, and bot traffic.
  • Stability: Prevents a single client from overwhelming your server, preserving resources for legitimate users.
  • Compliance: Many SaaS providers need to enforce usage quotas to meet service‑level agreements.
  • User Experience: Guarantees consistent response times by avoiding spikes in request load.

Choosing Redis for Rate Limiting

Redis offers several features that make it ideal for real‑time counters:

  • Low latency: Sub‑millisecond read/write operations keep the request path fast.
  • Atomic commands: Operations like INCR and EXPIRE can be combined in a single transaction, eliminating race conditions.
  • Scalability: Redis can be clustered across multiple nodes, allowing your rate limiter to work across several web server instances.
  • TTL support: Built‑in expiration simplifies sliding‑window implementations.

Fundamental Rate‑Limiting Algorithms

1. Fixed Window Counter

The simplest approach counts requests in a static time bucket (e.g., 100 requests per minute). While easy to implement, it suffers from “burst” problems at bucket boundaries.

2. Sliding Window Log

Each request timestamp is stored, and the server counts entries within the last n seconds. This provides precise limits but can be memory‑intensive for high‑traffic APIs.

3. Sliding Window Counter (Leaky Bucket)

A hybrid technique uses two counters to approximate a sliding window while keeping memory usage low. It’s the most common choice for production systems.

Implementing a Fixed Window Counter with Redis

Below is a minimal example using Flask and redis-py. The code demonstrates how to limit each IP address to 100 requests per minute.

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

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

RATE_LIMIT = 100          # max requests
WINDOW_SIZE = 60          # seconds

def is_allowed(ip):
    # Redis key format: "rate:{ip}:{timestamp}"
    current_window = int(time.time()) // WINDOW_SIZE
    key = f"rate:{ip}:{current_window}"
    
    # Atomically increment the counter and set expiration if first hit
    pipeline = r.pipeline()
    pipeline.incr(key, 1)
    pipeline.expire(key, WINDOW_SIZE + 1)
    count, _ = pipeline.execute()
    
    return count <= RATE_LIMIT

@app.before_request
def limit():
    client_ip = request.remote_addr
    if not is_allowed(client_ip):
        return jsonify({'error': 'Too many requests'}), 429

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

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

This snippet uses a pipeline to ensure the INCR and EXPIRE commands execute atomically, preventing race conditions when multiple workers handle the same IP simultaneously.

Moving to a Sliding Window Counter

To smooth out bursts, we can store two keys: one for the current window and one for the previous window. The request count is a weighted sum based on how far we are into the current window.

def sliding_allowed(ip):
    now = time.time()
    current_window = int(now // WINDOW_SIZE)
    prev_window = current_window - 1
    
    cur_key = f"rate:{ip}:{current_window}"
    prev_key = f"rate:{ip}:{prev_window}"
    
    pipeline = r.pipeline()
    pipeline.incr(cur_key, 1)
    pipeline.expire(cur_key, WINDOW_SIZE * 2)
    pipeline.get(prev_key)
    cur_count, _, prev_count = pipeline.execute()
    
    # Convert bytes to int, defaulting to 0
    prev_count = int(prev_count or 0)
    cur_count = int(cur_count)
    
    # Calculate weighted count
    elapsed = now % WINDOW_SIZE
    weight = (WINDOW_SIZE - elapsed) / WINDOW_SIZE
    effective = cur_count + prev_count * weight
    
    return effective <= RATE_LIMIT

By blending the two counters, the algorithm approximates a true sliding window while keeping memory usage constant (only two keys per IP).

Integrating with Popular Python Frameworks

FastAPI + Starlette Middleware

FastAPI’s asynchronous nature pairs well with aioredis. Below is a reusable middleware component.

import time
from fastapi import FastAPI, Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
import aioredis

RATE_LIMIT = 200
WINDOW = 60

class RateLimitMiddleware(BaseHTTPMiddleware):
    def __init__(self, app, redis):
        super().__init__(app)
        self.redis = redis

    async def dispatch(self, request: Request, call_next):
        ip = request.client.host
        now = int(time.time())
        window = now // WINDOW
        key = f"rl:{ip}:{window}"
        
        # Atomic increment + expire
        async with self.redis.pipeline() as pipe:
            pipe.incr(key)
            pipe.expire(key, WINDOW + 1)
            count, _ = await pipe.execute()
        
        if count > RATE_LIMIT:
            raise HTTPException(status_code=429, detail="Rate limit exceeded")
        
        response = await call_next(request)
        return response

app = FastAPI()
redis = await aioredis.create_redis_pool('redis://localhost')
app.add_middleware(RateLimitMiddleware, redis=redis)

Django with Redis Cache Backend

Django’s caching framework can be leveraged for rate limiting without writing raw Redis commands.

from django.core.cache import cache
from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin
import time

RATE_LIMIT = 500
WINDOW = 60

class RedisRateLimitMiddleware(MiddlewareMixin):
    def process_request(self, request):
        ip = request.META.get('REMOTE_ADDR')
        now = int(time.time())
        window = now // WINDOW
        cache_key = f"rl:{ip}:{window}"
        
        count = cache.incr(cache_key, 1)
        if count == 1:
            # First hit: set TTL
            cache.expire(cache_key, WINDOW + 1)
        
        if count > RATE_LIMIT:
            return JsonResponse({'error': 'Too many requests'}, status=429)

Make sure CACHES in settings.py points to a Redis backend.

Best Practices and Gotchas

  • Use a consistent key schema: Prefix keys (e.g., rl:) to avoid collisions with other Redis data.
  • Limit Redis memory: Set maxmemory policies to allkeys-lru or volatile-lru so stale counters are evicted automatically.
  • Handle bursts with bursts‑per‑second limits: Combine a short‑term (per‑second) limit with a longer‑term (per‑minute) limit for finer control.
  • Watch out for clock drift: Use the server’s time (via TIME command) when possible to keep all instances in sync.
  • Graceful degradation: Return a Retry-After header indicating when the client can retry.
  • Testing: Simulate high concurrency with tools like locust or hey to verify that your limiter holds under load.

Advanced Topics

Distributed Rate Limiting with Redis Cluster

When your application runs across multiple regions, a single Redis node becomes a single point of failure. Redis Cluster shards keys automatically, but you must ensure the key hashing function distributes IP‑based keys evenly. Using a {} hash tag in the key (e.g., rate:{ip}:{window}) guarantees that both the current and previous window keys land on the same shard, preserving atomicity.

Using Lua Scripts for True Atomicity

Even though pipelines are atomic for a single connection, a Lua script can guarantee that the increment, expiration, and comparison happen in one server‑side execution. Below is a compact script for a sliding window counter:

local key_cur = KEYS[1]
local key_prev = KEYS[2]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local weight = tonumber(ARGV[4])

local cur = redis.call('INCR', key_cur)
if cur == 1 then
    redis.call('EXPIRE', key_cur, ttl)
end

local prev = tonumber(redis.call('GET', key_prev) or '0')
local effective = cur + prev * weight

if effective > limit then
    return 0  -- reject
else
    return 1  -- allow
end

Calling the script from Python:

lua = open('sl

Comments

Leave a Reply

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