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.