Implementing rate limiting for APIs
Use this skill when:
Apply rate limiting to prevent:
SCENARIO → SOLUTION
─────────────────────────────────────────────────
DDoS attacks → Per-IP rate limits
API abuse → Per-user + per-IP limits
Cost control → Tiered limits by plan
Resource exhaustion → Global rate limits
Brute force attacks → Aggressive limits on auth endpoints
Scraping prevention → Low limits on list endpoints
Fair usage → Per-user quotas
Different endpoints need different limits:
# Critical/Auth endpoints - strictest limits
POST /login # 5 requests/minute per IP
POST /signup # 3 requests/hour per IP
POST /password-reset # 3 requests/hour per user
# Read endpoints - moderate limits
GET /users # 100 requests/minute per user
GET /posts # 200 requests/minute per user
# Write endpoints - lower limits
POST /posts # 50 requests/minute per user
PUT /posts/:id # 30 requests/minute per user
# Expensive operations - very low limits
POST /reports/generate # 5 requests/hour per user
POST /exports/csv # 10 requests/day per user
# Public endpoints - IP-based limits
GET /public/feed # 60 requests/minute per IP
Best for allowing bursts while maintaining average rate.
How it works:
Implementation:
import time
import redis
class TokenBucket:
def __init__(self, redis_client: redis.Redis, key: str,
capacity: int, refill_rate: float):
"""
capacity: Maximum tokens in bucket
refill_rate: Tokens added per second
"""
self.redis = redis_client
self.key = f"rate_limit:token_bucket:{key}"
self.capacity = capacity
self.refill_rate = refill_rate
def allow_request(self, tokens_needed: int = 1) -> bool:
"""Check if request is allowed and consume tokens"""
now = time.time()
# Lua script for atomic operation
script = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local tokens_needed = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- Refill tokens based on time elapsed
local elapsed = now - last_refill
tokens = math.min(capacity, tokens + (elapsed * refill_rate))
-- Check if enough tokens
if tokens >= tokens_needed then
tokens = tokens - tokens_needed
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 1
else
return 0
end
"""
result = self.redis.eval(
script,
1,
self.key,
self.capacity,
self.refill_rate,
tokens_needed,
now
)
return bool(result)
def get_remaining(self) -> int:
"""Get remaining tokens"""
bucket = self.redis.hmget(self.key, 'tokens', 'last_refill')
if not bucket[0]:
return self.capacity
tokens = float(bucket[0])
last_refill = float(bucket[1])
now = time.time()
elapsed = now - last_refill
tokens = min(self.capacity, tokens + (elapsed * self.refill_rate))
return int(tokens)
# Usage
limiter = TokenBucket(redis_client, user_id, capacity=100, refill_rate=10)
if limiter.allow_request():
# Process request
return response
else:
# Rate limited
return 429
Best for: APIs with bursty traffic patterns, credit-based systems
Best for smoothing traffic to constant rate.
How it works:
Implementation:
class LeakyBucket:
def __init__(self, redis_client: redis.Redis, key: str,
capacity: int, leak_rate: float):
"""
capacity: Maximum queue size
leak_rate: Requests processed per second
"""
self.redis = redis_client
self.key = f"rate_limit:leaky_bucket:{key}"
self.capacity = capacity
self.leak_rate = leak_rate
def allow_request(self) -> bool:
"""Check if request can be queued"""
now = time.time()
script = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'size', 'last_leak')
local size = tonumber(bucket[1]) or 0
local last_leak = tonumber(bucket[2]) or now
-- Leak based on time elapsed
local elapsed = now - last_leak
local leaked = elapsed * leak_rate
size = math.max(0, size - leaked)
-- Check if space available
if size < capacity then
size = size + 1
redis.call('HMSET', key, 'size', size, 'last_leak', now)
redis.call('EXPIRE', key, 3600)
return 1
else
return 0
end
"""
result = self.redis.eval(
script,
1,
self.key,
self.capacity,
self.leak_rate,
now
)
return bool(result)
# Usage
limiter = LeakyBucket(redis_client, user_id, capacity=50, leak_rate=5)
Best for: Protecting downstream services, guaranteed constant load
Best for simple per-period limits.
How it works:
Implementation:
class FixedWindow:
def __init__(self, redis_client: redis.Redis, key: str,
limit: int, window_seconds: int):
self.redis = redis_client
self.key = f"rate_limit:fixed_window:{key}"
self.limit = limit
self.window_seconds = window_seconds
def allow_request(self) -> bool:
"""Check if request is allowed"""
now = time.time()
window = int(now // self.window_seconds)
key = f"{self.key}:{window}"
# Increment counter
count = self.redis.incr(key)
# Set expiration on first request
if count == 1:
self.redis.expire(key, self.window_seconds * 2)
return count <= self.limit
def get_remaining(self) -> int:
"""Get remaining requests in window"""
now = time.time()
window = int(now // self.window_seconds)
key = f"{self.key}:{window}"
count = int(self.redis.get(key) or 0)
return max(0, self.limit - count)
def get_reset_time(self) -> int:
"""Get timestamp when window resets"""
now = time.time()
window = int(now // self.window_seconds)
return int((window + 1) * self.window_seconds)
# Usage
limiter = FixedWindow(redis_client, user_id, limit=100, window_seconds=60)
Best for: Simple quotas, easy to understand for users
Downside: Burst at window boundaries (200 requests at 11:59 + 12:00)
Best for smooth rate limiting without boundary bursts.
How it works:
Implementation:
class SlidingWindow:
def __init__(self, redis_client: redis.Redis, key: str,
limit: int, window_seconds: int):
self.redis = redis_client
self.key = f"rate_limit:sliding_window:{key}"
self.limit = limit
self.window_seconds = window_seconds
def allow_request(self) -> bool:
"""Check if request is allowed"""
now = time.time()
window_start = now - self.window_seconds
pipe = self.redis.pipeline()
# Remove old requests
pipe.zremrangebyscore(self.key, 0, window_start)
# Count requests in window
pipe.zcard(self.key)
# Add current request (temporarily)
pipe.zadd(self.key, {str(now): now})
# Set expiration
pipe.expire(self.key, self.window_seconds)
results = pipe.execute()
count = results[1]
# Remove current request if over limit
if count >= self.limit:
self.redis.zrem(self.key, str(now))
return False
return True
def get_remaining(self) -> int:
"""Get remaining requests in window"""
now = time.time()
window_start = now - self.window_seconds
# Clean and count
self.redis.zremrangebyscore(self.key, 0, window_start)
count = self.redis.zcard(self.key)
return max(0, self.limit - count)
# Usage
limiter = SlidingWindow(redis_client, user_id, limit=100, window_seconds=60)
Best for: Fair rate limiting, preventing boundary bursts
Downside: Higher memory usage (stores timestamps)
Best for distributed systems:
import redis
# Single instance
redis_client = redis.Redis(
host='localhost',
port=6379,
db=0,
decode_responses=True
)
# Redis Cluster
from redis.cluster import RedisCluster
redis_client = RedisCluster(
host='redis-cluster',
port=6379,
decode_responses=True
)
# With connection pooling
pool = redis.ConnectionPool(
host='localhost',
port=6379,
max_connections=50
)
redis_client = redis.Redis(connection_pool=pool)
Pros: Fast, atomic operations, distributed, persistence Cons: Extra dependency, network latency
Best for single-instance applications:
import time
from collections import defaultdict
from threading import Lock
class InMemoryRateLimiter:
def __init__(self):
self.limits = defaultdict(lambda: {'count': 0, 'reset': 0})
self.lock = Lock()
def check_limit(self, key: str, limit: int, window: int) -> bool:
now = time.time()
with self.lock:
if now > self.limits[key]['reset']:
self.limits[key] = {'count': 0, 'reset': now + window}
if self.limits[key]['count'] < limit:
self.limits[key]['count'] += 1
return True
return False
# Usage
limiter = InMemoryRateLimiter()
if limiter.check_limit(user_id, limit=100, window=60):
# Process request
pass
Pros: No external dependencies, zero latency Cons: Not distributed, lost on restart, memory growth
Best for long-term quota tracking:
# PostgreSQL example
import psycopg2
from datetime import datetime, timedelta
def check_rate_limit(user_id: int, limit: int, window_minutes: int) -> bool:
conn = psycopg2.connect("dbname=myapp")
cursor = conn.cursor()
window_start = datetime.utcnow() - timedelta(minutes=window_minutes)
# Clean old requests
cursor.execute("""
DELETE FROM rate_limit_requests
WHERE user_id = %s AND timestamp < %s
""", (user_id, window_start))
# Count recent requests
cursor.execute("""
SELECT COUNT(*) FROM rate_limit_requests
WHERE user_id = %s AND timestamp >= %s
""", (user_id, window_start))
count = cursor.fetchone()[0]
if count < limit:
cursor.execute("""
INSERT INTO rate_limit_requests (user_id, timestamp)
VALUES (%s, %s)
""", (user_id, datetime.utcnow()))
conn.commit()
conn.close()
return True
conn.close()
return False
Pros: Persistent, queryable, good for analytics Cons: Slower, higher load on DB
Return rate limit info to clients:
from flask import Flask, jsonify, request
app = Flask(__name__)
def add_rate_limit_headers(response, limiter, key: str):
"""Add rate limit headers to response"""
response.headers['X-RateLimit-Limit'] = str(limiter.limit)
response.headers['X-RateLimit-Remaining'] = str(limiter.get_remaining())
response.headers['X-RateLimit-Reset'] = str(limiter.get_reset_time())
return response
@app.route('/api/posts')
def get_posts():
user_id = request.headers.get('X-User-ID')
limiter = FixedWindow(redis_client, user_id, limit=100, window_seconds=60)
if not limiter.allow_request():
response = jsonify({'error': 'Rate limit exceeded'})
response.status_code = 429
response.headers['Retry-After'] = str(
limiter.get_reset_time() - int(time.time())
)
return add_rate_limit_headers(response, limiter, user_id)
# Process request
data = {'posts': [...]}
response = jsonify(data)
return add_rate_limit_headers(response, limiter, user_id)
X-RateLimit-Limit: 100
→ Maximum requests allowed in window
X-RateLimit-Remaining: 45
→ Requests remaining in current window
X-RateLimit-Reset: 1234567890
→ Unix timestamp when limit resets
Retry-After: 30
→ Seconds until client can retry (429 response)
Identify users by authentication:
def get_rate_limit_key(request) -> str:
# Authenticated users
if request.user.is_authenticated:
return f"user:{request.user.id}"
# API key users
api_key = request.headers.get('X-API-Key')
if api_key:
return f"api_key:{api_key}"
# Fallback to IP
return f"ip:{request.remote_addr}"
Protect against anonymous abuse:
def get_client_ip(request) -> str:
"""Get real client IP (behind proxy)"""
# Check proxy headers
if request.headers.get('X-Forwarded-For'):
return request.headers['X-Forwarded-For'].split(',')[0].strip()
if request.headers.get('X-Real-IP'):
return request.headers['X-Real-IP']
return request.remote_addr
# Use IP as key
limiter = FixedWindow(redis_client, get_client_ip(request), limit=60, window_seconds=60)
Different limits by user tier:
RATE_LIMITS = {
'free': {'requests_per_minute': 60, 'requests_per_day': 1000},
'basic': {'requests_per_minute': 300, 'requests_per_day': 10000},
'premium': {'requests_per_minute': 1000, 'requests_per_day': 100000},
'enterprise': {'requests_per_minute': 5000, 'requests_per_day': 1000000},
}
def check_user_rate_limit(user_id: int, tier: str) -> bool:
limits = RATE_LIMITS[tier]
# Check per-minute limit
minute_limiter = FixedWindow(
redis_client,
f"user:{user_id}:minute",
limit=limits['requests_per_minute'],
window_seconds=60
)
# Check per-day limit
day_limiter = FixedWindow(
redis_client,
f"user:{user_id}:day",
limit=limits['requests_per_day'],
window_seconds=86400
)
return minute_limiter.allow_request() and day_limiter.allow_request()
Gradually restrict rather than hard stop:
def get_priority_level(request) -> int:
"""Determine request priority (higher = more important)"""
if request.user.tier == 'enterprise':
return 3
elif request.user.tier == 'premium':
return 2
elif request.user.tier == 'basic':
return 1
else:
return 0
def adaptive_rate_limit(request) -> bool:
"""Allow high-priority requests during high load"""
load = get_system_load() # 0-100
priority = get_priority_level(request)
# Increase rate limits under heavy load
if load > 80:
# Only allow enterprise
return priority >= 3
elif load > 60:
# Block free tier
return priority >= 1
else:
# Normal operation
return True
Queue requests instead of rejecting:
import asyncio
from collections import deque
class RateLimitQueue:
def __init__(self, rate: int):
self.rate = rate
self.queue = deque()
self.processing = False
async def add_request(self, request_handler, *args):
"""Add request to queue"""
future = asyncio.Future()
self.queue.append((request_handler, args, future))
if not self.processing:
asyncio.create_task(self.process_queue())
return await future
async def process_queue(self):
"""Process queue at fixed rate"""
self.processing = True
interval = 1.0 / self.rate
while self.queue:
handler, args, future = self.queue.popleft()
try:
result = await handler(*args)
future.set_result(result)
except Exception as e:
future.set_exception(e)
await asyncio.sleep(interval)
self.processing = False
# Usage
queue = RateLimitQueue(rate=10) # 10 requests/second
result = await queue.add_request(process_request, data)
ALGORITHM | BURST | SMOOTH | MEMORY | COMPLEXITY | USE CASE
──────────────────────────────────────────────────────────────────
Token Bucket | Yes | No | Low | Medium | Bursty APIs
Leaky Bucket | No | Yes | Low | Medium | Constant load
Fixed Window | Yes | No | Low | Low | Simple quotas
Sliding Window | No | Yes | High | High | Fair limiting
from flask import jsonify
def rate_limit_exceeded_response(limiter, key: str):
reset_time = limiter.get_reset_time()
retry_after = reset_time - int(time.time())
response = jsonify({
'error': 'Rate limit exceeded',
'message': f'Try again in {retry_after} seconds',
'retry_after': retry_after,
'limit': limiter.limit,
'reset': reset_time
})
response.status_code = 429
response.headers['Retry-After'] = str(retry_after)
response.headers['X-RateLimit-Limit'] = str(limiter.limit)
response.headers['X-RateLimit-Remaining'] = '0'
response.headers['X-RateLimit-Reset'] = str(reset_time)
return response
DON'T use fixed window for critical endpoints:
# ❌ BAD - Allows 200 requests in 1 second (burst at boundary)
limiter = FixedWindow(redis_client, key, limit=100, window_seconds=60)
# ✅ GOOD - Use sliding window or token bucket
limiter = SlidingWindow(redis_client, key, limit=100, window_seconds=60)
DON'T forget to handle Redis failures:
# ❌ BAD - Crashes on Redis failure
if limiter.allow_request():
return response
# ✅ GOOD - Fail open (allow request) on errors
try:
if not limiter.allow_request():
return rate_limit_exceeded_response(limiter, key)
except redis.RedisError:
# Log error but allow request
logger.error("Rate limiter unavailable")
DON'T rate limit health checks:
# ✅ GOOD - Exempt monitoring endpoints
EXEMPT_PATHS = ['/health', '/metrics', '/status']
@app.before_request
def check_rate_limit():
if request.path in EXEMPT_PATHS:
return None # Skip rate limiting
# Apply rate limiting
...
The api-rate-limiting skill includes comprehensive resources for implementing production-grade rate limiting:
Location: skills/api/api-rate-limiting/resources/REFERENCE.md
Comprehensive technical reference (3,100+ lines) covering:
Core Topics:
Advanced Topics:
Language Implementations:
References:
1. test_rate_limits.py - API Rate Limiting Testing Tool
Tests API rate limiting behavior by sending requests and analyzing responses.
# Test with burst
./test_rate_limits.py --endpoint https://api.example.com/resource
# Sustained load test
./test_rate_limits.py --endpoint https://api.example.com --rps 10 --duration 60
# Test boundary burst vulnerability
./test_rate_limits.py --endpoint https://api.example.com --boundary-test --limit 100 --window 60
# JSON output
./test_rate_limits.py --endpoint https://api.example.com --json
Features:
2. analyze_patterns.py - Traffic Pattern Analysis Tool
Analyzes API access logs to recommend optimal rate limit configurations.
# Analyze access log
./analyze_patterns.py --log-file /var/log/nginx/access.log
# JSON output
./analyze_patterns.py --log-file access.log --json
# Custom time window
./analyze_patterns.py --log-file access.log --time-window 3600
Features:
3. benchmark_throughput.py - Performance Benchmarking Tool
Benchmarks rate limiter implementation performance and compares algorithms.
# Benchmark single algorithm
./benchmark_throughput.py --algorithm token_bucket
# Compare all algorithms
./benchmark_throughput.py --algorithm all
# Custom load parameters
./benchmark_throughput.py --algorithm sliding_window --concurrent-users 100 --requests 50
# JSON output
./benchmark_throughput.py --algorithm all --json
Features:
Location: skills/api/api-rate-limiting/resources/examples/
1. python/token_bucket.py - Token Bucket with Redis
Production-ready token bucket implementation using Redis and Lua scripts.
Features:
limiter = TokenBucket(redis_client, "user:123", capacity=100, refill_rate=10)
if limiter.consume():
response = handle_request()
else:
wait = limiter.wait_time()
return error_response(f"Rate limited. Retry in {wait:.1f}s", 429)
2. python/sliding_window.py - Sliding Window with Redis
Sliding window implementation using Redis sorted sets for smooth rate limiting.
Features:
limiter = SlidingWindow(redis_client, "user:123", limit=100, window_seconds=60)
allowed, remaining = limiter.allow_request_with_remaining()
if allowed:
print(f"Request allowed. {remaining} remaining")
3. nodejs/express-rate-limiter.js - Express Middleware
Complete Express middleware for rate limiting with Redis backend.
Features:
const rateLimiter = createRateLimiter(redisClient);
app.use('/api', rateLimiter({ limit: 100, window: 60 }));
4. go/rate_limiter.go - Go Implementation
Go rate limiter using golang.org/x/time/rate with Redis backend.
Features:
limiter := NewRedisRateLimiter(redisClient, 100, 10)
if limiter.Allow("user:123") {
// Process request
}
5. python/fastapi-limiter.py - FastAPI Integration
FastAPI rate limiting with async Redis operations and dependency injection.
Features:
@app.get("/api/resource")
async def get_resource(
_: None = Depends(rate_limit(limit=100, window=60))
):
return {"data": "value"}
6. redis/lua-rate-limiter.lua - Lua Scripts Collection
Complete collection of Lua scripts for atomic rate limiting in Redis.
Includes:
7. config/nginx-rate-limit.conf - Nginx Configuration
Production-ready Nginx configuration using limit_req module.
Features:
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;
location /api/ {
limit_req zone=api burst=200 delay=100;
proxy_pass http://backend;
}
8. python/distributed-limiter.py - Advanced Distributed Limiter
Complete distributed rate limiter supporting multiple algorithms and strategies.
Features:
limiter = DistributedRateLimiter(redis_client)
result = limiter.check_limit("user:123", limits={
"per_second": 10,
"per_minute": 100,
"per_hour": 1000
})
NEED BURSTS?
├─ Yes → Token Bucket (python/token_bucket.py)
└─ No → Need smooth limiting?
├─ Yes → Sliding Window (python/sliding_window.py)
└─ No → Fixed Window (simplest, nginx-rate-limit.conf)
DISTRIBUTED?
├─ Yes → Use Redis examples (all Python/Node.js/Go examples)
└─ No → Use in-memory (go/rate_limiter.go LocalRateLimiter)
LANGUAGE?
├─ Python → fastapi-limiter.py or distributed-limiter.py
├─ Node.js → express-rate-limiter.js
├─ Go → rate_limiter.go
└─ Nginx → nginx-rate-limit.conf