Guided Project 2: Distributed Caching with Rate Limiting
On this page 8
This project combines two fundamental Redis use cases crucial for scalable web applications:
- Distributed Caching: Storing frequently accessed data in Redis to reduce the load on primary databases and speed up response times.
- Rate Limiting: Preventing abuse of APIs or services by restricting the number of requests a user or client can make within a given time window.
We’ll build a simplified API-like service that uses Redis for both caching and rate limiting, demonstrated with Node.js and Python.
Project Objective
Create a mock “Product API” where:
- Product details are fetched from a “slow” backend (simulated with a delay).
- Product details are cached in Redis for faster subsequent requests.
- API requests are rate-limited per client IP address.
Prerequisites
- A running Redis server.
- Node.js and
ioredisinstalled. - Python and
redis-pyinstalled. - A basic understanding of web server frameworks (Express for Node.js, Flask for Python) is helpful but not strictly required, as we’ll keep the server part minimal.
Step 1: Initialize the Project and Redis Client
Create a new directory for your project.
Node.js:
mkdir redis-cache-ratelimit-nodejs
cd redis-cache-ratelimit-nodejs
npm init -y
npm install ioredis express # express for basic API simulation
Then create a file app.js.
Python:
mkdir redis-cache-ratelimit-python
cd redis-cache-ratelimit-python
python3 -m venv venv
source venv/bin/activate
pip install redis flask # flask for basic API simulation
Then create a file app.py.
Step 2: Implement the Rate Limiter
We’ll implement a “fixed window counter” rate limiter. For each IP address, we’ll store a counter in Redis that expires after a specific time window. Each request increments the counter. If the counter exceeds a threshold, the request is denied.
Rate Limiter Logic:
- Generate a unique key for the rate limit (e.g.,
rate_limit:<IP_Address>:<Window_Start_Timestamp>). INCRthe counter for this key.- If the key is new,
EXPIREit to the end of the current time window. - Check if the counter exceeds the
MAX_REQUESTS.
Node.js (app.js):
// redis-cache-ratelimit-nodejs/app.js
const express = require('express');
const Redis = require('ioredis');
const app = express();
const redis = new Redis();
const RATE_LIMIT_WINDOW_SECONDS = 60; // 1 minute window
const MAX_REQUESTS_PER_WINDOW = 5; // Max 5 requests per minute
/**
* Checks if a client is rate-limited.
* @param {string} ip - The client's IP address.
* @returns {Promise<boolean>} - True if rate-limited, false otherwise.
*/
async function isRateLimited(ip) {
const now = Math.floor(Date.now() / 1000); // Current timestamp in seconds
// Key format: rate_limit:<IP_Address>:<Window_Start_Timestamp>
// We use current minute as the window identifier.
const windowStartTimestamp = Math.floor(now / RATE_LIMIT_WINDOW_SECONDS) * RATE_LIMIT_WINDOW_SECONDS;
const key = `rate_limit:${ip}:${windowStartTimestamp}`;
try {
// Increment the counter for the current window.
// PX: Set expiration in milliseconds (PEXPIRE)
// NX: Only set expiration if key doesn't exist (SETNX)
const requests = await redis.incr(key);
if (requests === 1) {
// If this is the first request in the window, set its expiration
await redis.expire(key, RATE_LIMIT_WINDOW_SECONDS);
}
if (requests > MAX_REQUESTS_PER_WINDOW) {
console.log(`IP ${ip} is rate-limited. Requests: ${requests}`);
return true;
}
console.log(`IP ${ip} request count: ${requests}`);
return false;
} catch (error) {
console.error(`Rate limiting error for IP ${ip}:`, error);
// In case of Redis error, fail open (don't rate limit) to avoid disrupting service
return false;
}
}
// Middleware for rate limiting
app.use(async (req, res, next) => {
// Use a simplified IP for local testing, e.g., '127.0.0.1' or random
const clientIp = req.ip || '127.0.0.1'; // In production, req.ip will be more reliable
if (await isRateLimited(clientIp)) {
return res.status(429).send('Too Many Requests');
}
next();
});
// ... (API routes and server setup later)
Python (app.py):
# redis-cache-ratelimit-python/app.py
from flask import Flask, request, jsonify
import redis
import time
import json # For product data serialization
app = Flask(__name__)
r = redis.Redis(host='localhost', port=6379, db=0)
RATE_LIMIT_WINDOW_SECONDS = 60 # 1 minute window
MAX_REQUESTS_PER_WINDOW = 5 # Max 5 requests per minute
"""
Checks if a client is rate-limited.
:param ip: The client's IP address.
:returns: True if rate-limited, false otherwise.
"""
def is_rate_limited(ip):
now = int(time.time())
# Key format: rate_limit:<IP_Address>:<Window_Start_Timestamp>
# We use current minute as the window identifier.
window_start_timestamp = (now // RATE_LIMIT_WINDOW_SECONDS) * RATE_LIMIT_WINDOW_SECONDS
key = f"rate_limit:{ip}:{window_start_timestamp}"
try:
# Increment the counter for the current window.
# Use a pipeline for INCR and EXPIRE to make it atomic where possible,
# especially for the initial setting of the expiration.
# We can use r.incr and r.expire separately, but need to be careful if
# expire fails. SET with EX option is better: SET key value EX seconds NX
# However, INCR doesn't have an EX NX option directly.
# For this simple example, we'll use a transaction for atomicity
with r.pipeline() as pipe:
pipe.incr(key)
pipe.expire(key, RATE_LIMIT_WINDOW_SECONDS, nx=True) # Only set EX if key is new
# If the key already existed, expire(nx=True) does nothing.
# If it was new, incr set it to 1, then expire sets the TTL.
results = pipe.execute()
requests = results[0] # Result of INCR
if requests > MAX_REQUESTS_PER_WINDOW:
print(f"IP {ip} is rate-limited. Requests: {requests}")
return True
print(f"IP {ip} request count: {requests}")
return False
except Exception as e:
print(f"Rate limiting error for IP {ip}: {e}")
# In case of Redis error, fail open (don't rate limit)
return False
# Decorator for rate limiting
@app.before_request
def rate_limit_middleware():
client_ip = request.remote_addr or '127.0.0.1' # Use request.remote_addr in Flask
if is_rate_limited(client_ip):
return jsonify({'message': 'Too Many Requests'}), 429
# ... (API routes and server setup later)
Step 3: Implement the Caching Layer
We’ll cache product details. If a product is not in Redis, we’ll fetch it from a simulated “slow database” and then store it in Redis with a TTL.
Caching Logic:
- Check Redis for the
product:<ID>key. - If found, return the cached data immediately.
- If not found, simulate fetching from a backend database (e.g.,
fetchFromDatabase(productId)). - Store the fetched data in Redis (e.g.,
SET product:<ID> JSON_data EX TTL_seconds). - Return the data.
Node.js (app.js):
// ... (previous code for rate limiter and Express setup)
const CACHE_TTL_SECONDS = 300; // Cache for 5 minutes
// Simulate a slow database fetch
async function fetchProductFromDatabase(productId) {
console.log(`Fetching product ${productId} from slow database...`);
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate 1 second delay
return {
id: productId,
name: `Product ${productId}`,
description: `This is a detailed description for product ${productId}.`,
price: parseFloat((Math.random() * 1000).toFixed(2)),
last_updated: new Date().toISOString()
};
}
/**
* Retrieves product details, first from cache, then from database.
* @param {string} productId - The ID of the product.
* @returns {Promise<object>} - Product details.
*/
async function getProductDetails(productId) {
const cacheKey = `product:${productId}`;
try {
// 1. Try to fetch from Redis cache
let cachedProduct = await redis.get(cacheKey);
if (cachedProduct) {
console.log(`Product ${productId} found in cache.`);
return JSON.parse(cachedProduct);
}
// 2. If not in cache, fetch from the "database"
console.log(`Product ${productId} not found in cache. Fetching from database.`);
const productData = await fetchProductFromDatabase(productId);
// 3. Store in cache with TTL
await redis.setex(cacheKey, CACHE_TTL_SECONDS, JSON.stringify(productData));
console.log(`Product ${productId} cached for ${CACHE_TTL_SECONDS} seconds.`);
return productData;
} catch (error) {
console.error(`Error getting product details for ${productId}:`, error);
// Fallback to directly returning from database if caching fails
return fetchProductFromDatabase(productId);
}
}
// ... (API routes and server setup later)
Python (app.py):
# ... (previous code for rate limiter and Flask setup)
CACHE_TTL_SECONDS = 300 # Cache for 5 minutes
# Simulate a slow database fetch
def fetch_product_from_database(product_id):
print(f"Fetching product {product_id} from slow database...")
time.sleep(1) # Simulate 1 second delay
return {
'id': product_id,
'name': f"Product {product_id}",
'description': f"This is a detailed description for product {product_id}.",
'price': round(random.uniform(10.0, 1000.0), 2),
'last_updated': time.time()
}
"""
Retrieves product details, first from cache, then from database.
:param product_id: The ID of the product.
:returns: Product details.
"""
def get_product_details(product_id):
cache_key = f"product:{product_id}"
try:
# 1. Try to fetch from Redis cache
cached_product = r.get(cache_key)
if cached_product:
print(f"Product {product_id} found in cache.")
return json.loads(cached_product)
# 2. If not in cache, fetch from the "database"
print(f"Product {product_id} not found in cache. Fetching from database.")
product_data = fetch_product_from_database(product_id)
# 3. Store in cache with TTL
r.setex(cache_key, CACHE_TTL_SECONDS, json.dumps(product_data))
print(f"Product {product_id} cached for {CACHE_TTL_SECONDS} seconds.")
return product_data
except Exception as e:
print(f"Error getting product details for {product_id}: {e}")
# Fallback to directly returning from database if caching fails
return fetch_product_from_database(product_id)
# ... (API routes and server setup later)
Step 4: Create the API Endpoint and Server
Now, let’s wire up our rate limiter and caching logic to a simple API endpoint.
Node.js (app.js):
// ... (all previous code)
// API route to get product details
app.get('/products/:id', async (req, res) => {
const productId = req.params.id;
try {
const product = await getProductDetails(productId);
res.json(product);
} catch (error) {
console.error(`Failed to get product ${productId}:`, error);
res.status(500).send('Internal Server Error');
}
});
const PORT = 3000;
app.listen(PORT, () => {
console.log(`\nAPI server listening on port ${PORT}`);
console.log(`Rate Limit: ${MAX_REQUESTS_PER_WINDOW} requests per ${RATE_LIMIT_WINDOW_SECONDS} seconds.`);
console.log(`Cache TTL: ${CACHE_TTL_SECONDS} seconds.`);
console.log('Test with: curl http://localhost:3000/products/123');
console.log('To simulate multiple IPs, use `--header "X-Forwarded-For: 192.168.1.X"` in curl');
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down Redis client...');
await redis.quit();
console.log('Redis client disconnected. Exiting.');
process.exit(0);
});
Python (app.py):
# ... (all previous code)
# API route to get product details
@app.route('/products/<string:product_id>', methods=['GET'])
def products_route(product_id):
try:
product = get_product_details(product_id)
return jsonify(product)
except Exception as e:
print(f"Failed to get product {product_id}: {e}")
return jsonify({'message': 'Internal Server Error'}), 500
if __name__ == '__main__':
print(f"\nAPI server listening on port 5000")
print(f"Rate Limit: {MAX_REQUESTS_PER_WINDOW} requests per {RATE_LIMIT_WINDOW_SECONDS} seconds.")
print(f"Cache TTL: {CACHE_TTL_SECONDS} seconds.")
print("Test with: curl http://localhost:5000/products/abc")
print("To simulate multiple IPs, use `--header \"X-Forwarded-For: 192.168.1.X\"` in curl")
app.run(debug=False, port=5000)
Step 5: Run and Test the Application
-
Start your Redis server.
-
Start the API application:
- Node.js:
node app.js - Python:
python app.py(orflask runif configured)
- Node.js:
-
Test the caching and rate limiting using
curl:-
Initial Request (slow, then cached):
curl http://localhost:3000/products/123 # For Node.js curl http://localhost:5000/products/abc # For PythonYou should see “Fetching product… from slow database” in the server logs, followed by the product JSON.
-
Subsequent Requests (fast, from cache): Immediately repeat the same
curlcommand multiple times.curl http://localhost:3000/products/123You should now see “Product found in cache” and a much faster response.
-
Test Rate Limiting (same IP): Keep sending requests quickly. After 5 requests within the minute window, you should get a “Too Many Requests” (429) error.
for i in $(seq 1 10); do curl http://localhost:3000/products/123; sleep 0.5; doneThe server logs will show “IP 127.0.0.1 is rate-limited.”
-
Test with a Different IP (simulated): Use the
X-Forwarded-Forheader to simulate requests from a different IP address. This new IP should have its own rate limit.for i in $(seq 1 10); do curl --header "X-Forwarded-For: 192.168.1.10" http://localhost:3000/products/456; sleep 0.5; done(Adjust the port to 5000 for Python).
-
Wait for TTL/Rate Limit Reset: Wait for 60 seconds (for the rate limit to reset) or 300 seconds (for cache to expire), and observe the behavior again.
-
Further Challenges and Enhancements
-
Sliding Window Rate Limiter (Advanced):
- The current rate limiter is a “fixed window.” Implement a “sliding window log” or “sliding window counter” rate limiter, which is generally more fair. This involves storing timestamps of individual requests in a Redis Sorted Set or using a more complex counter approach. (Hint:
ZADD,ZREMRANGEBYSCORE,ZCARD).
- The current rate limiter is a “fixed window.” Implement a “sliding window log” or “sliding window counter” rate limiter, which is generally more fair. This involves storing timestamps of individual requests in a Redis Sorted Set or using a more complex counter approach. (Hint:
-
Cache Invalidation:
- What if a product’s price or description changes in the database? The cache would be stale for
CACHE_TTL_SECONDS. - Implement an endpoint or a mechanism (e.g., another Redis Pub/Sub message) to explicitly
DELa product’s cache key when its data is updated in the backend.
- What if a product’s price or description changes in the database? The cache would be stale for
-
Tiered Caching:
- Implement a multi-level cache: an in-memory (application-local) cache for very hot items (e.g., using a Map/dictionary), falling back to Redis, and then to the database. Add a small TTL (e.g., 5 seconds) to the local cache.
-
Error Handling and Metrics:
- Add more robust error handling for Redis connection issues.
- Implement basic metrics (e.g., number of cache hits/misses, rate limit denials) and expose them (e.g., simple print statements or a dedicated
/metricsendpoint).
-
Distributed Lock for Cache Population:
- If many clients request an uncached item simultaneously, they could all hit the slow database (“thundering herd” problem).
- Implement a Redis Distributed Lock (
SET key value EX seconds NX) before callingfetchProductFromDatabase. Only one instance should fetch from the DB; others wait or fetch from cache if it becomes available.
By successfully building and testing this project, you’ve gained invaluable experience in using Redis for distributed caching and rate limiting – two critical components for building high-performance and resilient modern web applications.
Spotted an error? Tell us via the corrections process — verified reports get fixed and credited.