Module 5: Capstone Project — API with Caching Strategy
Implementation: Caching + Rate Limiting
Overview
You build the first block of features on top of capsule 02's scaffolding. Here you implement the RateLimitMiddleware with a multi-tier sliding window (the 3 tiers free/pro/enterprise with differentiated limits), the CacheMiddleware that caches GET responses automatically, and the /api/products, /api/categories, and /api/users routers that use cache-aside with correct invalidation. By the end of the capsule, the API responds to real requests with working caching and rate limiting.
This capsule is 100% code. You saw the concepts in modules 2 and 3 — here you integrate them coherently. Every design decision applies the patterns you already learned: the rate limit middleware runs BEFORE the cache middleware (because we want to rate-limit even requests that would be a cache HIT), the cache middleware honors the endpoint's Cache-Control: max-age=, and the endpoints use consistent helpers for cache-aside. Capsule 04 adds sessions + Pub/Sub + write-through/write-behind on top of this foundation.
You'll verify the result with automated tests: 100 quick requests to confirm the rate limiting (the free tier rejects after 100 in an hour), hit rate benchmarks (cache-aside should reach >85% on realistic workloads), and correct HTTP headers on every response. It's the capsule where the project stops being scaffolding and starts being a real API.
The RateLimitMiddleware with a sliding window
Create app/rate_limit/sliding_window.py:
"""
A sliding window rate limiter (a refactor of module 3's capsule 03).
"""
import time
import uuid
import logging
from redis.asyncio import Redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
async def check_sliding_window(
r: Redis,
key: str,
limit: int,
window_seconds: int,
) -> tuple[bool, dict]:
"""
Checks the rate limit with a sliding window.
Returns:
(allowed, info) where info includes:
- limit, remaining, retry_after_seconds, reset_in_seconds
"""
now = time.time()
window_start = now - window_seconds
member = f"req:{now}:{uuid.uuid4().hex[:8]}"
try:
pipe = r.pipeline()
pipe.zremrangebyscore(key, 0, window_start)
pipe.zadd(key, {member: now})
pipe.zcard(key)
pipe.expire(key, window_seconds + 60)
_, _, count, _ = await pipe.execute()
if count > limit:
# Rollback
await r.zrem(key, member)
# Calculate retry_after from the oldest entry
oldest = await r.zrange(key, 0, 0, withscores=True)
if oldest:
oldest_score = oldest[0][1]
retry_after = max(0, oldest_score + window_seconds - now)
else:
retry_after = window_seconds
return (False, {
"limit": limit,
"remaining": 0,
"retry_after_seconds": round(retry_after, 1),
"reset_in_seconds": int(window_seconds),
})
return (True, {
"limit": limit,
"remaining": limit - count,
"retry_after_seconds": 0,
"reset_in_seconds": int(window_seconds),
})
except RedisError as e:
logger.warning(f"Rate limit check failed (Redis error): {e}. Allowing request (degraded).")
return (True, {
"limit": limit,
"remaining": limit,
"retry_after_seconds": 0,
"reset_in_seconds": int(window_seconds),
"degraded": True,
})
app/rate_limit/middleware.py
"""
The RateLimitMiddleware for FastAPI.
"""
import time
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from app.config import TIER_LIMITS, ENDPOINT_CATEGORY, DEFAULT_RATE_CATEGORY
from app.redis_client import get_redis
from app.rate_limit.sliding_window import check_sliding_window
from app.auth.jwt_handler import decode_jwt_safe # capsule 04 creates it
logger = logging.getLogger(__name__)
SKIP_PATHS = {"/", "/health", "/metrics", "/docs", "/openapi.json", "/redoc"}
class RateLimitMiddleware(BaseHTTPMiddleware):
"""
Multi-tier sliding window rate limiting.
It identifies the user by:
1. The JWT (if present) → the tier from the session in Redis
2. An IP fallback → the "free" tier
"""
async def dispatch(self, request: Request, call_next):
# Skip rate limiting on the system endpoints
if request.url.path in SKIP_PATHS:
return await call_next(request)
# Identify the user and the tier
user_id, tier = await self._get_user_and_tier(request)
rate_key_id = f"user:{user_id}" if user_id else f"ip:{request.client.host if request.client else 'unknown'}"
# Determine the endpoint's category
endpoint_key = f"{request.method}:{request.url.path}"
# Simplified path matching (in production, use a regex)
category = self._match_category(request.method, request.url.path)
# Get the tier's limit for this category
tier_config = TIER_LIMITS.get(tier, TIER_LIMITS["free"])
limit, window = tier_config[category]
# Check the rate limit
rate_key = f"rate:{tier}:{category}:{rate_key_id}"
r = get_redis()
allowed, info = await check_sliding_window(r, rate_key, limit, window)
if not allowed:
logger.info(f"Rate limited: {rate_key_id} on {category} (tier={tier})")
return JSONResponse(
status_code=429,
content={
"error": "rate_limit_exceeded",
"message": f"Rate limit exceeded for {category}. Retry after {int(info['retry_after_seconds'])}s.",
"retry_after_seconds": int(info["retry_after_seconds"]),
"category": category,
},
headers={
"X-RateLimit-Limit": str(info["limit"]),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(int(time.time() + info["retry_after_seconds"])),
"Retry-After": str(int(info["retry_after_seconds"])),
}
)
# Add headers and continue
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(info["limit"])
response.headers["X-RateLimit-Remaining"] = str(info["remaining"])
response.headers["X-RateLimit-Reset"] = str(int(time.time() + info["reset_in_seconds"]))
if info.get("degraded"):
response.headers["X-RateLimit-Degraded"] = "true"
return response
async def _get_user_and_tier(self, request: Request) -> tuple[int | None, str]:
"""Extracts the user_id + tier from the JWT (if it exists)."""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
return (None, "free")
token = auth_header.split(" ", 1)[1]
payload = decode_jwt_safe(token)
if not payload:
return (None, "free")
# Check the session in Redis to get the current tier
session_id = payload.get("session_id")
if not session_id:
return (payload.get("user_id"), "free")
try:
r = get_redis()
tier = await r.hget(f"session:{session_id}", "tier")
return (payload.get("user_id"), tier or "free")
except Exception:
return (payload.get("user_id"), "free")
def _match_category(self, method: str, path: str) -> str:
"""Match endpoint to rate limit category."""
# An exact match
key = f"{method}:{path}"
if key in ENDPOINT_CATEGORY:
return ENDPOINT_CATEGORY[key]
# A prefix match (for paths with an id)
for endpoint_key, category in ENDPOINT_CATEGORY.items():
ep_method, ep_path = endpoint_key.split(":", 1)
if method == ep_method and path.startswith(ep_path):
return category
return DEFAULT_RATE_CATEGORY
The CacheMiddleware
Create app/caching/middleware.py:
"""
The CacheMiddleware: it caches GET responses automatically.
"""
import hashlib
import json
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from redis.exceptions import RedisError
from app.redis_client import get_redis
logger = logging.getLogger(__name__)
CACHEABLE_PREFIXES = (
"/api/products",
"/api/categories",
"/api/users",
)
SKIP_PATHS = {"/", "/health", "/metrics", "/docs", "/openapi.json", "/redoc"}
class CacheMiddleware(BaseHTTPMiddleware):
"""
Caches GET responses with a TTL read from the Cache-Control header.
It generates a cache key based on the path + sorted query params.
It skips on a client's Cache-Control: no-cache.
"""
def __init__(self, app, default_ttl: int = 300):
super().__init__(app)
self.default_ttl = default_ttl
async def dispatch(self, request: Request, call_next):
# Only cache GETs
if request.method != "GET":
return await call_next(request)
# Skip the system endpoints
if request.url.path in SKIP_PATHS:
return await call_next(request)
# Only cache the cacheable paths
if not any(request.url.path.startswith(p) for p in CACHEABLE_PREFIXES):
return await call_next(request)
# Skip if the client asks for no-cache
if "no-cache" in request.headers.get("cache-control", "").lower():
return await call_next(request)
cache_key = self._build_key(request)
r = get_redis()
# Try for a cache HIT
try:
cached = await r.get(cache_key)
if cached:
logger.debug(f"Cache HIT: {cache_key}")
# Increment metrics
await r.incr("metrics:cache:hits")
return Response(
content=cached,
media_type="application/json",
headers={"X-Cache": "HIT"},
)
except RedisError as e:
logger.warning(f"Cache read failed for {cache_key}: {e}")
# Cache MISS: run the endpoint
response = await call_next(request)
try:
await r.incr("metrics:cache:misses")
except RedisError:
pass
# Cache it if it succeeded
if response.status_code == 200:
try:
# Read the body
body = b""
async for chunk in response.body_iterator:
body += chunk
# The TTL from the endpoint's Cache-Control, falling back to the default
cache_control = response.headers.get("cache-control", "")
ttl = self._extract_max_age(cache_control) or self.default_ttl
await r.set(cache_key, body, ex=ttl)
logger.debug(f"Cache MISS stored: {cache_key} (TTL={ttl}s)")
# Rebuild the response (we consumed the body)
return Response(
content=body,
status_code=200,
media_type=response.media_type,
headers={**dict(response.headers), "X-Cache": "MISS"},
)
except RedisError as e:
logger.warning(f"Cache write failed for {cache_key}: {e}")
return response
return response
def _build_key(self, request: Request) -> str:
"""Generates a unique cache key for the request."""
# The path + sorted query params
params = sorted(request.query_params.items())
params_str = "&".join(f"{k}={v}" for k, v in params)
raw = f"{request.url.path}?{params_str}"
# Hash for short keys
h = hashlib.sha256(raw.encode()).hexdigest()[:16]
return f"cache:http:{request.url.path}:{h}"
def _extract_max_age(self, cache_control: str) -> int | None:
if "max-age=" in cache_control:
try:
return int(cache_control.split("max-age=")[1].split(",")[0].strip())
except (IndexError, ValueError):
pass
return None
Routers: /api/products
Create app/routers/api.py:
"""
The protected API endpoints: products, categories, users (reads), search.
"""
import json
import logging
from fastapi import APIRouter, HTTPException, Depends, Response
from redis.asyncio import Redis
from app.config import CACHE_TTL, SEARCH_TOP_QUERIES, PUBSUB_INVALIDATE_PREFIX
from app.redis_client import get_redis_dep
from app.models import (
Product, ProductCreate, ProductUpdate, ProductSummary,
Category, User, UserUpdate
)
from app.db import (
db_get_products, db_get_product, db_create_product,
db_update_product, db_delete_product,
db_get_categories, db_get_user, db_update_user,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["API"])
# ═══════════════════════════════════════════════════════════
# Products: cache-aside with invalidation
# ═══════════════════════════════════════════════════════════
@router.get("/products", response_model=list[ProductSummary])
async def list_products(
response: Response,
skip: int = 0,
limit: int = 20,
r: Redis = Depends(get_redis_dep),
):
"""
Cache-aside with a 5 min TTL.
The cache key includes skip+limit to distinguish the pages.
The middleware already caches this automatically, but we also do it
manually for the demo (in production, only one).
"""
response.headers["Cache-Control"] = f"max-age={CACHE_TTL['products_list']}"
# The middleware will cache it. The endpoint just returns the data.
items = await db_get_products(skip=skip, limit=limit)
return [ProductSummary(**p) for p in items]
@router.get("/products/{product_id}", response_model=Product)
async def get_product(
product_id: int,
response: Response,
r: Redis = Depends(get_redis_dep),
):
"""Cache-aside with a 10 min TTL."""
cache_key = f"cache:product:{product_id}"
# Try the cache (manually, before the middleware)
try:
cached = await r.get(cache_key)
if cached:
response.headers["X-Cache"] = "HIT"
return Product(**json.loads(cached))
except Exception as e:
logger.warning(f"Cache read failed: {e}")
# A DB query
product = await db_get_product(product_id)
if not product:
raise HTTPException(404, "Product not found")
response.headers["Cache-Control"] = f"max-age={CACHE_TTL['product_detail']}"
response.headers["X-Cache"] = "MISS"
# Cache it (best effort)
try:
await r.set(cache_key, json.dumps(product), ex=CACHE_TTL["product_detail"])
except Exception as e:
logger.warning(f"Cache write failed: {e}")
response.headers["X-Cache"] = "BYPASS"
return Product(**product)
@router.post("/products", response_model=Product, status_code=201)
async def create_product(
data: ProductCreate,
r: Redis = Depends(get_redis_dep),
):
"""
Create a product + invalidate the related caches.
It also publishes an event on Pub/Sub.
"""
new_product = await db_create_product(data.model_dump())
# Invalidate caches manually
try:
# Invalidate the list caches (every page)
cursor = 0
while True:
cursor, keys = await r.scan(cursor=cursor, match="cache:http:/api/products:*", count=100)
if keys:
await r.delete(*keys)
if cursor == 0:
break
# Publish the invalidation event
await r.publish(
f"{PUBSUB_INVALIDATE_PREFIX}:product:list",
json.dumps({"action": "create", "product_id": new_product["id"]})
)
except Exception as e:
logger.warning(f"Cache invalidation failed: {e}")
return Product(**new_product)
@router.put("/products/{product_id}", response_model=Product)
async def update_product(
product_id: int,
data: ProductUpdate,
r: Redis = Depends(get_redis_dep),
):
"""An update + a specific invalidation + Pub/Sub."""
updated = await db_update_product(product_id, data.model_dump(exclude_unset=True))
if not updated:
raise HTTPException(404, "Product not found")
# Invalidate
try:
await r.delete(f"cache:product:{product_id}")
# And the lists too
cursor = 0
while True:
cursor, keys = await r.scan(cursor=cursor, match="cache:http:/api/products*", count=100)
if keys:
await r.delete(*keys)
if cursor == 0:
break
# Pub/Sub
await r.publish(
f"{PUBSUB_INVALIDATE_PREFIX}:product:{product_id}",
json.dumps({"action": "update", "product_id": product_id})
)
except Exception as e:
logger.warning(f"Cache invalidation failed: {e}")
return Product(**updated)
@router.delete("/products/{product_id}", status_code=204)
async def delete_product(
product_id: int,
r: Redis = Depends(get_redis_dep),
):
"""A delete + invalidation + Pub/Sub."""
deleted = await db_delete_product(product_id)
if not deleted:
raise HTTPException(404, "Product not found")
try:
await r.delete(f"cache:product:{product_id}")
cursor = 0
while True:
cursor, keys = await r.scan(cursor=cursor, match="cache:http:/api/products*", count=100)
if keys:
await r.delete(*keys)
if cursor == 0:
break
await r.publish(
f"{PUBSUB_INVALIDATE_PREFIX}:product:{product_id}",
json.dumps({"action": "delete", "product_id": product_id})
)
except Exception as e:
logger.warning(f"Cache invalidation failed: {e}")
# ═══════════════════════════════════════════════════════════
# Categories: cache-aside with a long TTL
# ═══════════════════════════════════════════════════════════
@router.get("/categories", response_model=list[Category])
async def list_categories(
response: Response,
r: Redis = Depends(get_redis_dep),
):
"""Cache-aside with a 1h TTL. They rarely change."""
response.headers["Cache-Control"] = f"max-age={CACHE_TTL['categories']}"
items = await db_get_categories()
return [Category(**c) for c in items]
# ═══════════════════════════════════════════════════════════
# Users: cache-aside with a hash + a sliding TTL
# ═══════════════════════════════════════════════════════════
@router.get("/users/{user_id}", response_model=User)
async def get_user(
user_id: int,
response: Response,
r: Redis = Depends(get_redis_dep),
):
"""
Cache-aside with a hash. A sliding TTL renewed on every access.
If the user is active, their profile stays in the cache.
"""
cache_key = f"cache:user:{user_id}"
# Try the cache (a hash)
try:
cached = await r.hgetall(cache_key)
if cached:
# Renew the TTL (sliding)
await r.expire(cache_key, CACHE_TTL["user_profile"])
response.headers["X-Cache"] = "HIT"
return User(**cached)
except Exception as e:
logger.warning(f"Cache read failed: {e}")
# The DB
user = await db_get_user(user_id)
if not user:
raise HTTPException(404, "User not found")
response.headers["X-Cache"] = "MISS"
# Cache it (a hash)
try:
# Convert every value to a string for HSET
cache_data = {k: str(v) for k, v in user.items()}
await r.hset(cache_key, mapping=cache_data)
await r.expire(cache_key, CACHE_TTL["user_profile"])
except Exception as e:
logger.warning(f"Cache write failed: {e}")
response.headers["X-Cache"] = "BYPASS"
return User(**user)
# The update endpoint (write-through) — capsule 04 adds it
# ═══════════════════════════════════════════════════════════
# Search: cache the top queries only
# ═══════════════════════════════════════════════════════════
@router.get("/search")
async def search(
q: str,
response: Response,
r: Redis = Depends(get_redis_dep),
):
"""
Selective cache-aside: it only caches queries in SEARCH_TOP_QUERIES.
Unique/rare queries are NOT cached (an infinite space).
"""
if not q:
raise HTTPException(400, "Query parameter 'q' required")
q_lower = q.lower().strip()
# Only cache the top queries
should_cache = q_lower in SEARCH_TOP_QUERIES
cache_key = f"cache:search:{q_lower}" if should_cache else None
# Try the cache
if should_cache:
try:
cached = await r.get(cache_key)
if cached:
response.headers["X-Cache"] = "HIT"
return json.loads(cached)
except Exception as e:
logger.warning(f"Cache read failed: {e}")
# A mock search (in production: Elasticsearch, Algolia, etc.)
import asyncio
await asyncio.sleep(0.1) # 100ms simulated search
results = {
"query": q,
"results": [{"id": i, "name": f"Result for {q} #{i}"} for i in range(1, 11)],
"total": 10,
}
response.headers["X-Cache"] = "MISS" if should_cache else "BYPASS"
# Cache it if it applies
if should_cache:
try:
await r.set(cache_key, json.dumps(results), ex=CACHE_TTL["search_top_queries"])
except Exception as e:
logger.warning(f"Cache write failed: {e}")
return results
Integrating it in app/main.py
Modify app/main.py to add the middleware and the router:
"""
Production Cached API - the capsule 03 version (caching + rate limiting).
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from redis.asyncio import Redis
from app.config import LOG_LEVEL, CORS_ORIGINS
from app.redis_client import init_pool, close_pool, get_redis, get_redis_dep
from app.db import seed_data
from app.rate_limit.middleware import RateLimitMiddleware
from app.caching.middleware import CacheMiddleware
from app.routers import api as api_router
logging.basicConfig(
level=LOG_LEVEL,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s"
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
init_pool()
r = get_redis()
try:
await r.ping()
logger.info("✓ Redis connected")
except Exception as e:
logger.error(f"✗ Redis failed: {e}")
raise
seed_data()
logger.info("✓ Mock DB seeded")
logger.info("✓ API ready")
yield
await close_pool()
logger.info("✓ Cleanup complete")
app = FastAPI(
title="Production Cached API",
version="1.0.0",
lifespan=lifespan,
)
# Middleware (added in reverse of execution order — the last one added runs first)
# Execution: CORS → RateLimit → Cache → endpoint
app.add_middleware(CacheMiddleware, default_ttl=300)
app.add_middleware(RateLimitMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(api_router.router)
@app.get("/health")
async def health(r: Redis = Depends(get_redis_dep)):
health_data = {"status": "healthy", "services": {}}
try:
await r.ping()
info = await r.info("server")
memory = await r.info("memory")
health_data["services"]["redis"] = {
"status": "healthy",
"version": info.get("redis_version"),
"memory_used": memory.get("used_memory_human"),
}
except Exception as e:
health_data["status"] = "degraded"
health_data["services"]["redis"] = {"status": "unhealthy", "reason": str(e)}
return health_data
@app.get("/metrics")
async def metrics(r: Redis = Depends(get_redis_dep)):
try:
hits = int(await r.get("metrics:cache:hits") or 0)
misses = int(await r.get("metrics:cache:misses") or 0)
total = hits + misses
hit_rate = (hits / total * 100) if total > 0 else 0
return {
"cache": {
"hits": hits,
"misses": misses,
"hit_rate_percent": round(hit_rate, 2),
},
}
except Exception as e:
return {"error": str(e)}
@app.get("/")
async def root():
return {"name": "Production Cached API", "docs": "/docs"}
⚠️ A temporary stub: decode_jwt_safe() in rate_limit/middleware.py — capsule 04 implements it. For now, create a stub:
# app/auth/jwt_handler.py (a temporary stub)
def decode_jwt_safe(token: str) -> dict | None:
"""STUB: capsule 04 implements it with PyJWT."""
return None
Verification
# Start it
uvicorn app.main:app --reload
# Test 1: Health
curl http://localhost:8000/health | jq
# Test 2: GET products (cache MISS → HIT)
curl -i http://localhost:8000/api/products | head -10
# X-Cache: MISS
# X-RateLimit-Limit: 100 ← the free tier (no auth)
# X-RateLimit-Remaining: 99
curl -i http://localhost:8000/api/products | head -10
# X-Cache: HIT
# X-RateLimit-Remaining: 98 ← the rate limit counts EVERY request
# Test 3: GET with query params (a different cache key)
curl -i "http://localhost:8000/api/products?skip=10&limit=5" | head -10
# X-Cache: MISS (a different cache key)
curl -i "http://localhost:8000/api/products?skip=10&limit=5" | head -10
# X-Cache: HIT
# Test 4: GET a single product
curl -i http://localhost:8000/api/products/1 | head -10
# X-Cache: MISS (manual cache-aside)
curl -i http://localhost:8000/api/products/1 | head -10
# X-Cache: HIT
# Test 5: A POST invalidates the list
curl -X POST http://localhost:8000/api/products \
-H "Content-Type: application/json" \
-d '{"name": "New Product", "price": 99.99, "stock": 10, "category_id": 1}' | jq
# (it creates the product and invalidates the list cache)
curl -i http://localhost:8000/api/products | head -10
# X-Cache: MISS (the cache was invalidated)
# Test 6: A top search query (cached)
curl -i "http://localhost:8000/api/search?q=laptop" | head -10
# X-Cache: MISS
curl -i "http://localhost:8000/api/search?q=laptop" | head -10
# X-Cache: HIT
# Test 7: A rare search query (NOT cached)
curl -i "http://localhost:8000/api/search?q=xyz123" | head -10
# X-Cache: BYPASS (it doesn't cache)
curl -i "http://localhost:8000/api/search?q=xyz123" | head -10
# X-Cache: BYPASS (still not cached)
# Test 8: The rate limit (the free tier: 100/hr on general)
for i in {1..105}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/products)
if [ "$STATUS" != "200" ]; then echo "Req $i: $STATUS"; fi
done
# Expected: Req 101+ return a 429
# Test 9: Metrics
curl http://localhost:8000/metrics | jq
# {
# "cache": {
# "hits": 50,
# "misses": 5,
# "hit_rate_percent": 90.91
# }
# }
Automated tests
Create tests/test_caching.py:
import pytest
import httpx
BASE = "http://localhost:8000"
@pytest.mark.asyncio
async def test_cache_hit_on_second_request():
"""Verify cache works: second request is HIT."""
async with httpx.AsyncClient() as client:
r1 = await client.get(f"{BASE}/api/categories")
assert r1.status_code == 200
# First might be MISS or HIT depending on previous tests
r2 = await client.get(f"{BASE}/api/categories")
assert r2.status_code == 200
assert r2.headers["x-cache"] == "HIT"
@pytest.mark.asyncio
async def test_cache_invalidation_on_post():
"""Verify POST invalidates list cache."""
async with httpx.AsyncClient() as client:
# Prime cache
await client.get(f"{BASE}/api/products?skip=0&limit=5")
r_hit = await client.get(f"{BASE}/api/products?skip=0&limit=5")
assert r_hit.headers["x-cache"] == "HIT"
# POST invalidates
await client.post(f"{BASE}/api/products", json={
"name": "Test", "price": 10.0, "stock": 1, "category_id": 1,
})
# Next GET is MISS
r_miss = await client.get(f"{BASE}/api/products?skip=0&limit=5")
assert r_miss.headers["x-cache"] == "MISS"
@pytest.mark.asyncio
async def test_search_top_query_cached():
async with httpx.AsyncClient() as client:
r1 = await client.get(f"{BASE}/api/search?q=laptop")
r2 = await client.get(f"{BASE}/api/search?q=laptop")
assert r2.headers["x-cache"] == "HIT"
@pytest.mark.asyncio
async def test_search_rare_query_bypass():
async with httpx.AsyncClient() as client:
unique_q = f"random_{__import__('uuid').uuid4().hex[:8]}"
r1 = await client.get(f"{BASE}/api/search?q={unique_q}")
assert r1.headers["x-cache"] == "BYPASS"
@pytest.mark.asyncio
async def test_no_cache_header_skips():
async with httpx.AsyncClient() as client:
r = await client.get(
f"{BASE}/api/products",
headers={"Cache-Control": "no-cache"},
)
# The cache middleware honors no-cache. The endpoint can add X-Cache: MISS but the middleware must not serve a HIT.
# In this implementation, when there's a no-cache, the middleware does NOT touch the response.
# Verify the request went to the endpoint (it wasn't served from the cache)
# tests/test_rate_limit.py
import asyncio
import pytest
import httpx
BASE = "http://localhost:8000"
@pytest.mark.asyncio
async def test_rate_limit_kicks_in():
"""The free tier: 100/hr on general. Request 101 should be a 429."""
async with httpx.AsyncClient() as client:
# Clean up Redis first (in real tests)
# ...
# 100 requests OK
for i in range(100):
r = await client.get(f"{BASE}/api/products")
assert r.status_code in (200, 429) # Some might 429 if previous tests left counters
# Eventually we hit the limit
# Verify we see at least one 429
r = await client.get(f"{BASE}/api/products")
# If the previous 100 were in the same window, this one is a 429
Run them:
pytest tests/ -v -s
Troubleshooting
Problem 1: The middleware order is wrong
Cause: In FastAPI, middleware runs in reverse of the order you add it. The last one added runs first.
Solution:
# Execution: CORS → RateLimit → Cache → endpoint
# So you add them in reverse order:
app.add_middleware(CacheMiddleware) # last → the first to run before the endpoint
app.add_middleware(RateLimitMiddleware) # before that
app.add_middleware(CORSMiddleware) # added first → the last to run
A reminder: on every request, the flow is: CORS takes the request → Rate Limit checks → the Cache looks → the Endpoint runs. On the response, it goes the other way.
Problem 2: The headers don't show up on 429 responses
Cause: The RateLimitMiddleware returns a JSONResponse(...) without the correct headers.
Solution: Make sure you include every required header in the JSONResponse(headers={...}).
Problem 3: The cache key includes the auth headers (a cache leak between users)
Cause: The cache key is based on the path + query params, NOT the headers. If two users make a GET /api/products, they share the cache key — this is CORRECT for public data.
Solution: For per-user data (e.g., /api/me/orders), do NOT use the automatic middleware. Cache it manually with the key cache:me:{user_id}:orders.
Problem 4: X-Cache: HIT but with old data
Cause: The invalidation pattern doesn't cover every cache key.
Solution: On every write operation, invalidate with a SCAN over a pattern:
async for key in r.scan_iter(match="cache:http:/api/products*"):
await r.delete(key)
Problem 5: The rate limit counts requests served from the cache
Cause: The RateLimitMiddleware runs BEFORE the CacheMiddleware. This is deliberate.
Why: we want to rate-limit even users who only read from the cache. If an attacker makes 10000 requests/sec to /api/products (all cache HITs), it would still be bad for your app just from processing the middleware.
Solution: It's the correct behavior. Don't change it.
Problem 6: The SCAN pattern matching doesn't find every key
Cause: The middleware's cache key pattern includes a hash at the end.
Solution: Use a wildcard at the end:
# The cache keys: "cache:http:/api/products:abc123def456"
# The correct pattern:
await r.scan_iter(match="cache:http:/api/products*") # with a wildcard
Summary
In this capsule you built:
The RateLimitMiddleware:
- A sliding window with sorted sets (a refactor of M3's capsule 03)
- Multi-tier: free/pro/enterprise with limits per category
- It identifies the user by JWT (with a stub for capsule 04)
- Standard HTTP headers:
X-RateLimit-*,Retry-After - The 429 Too Many Requests status
The CacheMiddleware:
- It caches GET responses automatically
- The cache key: a hash of the path + sorted query params
- It honors the endpoint's
Cache-Control: max-age= - It skips on a client's
Cache-Control: no-cache - Headers:
X-Cache: HIT/MISS/BYPASS - Metrics: hit/miss counters
The /api/* routers:
/api/products: a list with automatic cache-aside/api/products/{id}: the detail with manual cache-aside + a sliding TTLPOST /api/products: invalidates the list + a Pub/Sub eventPUT /api/products/{id}: a specific invalidation + Pub/SubDELETE /api/products/{id}: invalidation + Pub/Sub/api/categories: a long cache TTL (1h)/api/users/{id}: cache-aside with a HASH (granular updates)/api/search: caches only the top queries (an infinite space)
Verification:
- Correct headers in the responses
- Working rate limiting (a 429 after N requests)
- Cache hits/misses matching expectations
- Correct invalidation (POST → the next GET is a MISS)
- Working pattern matching for cacheable vs rare queries
The critical part: the API now has fully working caching + rate limiting. Everything you learned in M2 and M3 is professionally integrated. Capsule 04 adds sessions + a Pub/Sub listener + write-through/write-behind on top of this foundation.
Additional resources
- FastAPI Middleware Order — The ordering behavior
- Cache-Control HTTP Header — The header's spec
- Rate Limiting Best Practices — Stripe's patterns
- Cache Stampede Prevention — You saw it in M2's capsule 05; here you could add locking
- Async Python with Redis — The official patterns
- 12-Factor Logs — Why structured logging matters
What's next?
In Capsule 04 you complete the project by adding: JWT + Redis sessions (login, logout, logout-all, list devices), write-through for PUT /api/users/{id}, write-behind for POST /analytics/event with an async worker, a Pub/Sub listener that receives invalidation events and acts on them, a WebSocket bridge for real-time notifications, and extended metrics.
Keep Redis running. Let's go.