Python Web App Rate Limiting With Redis

Written by

in

When you build a Python web application, protecting your API endpoints from abuse and ensuring fair usage are critical for performance, security, and cost control. Rate limiting—the practice of restricting how many requests a client can make in a given time window—provides a simple yet powerful defense against brute‑force attacks, accidental overloads, and malicious scraping. Among the many tools available, Redis stands out as a fast, in‑memory data store that can handle the high‑throughput demands of modern web services. In this guide we’ll walk through the concepts, design patterns, and practical code you need to implement robust rate limiting for a Python web app using Redis.

Why Use Redis for Rate Limiting?

Redis offers several advantages that make it a perfect fit for throttling requests:

  • Speed: All operations are performed in memory, delivering sub‑millisecond latency.
  • Atomicity: Commands such as INCR and EXPIRE can be combined in a Lua script to guarantee race‑condition‑free updates.
  • Scalability: Redis can be clustered, allowing you to share rate‑limit counters across multiple web‑server instances.
  • TTL support: Built‑in expiration makes it easy to reset counters after the defined time window.

Core Rate‑Limiting Algorithms

Before diving into code, it’s worth understanding the three most common algorithms and when to use each.

1. Fixed Window Counter

Counts requests in a static time bucket (e.g., 0‑59 seconds). Simple to implement but can suffer from “burst” problems at bucket boundaries.

2. Sliding Window Log

Stores timestamps for each request and discards entries older than the window. Provides precise control but can be memory‑intensive for high‑traffic APIs.

3. Token Bucket (Leaky Bucket)

Maintains a bucket of tokens that refill at a steady rate. Allows short bursts while enforcing an average rate. This is the most flexible for public APIs.

In most Python web apps, the Token Bucket approach strikes the best balance between accuracy and performance, and it maps cleanly to Redis using INCR, DECR, and EXPIRE operations or a Lua script.

Setting Up Redis in a Python Project

First, install the Redis client library. Both redis-py and aioredis (for async frameworks) are widely used.

pip install redis  # synchronous
# or
pip install aioredis  # asynchronous

Next, create a reusable Redis connection wrapper:

import redis
from typing import Optional

class RedisClient:
    _instance: Optional[redis.Redis] = None

    @classmethod
    def get_client(cls) -> redis.Redis:
        if cls._instance is None:
            cls._instance = redis.Redis(
                host='localhost',
                port=6379,
                db=0,
                decode_responses=True  # store strings, not bytes
            )
        return cls._instance

Implementing a Token‑Bucket Rate Limiter

Below is a concise, production‑ready implementation that works with Flask, Django, FastAPI, or any WSGI/ASGI framework. The logic is encapsulated in a class that can be used as a decorator or middleware.

Python Code

import time
import redis
from typing import Callable, Any

class RedisRateLimiter:
    """
    Token bucket rate limiter using Redis.
    Parameters:
        limit: maximum number of requests allowed per window
        window: length of the window in seconds
        redis_client: an instance of redis.Redis
    """
    def __init__(self, limit: int, window: int, redis_client: redis.Redis):
        self.limit = limit
        self.window = window
        self.redis = redis_client

        # Lua script ensures atomic check‑and‑update
        self.lua_script = """
        local key = KEYS[1]
        local limit = tonumber(ARGV[1])
        local ttl = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])

        local tokens = redis.call('GET', key)
        if tokens == false then
            tokens = limit - 1
            redis.call('SET', key, tokens, 'EX', ttl)
            return {tokens, ttl}
        end

        tokens = tonumber(tokens)
        if tokens <= 0 then
            return {tokens, redis.call('TTL', key)}
        end

        tokens = tokens - 1
        redis.call('DECR', key)
        return {tokens, redis.call('TTL', key)}
        """
        self.script = self.redis.register_script(self.lua_script)

    def is_allowed(self, identifier: str) -> bool:
        """
        Returns True if the request is within the limit, False otherwise.
        The identifier is usually an API key, IP address, or user ID.
        """
        key = f"rl:{identifier}"
        now = int(time.time())
        remaining, ttl = self.script(
            keys=[key],
            args=[self.limit, self.window, now],
        )
        return int(remaining) >= 0

    def __call__(self, func: Callable) -> Callable:
        """
        Allows the class to be used as a decorator.
        Example:
            @rate_limiter
            def my_view():
                ...
        """
        def wrapper(*args, **kwargs) -> Any:
            request = kwargs.get('request')
            identifier = getattr(request, 'remote_addr', 'anonymous')
            if not self.is_allowed(identifier):
                raise PermissionError('Rate limit exceeded')
            return func(*args, **kwargs)
        return wrapper

How It Works

  • The is_allowed method builds a Redis key scoped to the client identifier (IP, API key, etc.).
  • A Lua script atomically:
    1. Initializes the bucket with limit tokens and a TTL equal to the window size.
    2. Decrements the token count on each request.
    3. Returns the remaining tokens and the current TTL.
  • If the token count drops below zero, the request is rejected.

Integrating the Limiter with Popular Frameworks

Flask Example

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

app = Flask(__name__)
redis_client = Redis(host='localhost', port=6379, db=0)
rate_limiter = RedisRateLimiter(limit=100, window=60, redis_client=redis_client)

@app.route('/api/data')
@rate_limiter
def get_data():
    return jsonify({'message': 'Success', 'data': [1, 2, 3]})

@app.errorhandler(PermissionError)
def handle_rate_limit(error):
    return jsonify({'error': str(error)}), 429

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

Django Middleware

# myapp/middleware.py
from django.http import JsonResponse
from redis import Redis
from .rate_limiter import RedisRateLimiter

class RateLimitMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
        self.redis = Redis(host='localhost', port=6379, db=0)
        self.limiter = RedisRateLimiter(limit=200, window=60, redis_client=self.redis)

    def __call__(self, request):
        identifier = request.META.get('REMOTE_ADDR')
        if not self.limiter.is_allowed(identifier):
            return JsonResponse({'error': 'Too many requests'}, status=429)
        response = self.get_response(request)
        return response

Register the middleware in settings.py:

MIDDLEWARE = [
    # ... other middleware ...
    'myapp.middleware.RateLimitMiddleware',
]

FastAPI Dependency

from fastapi import FastAPI, Request, HTTPException, Depends
from redis import Redis
from .rate_limiter import RedisRateLimiter

app = FastAPI()
redis_client = Redis(host='localhost', port=6379, db=0)
limiter = RedisRateLimiter(limit=50, window=30, redis_client=redis_client)

async def rate_limit_dependency(request: Request):
    identifier = request.client.host
    if not limiter.is_allowed(identifier):
        raise HTTPException(status_code=429, detail='Rate limit exceeded')

@app.get('/items')
async def read_items(dep: None = Depends(rate_limit_dependency)):
    return {'items': ['apple', 'banana']}

Best Practices & Tips for Production

  • Choose the right identifier: For public APIs use API keys; for internal services IP addresses may suffice.
  • Handle bursts gracefully: Combine a token bucket with a short “burst” allowance (e.g., 10 extra tokens) to avoid penalizing legitimate spikes.
  • Monitor Redis latency: Rate limiting adds a round‑trip to Redis on every request. Use connection pooling and keep Redis close to your application servers.
  • Fail‑open vs. fail‑closed: Decide whether to allow requests when Redis is down (fail‑open) or to block them (fail‑closed). A common pattern is to fallback to an in‑memory counter with a short TTL.
  • Distributed rate limits: If you run multiple instances, store counters in a shared Redis cluster to keep limits consistent across the fleet.
  • Expose metrics: Emit Prometheus counters such as rate_limit_allowed_total and rate_limit_blocked_total for observability.
  • Testing: Write unit tests that mock Redis responses and integration tests that hit a real Redis instance to verify edge cases (e.g., token exhaustion, TTL expiration).

Advanced Enhancements

Once the basic limiter is stable, you can extend it with the following

Comments

Leave a Reply

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