In the modern landscape of high-throughput microservices, protecting your APIs from abuse, DDoS attacks, and accidental overload is not optional—it is critical. While basic rate limiting works well in single-node applications, distributed systems require a shared state to ensure consistent enforcement across multiple instances. This is where Redis shines, but to truly guarantee correctness, you must move beyond simple key-value operations and leverage the power of Lua scripting.
The Challenge of Distributed Atomicity
Imagine you have three API servers behind a load balancer. A malicious user sends 100 requests per second, hitting Server A, B, and C in a round-robin fashion. If you store rate limit counters locally on each server, the user effectively gets 300 requests per second allowed, bypassing your 100 req/s limit. You need a centralized counter.
Simply incrementing a key in Redis (e.g., INCR) sounds like a solution, but it fails under high concurrency without proper windowing logic. A naive approach might involve setting a key with an expiry, but this leads to the "boundary problem." A user could make 10 requests at second 0, and 10 requests at second 1, effectively doubling their throughput just by timing their requests to the edge of time windows. To solve this accurately, we need to check the count and set the expiry atomically.
Why Lua Scripts?
Redis executes Lua scripts atomically. This means that once a script starts running, it will not be interrupted by any other command. This property is essential for rate limiting because we need to:
- Check if the rate limit key exists.
- Retrieve its current count.
- Increment the count.
- Set the expiry if it is the first request in the window.
- Return the status to the caller.
Doing this with separate GET and SET commands is prone to race conditions. By embedding this logic in a Lua script, we ensure that the entire logic block executes as a single, indivisible unit.
Implementing the Sliding Window Counter
Below is a robust Lua script implementation for a fixed-window rate limiter. This script checks the number of requests within a specific time window (defined in milliseconds).
-- Key to store the rate limit counter
local key = KEYS[1]
-- Current time in milliseconds
local now = tonumber(ARGV[1])
-- Window size in milliseconds
local window = tonumber(ARGV[2])
-- Max allowed requests
local limit = tonumber(ARGV[3])
-- Calculate the start of the current window
local window_start = now - window
-- Remove expired entries (simple cleanup, though for strict sliding windows, a sorted set is better)
-- For fixed window, we just check the key exists
local count = redis.call('GET', key)
if not count then
-- First request in this window
redis.call('SET', key, 1)
redis.call('EXPIRE', key, math.ceil(window / 1000))
return {1, 1}
else
count = tonumber(count)
if count < limit then
-- Request allowed
redis.call('INCR', key)
return {0, count + 1}
else
-- Rate limit exceeded
return {1, count}
end
end
In this script, KEYS[1] represents the unique identifier for the client (e.g., their IP address or API Key). ARGV passes the current timestamp, window duration, and the maximum allowed limit. The script returns an array where the first element indicates if the request was blocked (1) or allowed (0), and the second element provides the current request count.
Practical Integration with Python
Integrating this with a Python Flask application using the redis-py library is straightforward. You load the script into the Redis client once and then call it by reference.
import time
import redis
# Initialize Redis client
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Load the Lua script
lua_script = """
-- (Insert Lua script from above here)
"""
rate_limit_fn = r.register_script(lua_script)
def check_rate_limit(user_id, limit=10, window=60000):
now = int(time.time() * 1000)
key = f"rate_limit:{user_id}"
# Execute the Lua script
result = rate_limit_fn(keys=[key], args=[now, window, limit])
status, count = result
if status == 1:
# Return 429 Too Many Requests
return False, count
# Return 200 OK
return True, count
Conclusion
Implementing distributed rate limiting with Redis and Lua scripts provides a powerful, secure, and high-performance foundation for your API infrastructure. By ensuring atomicity, you prevent race conditions and accurately enforce your security policies. This approach not only protects your backend from overload but also maintains a fair and consistent experience for legitimate users, regardless of which server instance handles their request.