Module 5: Capstone Project — API with Caching Strategy
Capsule 04 — Sessions + Pub/Sub + Monitoring
Overview
In the previous capsule you left the project with working caching + rate limiting. Now you close out the business logic by adding the three blocks that are missing for the API to be production-ready:
- Sessions with JWT + Redis: real login, immediate logout (not waiting for the JWT to expire), logout-all (invalidating all the user's sessions), list devices.
- Write-through for
PUT /api/users/{id}: when you update a user, you write to Postgres and to Redis in the same operation. The cache is always fresh, the write latency is a bit higher — the tradeoff you saw in Module 2. - Write-behind for
POST /analytics/event: the client gets an immediate202 Accepted; an async worker drains the Redis queue into Postgres in batches. Maximum throughput, eventual durability. - A Pub/Sub listener: a background task that listens on the
cache:invalidatechannel and processes events (logging, metrics, future cases like notifying external workers). This closes the distributed invalidation loop you saw in M4's capsule 02. - A WebSocket bridge
/ws/notifications: connected clients receive Redis Pub/Sub events in real time (you already built it in M4's capsule 05; here you integrate it into the project). - Extended metrics in
/metrics: on top of cache hits/misses, you now report active sessions, Pub/Sub events received, the write-behind queue length, and captured errors.
By the end of this capsule, the project has all the guide's features integrated. Capsule 05 is just the close: Docker Compose, end-to-end verification, a README, and a portfolio.
The tone: you keep building on what you already have. Each block references the module where you learned it and integrates it into the project. Without re-explaining the fundamentals.
JWT + Redis sessions
In capsule 03 you left a _get_user_and_tier() stub in the rate limit middleware that always returned None. It's time to replace it with a real JWT plus sessions in Redis.
The pattern (a quick reminder)
You saw it in full in Module 3's capsule 04. A one-line reminder: the JWT identifies the user, but the session lives in Redis (a HASH with metadata) and in a SET per user (user:{id}:sessions). Logout = DEL session:{sid} + SREM user:{id}:sessions {sid}. Logout-all = a pipeline of DELs over every sid in the SET.
app/auth/jwt_handler.py
Replace the stub file (if it existed) with the complete implementation.
# app/auth/jwt_handler.py
from datetime import datetime, timezone, timedelta
from typing import Optional
import jwt as pyjwt
from fastapi import HTTPException, status
from app.config import settings
def create_access_token(user_id: str, session_id: str, tier: str = "free") -> str:
"""Creates a JWT with the user_id, session_id, and tier."""
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"sid": session_id,
"tier": tier,
"iat": now,
"exp": now + timedelta(seconds=settings.JWT_EXPIRES_SECONDS),
}
return pyjwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
def decode_access_token(token: str) -> dict:
"""Decodes a JWT. It raises an HTTPException if it's invalid or expired."""
try:
payload = pyjwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
return payload
except pyjwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
except pyjwt.InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
You need two settings in app/config.py (add them if you don't have them):
# app/config.py — add
JWT_SECRET: str = "change-me-in-production" # In a real .env
JWT_EXPIRES_SECONDS: int = 3600 # 1h
SESSION_TTL_SECONDS: int = 86400 # 24h (longer than the JWT)
app/auth/sessions.py
Sessions as a HASH + a SET per user. The pattern is identical to M3's capsule 04, adapted to the project.
# app/auth/sessions.py
import secrets
from datetime import datetime, timezone
from typing import Optional
from redis.asyncio import Redis
from app.config import settings
def _session_key(session_id: str) -> str:
return f"session:{session_id}"
def _user_sessions_key(user_id: str) -> str:
return f"user:{user_id}:sessions"
async def create_session(
r: Redis,
user_id: str,
tier: str,
user_agent: str = "",
ip: str = "",
) -> str:
"""Creates a session in Redis. It returns the session_id."""
session_id = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc).isoformat()
pipe = r.pipeline()
pipe.hset(_session_key(session_id), mapping={
"user_id": user_id,
"tier": tier,
"created_at": now,
"last_seen": now,
"user_agent": user_agent,
"ip": ip,
})
pipe.expire(_session_key(session_id), settings.SESSION_TTL_SECONDS)
pipe.sadd(_user_sessions_key(user_id), session_id)
pipe.expire(_user_sessions_key(user_id), settings.SESSION_TTL_SECONDS)
await pipe.execute()
return session_id
async def get_session(r: Redis, session_id: str) -> Optional[dict]:
"""Returns the session's metadata, or None if it doesn't exist/expired."""
data = await r.hgetall(_session_key(session_id))
return data if data else None
async def touch_session(r: Redis, session_id: str) -> None:
"""Updates last_seen and extends the TTL (a sliding session)."""
now = datetime.now(timezone.utc).isoformat()
pipe = r.pipeline()
pipe.hset(_session_key(session_id), "last_seen", now)
pipe.expire(_session_key(session_id), settings.SESSION_TTL_SECONDS)
await pipe.execute()
async def revoke_session(r: Redis, session_id: str) -> bool:
"""Deletes a specific session. It returns True if it existed."""
data = await r.hgetall(_session_key(session_id))
if not data:
return False
user_id = data.get("user_id")
pipe = r.pipeline()
pipe.delete(_session_key(session_id))
if user_id:
pipe.srem(_user_sessions_key(user_id), session_id)
await pipe.execute()
return True
async def revoke_all_user_sessions(r: Redis, user_id: str) -> int:
"""Logout-all: deletes every session the user has."""
sids = await r.smembers(_user_sessions_key(user_id))
if not sids:
return 0
pipe = r.pipeline()
for sid in sids:
pipe.delete(_session_key(sid))
pipe.delete(_user_sessions_key(user_id))
await pipe.execute()
return len(sids)
async def list_user_sessions(r: Redis, user_id: str) -> list[dict]:
"""Returns the metadata of every active session the user has."""
sids = await r.smembers(_user_sessions_key(user_id))
if not sids:
return []
sessions = []
for sid in sids:
data = await r.hgetall(_session_key(sid))
if data:
sessions.append({"session_id": sid, **data})
return sessions
app/auth/dependencies.py
This is the dependency you'll use in endpoints that require auth. It verifies the JWT and that the session exists in Redis (this is what makes immediate logout possible).
# app/auth/dependencies.py
from typing import Annotated
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from redis.asyncio import Redis
from app.dependencies import get_redis
from app.auth.jwt_handler import decode_access_token
from app.auth.sessions import get_session, touch_session
security = HTTPBearer()
async def get_current_user(
request: Request,
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
r: Annotated[Redis, Depends(get_redis)],
) -> dict:
"""
Verifies the JWT + the session in Redis. It returns a dict with user_id, tier, session_id.
If the JWT is valid but the session was revoked, it returns a 401 (immediate logout).
"""
payload = decode_access_token(credentials.credentials)
user_id = payload.get("sub")
session_id = payload.get("sid")
tier = payload.get("tier", "free")
if not user_id or not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
session = await get_session(r, session_id)
if not session:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session revoked or expired",
)
# A sliding session: it extends the TTL on every authenticated request
await touch_session(r, session_id)
# So the rate limit middleware can read it (request.state)
request.state.user_id = user_id
request.state.tier = tier
request.state.session_id = session_id
return {"user_id": user_id, "tier": tier, "session_id": session_id}
app/routers/auth.py
Login, logout, logout-all, and a sessions list. In this version the "login" doesn't validate a password against a users DB — it uses a hardcoded user for simplicity (the guide is about Redis, not full auth). If you wanted a real DB, you'd swap the check for the verify_password() from your Auth guide (#9).
# app/routers/auth.py
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status, Request
from pydantic import BaseModel
from redis.asyncio import Redis
from app.dependencies import get_redis
from app.auth.jwt_handler import create_access_token
from app.auth.sessions import (
create_session,
revoke_session,
revoke_all_user_sessions,
list_user_sessions,
)
from app.auth.dependencies import get_current_user
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
user_id: str # In real life: email + password
tier: str = "free" # free, pro, enterprise
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
session_id: str
# Demo users (in production this would come from Postgres)
DEMO_USERS = {
"alice": "free",
"bob": "pro",
"carol": "enterprise",
}
@router.post("/login", response_model=LoginResponse)
async def login(
payload: LoginRequest,
request: Request,
r: Annotated[Redis, Depends(get_redis)],
):
"""A demo login. In production: verify the password against a users DB."""
if payload.user_id not in DEMO_USERS:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
tier = DEMO_USERS[payload.user_id]
user_agent = request.headers.get("user-agent", "")
ip = request.client.host if request.client else ""
session_id = await create_session(r, payload.user_id, tier, user_agent, ip)
token = create_access_token(payload.user_id, session_id, tier)
return LoginResponse(access_token=token, session_id=session_id)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
user: Annotated[dict, Depends(get_current_user)],
r: Annotated[Redis, Depends(get_redis)],
):
"""Logs out of the current session."""
await revoke_session(r, user["session_id"])
@router.post("/logout-all", status_code=status.HTTP_204_NO_CONTENT)
async def logout_all(
user: Annotated[dict, Depends(get_current_user)],
r: Annotated[Redis, Depends(get_redis)],
):
"""Logs out of every session the user has (on every device)."""
await revoke_all_user_sessions(r, user["user_id"])
@router.get("/sessions")
async def list_my_sessions(
user: Annotated[dict, Depends(get_current_user)],
r: Annotated[Redis, Depends(get_redis)],
):
"""Lists every active session the current user has."""
sessions = await list_user_sessions(r, user["user_id"])
return {"count": len(sessions), "sessions": sessions}
Updating the rate limit middleware
Replace the _get_user_and_tier() stub in app/rate_limit/middleware.py with something that reads from the JWT. Since the middleware runs before the dependencies, decode the token yourself (without verifying the session yet — get_current_user does that afterwards).
# app/rate_limit/middleware.py — only the _get_user_and_tier method
import jwt as pyjwt
from app.config import settings
async def _get_user_and_tier(self, request: Request) -> tuple[Optional[str], str]:
"""Extracts the user_id and tier from the JWT. With no token, it returns (None, 'free')."""
auth = request.headers.get("authorization", "")
if not auth.startswith("Bearer "):
return None, "free"
token = auth[7:]
try:
payload = pyjwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
return payload.get("sub"), payload.get("tier", "free")
except pyjwt.InvalidTokenError:
return None, "free"
Why the middleware doesn't verify the session: if it did, every request would hit Redis twice (the session + the rate limit). Better: the middleware only reads the tier for rate limiting; the dependencies verify the session in the endpoints that need it. An accepted trade-off.
Write-through: PUT /api/users/{id}
A reminder of the pattern (M2's capsule 03): write-through writes to the DB and the cache in the same operation. The guarantee: the cache is always consistent. The cost: slightly higher write latency, and if the cache fails, the write can degrade.
In the project, /api/users/{id} already has cache-aside on the GET (you built it in capsule 03). Now you add write-through on the PUT.
# app/routers/users.py — add the PUT
import json
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_redis, get_db
from app.auth.dependencies import get_current_user
from app.models import User, UserUpdate
from app.config import settings
@router.put("/{user_id}", response_model=User)
async def update_user(
user_id: str,
payload: UserUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
r: Annotated[Redis, Depends(get_redis)],
current: Annotated[dict, Depends(get_current_user)],
):
"""Write-through: it updates Postgres + the cache + publishes an invalidation."""
if current["user_id"] != user_id and current["tier"] != "enterprise":
raise HTTPException(status_code=403, detail="Forbidden")
# 1. Update the DB (the source of truth)
user = await db.get(UserORM, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(user, field, value)
await db.commit()
await db.refresh(user)
user_dict = User.model_validate(user).model_dump(mode="json")
# 2. Write-through: update the cache atomically
cache_key = f"user:{user_id}"
pipe = r.pipeline()
pipe.delete(cache_key)
pipe.hset(cache_key, mapping={k: json.dumps(v) for k, v in user_dict.items()})
pipe.expire(cache_key, settings.CACHE_TTL_USER)
await pipe.execute()
# 3. Publish an event to invalidate the related caches (the users list, etc.)
await r.publish("cache:invalidate", json.dumps({
"type": "user.updated",
"user_id": user_id,
"keys": [f"users:list:*"], # The pattern to invalidate in the related caches
}))
return user
Why the
DELETEbefore theHSET: if the user had an optional field with a value before (e.g.,phone) and it's nowNone, theHSETdoesn't delete it automatically. The precedingDELETEcleans it up. A small optimization: if you guarantee thatUserUpdatealways includes every field, you can drop theDELETE.
Why the publish comes after the commit: if the commit fails, you don't want to have already notified the other workers. The order: DB → cache → publish.
Write-behind: POST /analytics/event
The opposite pattern. The client gets an immediate 202 Accepted, the event is enqueued in Redis (a LIST), and a worker drains it in batches to Postgres. Maximum throughput. The risk: eventual durability (if Redis goes down before the flush, the queued events are lost).
You saw it theoretically in M2's capsule 03. Here you apply it.
The endpoint
# app/routers/analytics.py
import json
from datetime import datetime, timezone
from typing import Annotated
from fastapi import APIRouter, Depends, status, Request
from pydantic import BaseModel
from redis.asyncio import Redis
from app.dependencies import get_redis
router = APIRouter(prefix="/analytics", tags=["analytics"])
class AnalyticsEvent(BaseModel):
event_type: str # "page_view", "click", "purchase", etc.
user_id: str | None = None
properties: dict = {}
QUEUE_KEY = "analytics:queue"
@router.post("/event", status_code=status.HTTP_202_ACCEPTED)
async def track_event(
event: AnalyticsEvent,
request: Request,
r: Annotated[Redis, Depends(get_redis)],
):
"""Write-behind: it enqueues in Redis, the worker drains it later."""
payload = {
"event_type": event.event_type,
"user_id": event.user_id,
"properties": event.properties,
"ip": request.client.host if request.client else None,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
await r.rpush(QUEUE_KEY, json.dumps(payload))
return {"status": "accepted", "queue_length": await r.llen(QUEUE_KEY)}
The worker
A background task that starts in main.py's lifespan. It drains the queue in batches with an atomic LRANGE + LTRIM via a pipeline.
# app/workers/analytics_worker.py
import asyncio
import json
import logging
from typing import TYPE_CHECKING
from sqlalchemy import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import async_session_maker
from app.redis_client import get_pool
from app.config import settings
if TYPE_CHECKING:
from redis.asyncio import Redis
logger = logging.getLogger("analytics_worker")
QUEUE_KEY = "analytics:queue"
BATCH_SIZE = 100
FLUSH_INTERVAL = 5 # seconds
async def _flush_batch(r: "Redis") -> int:
"""Reads up to BATCH_SIZE events from the queue and writes them to Postgres."""
pipe = r.pipeline()
pipe.lrange(QUEUE_KEY, 0, BATCH_SIZE - 1)
pipe.ltrim(QUEUE_KEY, BATCH_SIZE, -1)
raw_events, _ = await pipe.execute()
if not raw_events:
return 0
events = [json.loads(e) for e in raw_events]
async with async_session_maker() as db:
try:
from app.db import AnalyticsEventORM
await db.execute(
insert(AnalyticsEventORM),
[{
"event_type": e["event_type"],
"user_id": e.get("user_id"),
"properties": e.get("properties", {}),
"ip": e.get("ip"),
"timestamp": e["timestamp"],
} for e in events]
)
await db.commit()
logger.info(f"Flushed {len(events)} analytics events to DB")
return len(events)
except Exception as ex:
await db.rollback()
# Re-enqueue at the end so we don't lose the data (best-effort)
for e in events:
await r.rpush(QUEUE_KEY, json.dumps(e))
logger.error(f"Flush failed, re-queued {len(events)} events: {ex}")
return 0
async def analytics_worker_loop():
"""An infinite loop: every FLUSH_INTERVAL seconds, it drains a batch."""
from redis.asyncio import Redis
pool = get_pool()
r = Redis(connection_pool=pool)
logger.info("Analytics worker started")
try:
while True:
try:
await _flush_batch(r)
except Exception as ex:
logger.exception(f"Worker iteration failed: {ex}")
await asyncio.sleep(FLUSH_INTERVAL)
except asyncio.CancelledError:
logger.info("Analytics worker stopping, flushing remaining...")
# A final flush before exiting
try:
await _flush_batch(r)
except Exception:
logger.exception("Final flush failed")
raise
Integrating it in main.py
# app/main.py — update the lifespan
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.workers.analytics_worker import analytics_worker_loop
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
init_pool(settings.REDIS_URL, max_connections=50)
# Background workers
worker_task = asyncio.create_task(analytics_worker_loop())
pubsub_task = asyncio.create_task(pubsub_listener_loop()) # see the next section
yield
# Shutdown
worker_task.cancel()
pubsub_task.cancel()
try:
await asyncio.gather(worker_task, pubsub_task, return_exceptions=True)
except Exception:
pass
await close_pool()
And if the server crashes with events in the queue? They stay in Redis. When the server restarts, the worker drains them. The real risk: if Redis goes down with the events in memory. That's why write-behind is for data where "losing a handful" is acceptable (analytics, logs). NEVER for payments or transactions.
The Pub/Sub listener
In M4 you saw how Redis Pub/Sub enables distributed invalidation. Here you integrate it into the project: a background task that listens on cache:invalidate and acts (logging + metrics + optionally deleting local caches).
# app/pubsub/listener.py
import asyncio
import json
import logging
from typing import TYPE_CHECKING
from app.redis_client import get_pool
from app.metrics import metrics
if TYPE_CHECKING:
from redis.asyncio import Redis
logger = logging.getLogger("pubsub_listener")
async def _handle_invalidation(event: dict, r: "Redis") -> None:
"""Processes a cache:invalidate event."""
event_type = event.get("type")
metrics["pubsub_events_received"] += 1
metrics["pubsub_by_type"][event_type] = metrics["pubsub_by_type"].get(event_type, 0) + 1
logger.info(f"Pub/Sub event: {event_type} → {event}")
# Act based on the type
keys_to_invalidate = event.get("keys", [])
for key_pattern in keys_to_invalidate:
if "*" in key_pattern:
# A pattern: use SCAN so we don't block
cursor = 0
count = 0
while True:
cursor, batch = await r.scan(cursor, match=key_pattern, count=100)
if batch:
await r.delete(*batch)
count += len(batch)
if cursor == 0:
break
if count:
logger.info(f"Invalidated {count} keys matching {key_pattern}")
else:
await r.delete(key_pattern)
async def pubsub_listener_loop():
"""Subscribes to cache:invalidate and processes the events. An infinite loop."""
from redis.asyncio import Redis
pool = get_pool()
r = Redis(connection_pool=pool)
pubsub = r.pubsub()
await pubsub.subscribe("cache:invalidate")
logger.info("Pub/Sub listener subscribed to cache:invalidate")
try:
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
event = json.loads(message["data"])
await _handle_invalidation(event, r)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON in pub/sub: {message['data']!r}")
except Exception:
logger.exception("Failed to handle invalidation event")
except asyncio.CancelledError:
logger.info("Pub/Sub listener stopping")
await pubsub.unsubscribe("cache:invalidate")
await pubsub.close()
raise
Why a separate listener if the routers already delete the caches directly? Because it enables horizontal scaling: if you have 3 instances of the API running, one does a
PUTand publishes → the other 2 listen and delete their local caches (if they had any). In the current project the cache is Redis (shared), so the listener is more for observability + future extension.
The WebSocket bridge /ws/notifications
You already built the complete pattern in M4's capsule 05 (Real-time Notifications). Here you integrate it into the project. You reuse the same ConnectionManager and bridge.
# app/routers/websocket.py
import asyncio
import json
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Query, HTTPException
from redis.asyncio import Redis
from app.dependencies import get_redis
from app.auth.jwt_handler import decode_access_token
from app.auth.sessions import get_session
logger = logging.getLogger("ws")
router = APIRouter()
class ConnectionManager:
def __init__(self):
self.active: dict[str, set[WebSocket]] = {} # user_id → set of WebSockets
async def connect(self, user_id: str, ws: WebSocket):
await ws.accept()
self.active.setdefault(user_id, set()).add(ws)
def disconnect(self, user_id: str, ws: WebSocket):
if user_id in self.active:
self.active[user_id].discard(ws)
if not self.active[user_id]:
del self.active[user_id]
async def send_to_user(self, user_id: str, message: dict):
if user_id not in self.active:
return
dead = []
for ws in self.active[user_id]:
try:
await ws.send_json(message)
except Exception:
dead.append(ws)
for ws in dead:
self.active[user_id].discard(ws)
manager = ConnectionManager()
@router.websocket("/ws/notifications")
async def notifications_ws(
websocket: WebSocket,
token: str = Query(...),
):
"""A WebSocket authenticated by a JWT in the query string. It receives the user's events."""
try:
payload = decode_access_token(token)
user_id = payload["sub"]
session_id = payload["sid"]
except HTTPException:
await websocket.close(code=1008, reason="Invalid token")
return
# Verify the session is still valid
from app.redis_client import get_pool
from redis.asyncio import Redis
pool = get_pool()
r = Redis(connection_pool=pool)
session = await get_session(r, session_id)
if not session:
await websocket.close(code=1008, reason="Session revoked")
return
await manager.connect(user_id, websocket)
logger.info(f"WS connected: user={user_id}")
try:
while True:
# Keep the connection alive. If you want to allow client → server messages, parse them here.
await websocket.receive_text()
except WebSocketDisconnect:
logger.info(f"WS disconnected: user={user_id}")
finally:
manager.disconnect(user_id, websocket)
The bridge: Pub/Sub → WebSocket
Extend pubsub_listener_loop so it also pushes to connected WebSocket clients:
# app/pubsub/listener.py — add at the end of _handle_invalidation
from app.routers.websocket import manager as ws_manager
async def _notify_websocket_users(event: dict) -> None:
"""If the event has a user_id, notify the user via WS if they're connected."""
user_id = event.get("user_id")
if not user_id:
return
await ws_manager.send_to_user(user_id, {
"type": event.get("type", "notification"),
"data": event,
})
# In _handle_invalidation, before the return:
async def _handle_invalidation(event: dict, r: "Redis") -> None:
# ... the existing code ...
await _notify_websocket_users(event)
Now when someone does a PUT /api/users/{id}, the Pub/Sub event reaches the listener, which invalidates the caches and notifies the user via WS if they're connected. The loop is complete.
Extended metrics: /metrics
app/metrics.py (if it doesn't exist) and the /metrics endpoint:
# app/metrics.py
from collections import defaultdict
metrics = {
"cache_hits": 0,
"cache_misses": 0,
"cache_bypass": 0,
"rate_limited": 0,
"pubsub_events_received": 0,
"pubsub_by_type": defaultdict(int),
"errors_by_type": defaultdict(int),
}
def reset_metrics():
metrics["cache_hits"] = 0
metrics["cache_misses"] = 0
metrics["cache_bypass"] = 0
metrics["rate_limited"] = 0
metrics["pubsub_events_received"] = 0
metrics["pubsub_by_type"].clear()
metrics["errors_by_type"].clear()
# app/main.py — the /metrics endpoint
from app.metrics import metrics
@app.get("/metrics", tags=["monitoring"])
async def get_metrics(r: Redis = Depends(get_redis)):
"""The system's metrics. In production you'd use Prometheus."""
info = await r.info("clients")
queue_length = await r.llen("analytics:queue")
# Active sessions: an approximation by counting keys
session_count = 0
cursor = 0
while True:
cursor, batch = await r.scan(cursor, match="session:*", count=500)
session_count += len(batch)
if cursor == 0:
break
return {
"cache": {
"hits": metrics["cache_hits"],
"misses": metrics["cache_misses"],
"bypass": metrics["cache_bypass"],
"hit_ratio": (
metrics["cache_hits"]
/ max(metrics["cache_hits"] + metrics["cache_misses"], 1)
),
},
"rate_limit": {
"blocked": metrics["rate_limited"],
},
"pubsub": {
"events_received": metrics["pubsub_events_received"],
"by_type": dict(metrics["pubsub_by_type"]),
},
"sessions": {
"active": session_count,
},
"analytics": {
"queue_length": queue_length,
},
"redis": {
"connected_clients": info.get("connected_clients", 0),
},
"errors": dict(metrics["errors_by_type"]),
}
Incrementing the counters
In each middleware/handler, increment the corresponding counter. Examples:
# CacheMiddleware
metrics["cache_hits"] += 1 # or _misses, or _bypass
# RateLimitMiddleware (when it blocks)
metrics["rate_limited"] += 1
# The listener (already integrated above)
# Any exception handler
metrics["errors_by_type"][type(exc).__name__] += 1
Partial verification
Bring up the complete API and test the end-to-end flow of auth + caching + invalidation + WebSocket.
# 1. Login
curl -s -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"user_id": "alice", "tier": "free"}' | jq
# Save the token in a variable
TOKEN=$(curl -s -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"user_id": "alice", "tier": "free"}' | jq -r .access_token)
# 2. List the sessions
curl -s http://localhost:8000/auth/sessions \
-H "Authorization: Bearer $TOKEN" | jq
# 3. PUT a user (write-through)
curl -s -X PUT http://localhost:8000/api/users/alice \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Alice Updated"}' | jq
# Verify the cache updated (the next GET should be a HIT with the new data)
curl -i http://localhost:8000/api/users/alice -H "Authorization: Bearer $TOKEN"
# 4. POST an analytics event (write-behind)
for i in {1..50}; do
curl -s -X POST http://localhost:8000/analytics/event \
-H "Content-Type: application/json" \
-d "{\"event_type\": \"click\", \"user_id\": \"alice\", \"properties\": {\"page\": \"/home\"}}" > /dev/null
done
# Check the queue length (it should go down as the worker drains it)
redis-cli LLEN analytics:queue
sleep 6
redis-cli LLEN analytics:queue # It should be 0 after the flush
# 5. Logout (the session is revoked immediately)
curl -X POST http://localhost:8000/auth/logout \
-H "Authorization: Bearer $TOKEN"
# The next request with the same token: 401
curl -i http://localhost:8000/auth/sessions \
-H "Authorization: Bearer $TOKEN"
# HTTP/1.1 401 Unauthorized
# {"detail": "Session revoked or expired"}
# 6. Metrics
curl -s http://localhost:8000/metrics | jq
# 7. WebSocket (in another terminal)
TOKEN2=$(curl -s -X POST http://localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"user_id": "bob", "tier": "pro"}' | jq -r .access_token)
websocat "ws://localhost:8000/ws/notifications?token=$TOKEN2"
# Leave it open. From another terminal: PUT /api/users/bob → bob receives a notification
The expected result:
- Login returns a token and a session_id
/auth/sessionslists the active sessions with their metadata (user-agent, ip, last_seen)- The PUT to a user updates Postgres + the cache + publishes an event → the next GET is a HIT with fresh data
- The POST to analytics queues fast (200 OK <10 ms), and the worker drains it every 5s
- Logout invalidates the session immediately → the next request is a 401 (it doesn't wait for the JWT to expire)
- The metrics report active sessions, the queue length, hits/misses, and pub/sub events
- The WebSocket receives a
user.updatedevent when a PUT is made to the connected user
Troubleshooting
1. "Session revoked or expired" immediately after login
The session was created (check it with redis-cli HGETALL session:<sid>), but the dependency is looking for a different sid. The cause: the JWT and the session use different ids. Check that create_access_token(user_id, session_id, ...) receives the real session_id returned by create_session().
2. The analytics worker doesn't drain the queue
Check the logs: Analytics worker started should show up at boot. If it doesn't, the lifespan isn't creating the task. Check that app.add_lifespan(lifespan) comes before the routers in main.py.
3. The Pub/Sub listener receives events but doesn't process them
async for message in pubsub.listen() also receives subscribe/unsubscribe events. That's why there's the if message["type"] != "message": continue. If you remove that check, it tries to parse JSON on messages that aren't data and crashes.
4. The WebSocket closes immediately
Common causes: an invalid token (1008), or the client not waiting for the handshake. Try websocat -v to see the exact codes. Also: if your reverse proxy (nginx) doesn't have proxy_set_header Upgrade $http_upgrade, the WS upgrade fails.
5. A race condition: a PUT to a user, and an immediate GET returns old data
If you see it consistently, there's an ordering bug. Verify that the cache's pipe.execute() completes before the response is returned. If you use parallel tasks, there's a risk of reading the old cache. The implementation above is sequential → correct.
6. The analytics queue grows and never drains
The worker is crashing. Check the logs. A common cause: a schema mismatch between the event's JSON and the ORM column. Fix it by logging the first event that fails and comparing it against the AnalyticsEventORM model.
7. The metrics don't update
metrics is a global dict in app/metrics.py. If you import it correctly in each module, they share state. If you redefine it in some module (from x import metrics; metrics = {...}), you create a new dict and break the sharing.
Recap
Sessions:
- The JWT holds the user_id, session_id, and tier
- The session lives in a Redis HASH + a SET per user
- The
get_current_userdependency verifies the JWT + the Redis session (immediate logout) - A sliding session: the TTL extends on every request
Write-through (PUT users):
- The DB first (the source of truth)
- The cache atomically via a pipeline (DELETE + HSET + EXPIRE)
- A publish to notify the other workers/instances
Write-behind (POST analytics):
- The client: an immediate 202 Accepted
- A queue in a Redis LIST
- An async worker drains it every 5s in batches of 100
- A best-effort re-queue if the DB fails
- A final flush on shutdown
The Pub/Sub listener:
- A background task subscribed to
cache:invalidate - It processes events: invalidates keys, records metrics, notifies WS
- Cleanly cancelable with asyncio.CancelledError
The WebSocket bridge:
- Auth with a JWT in the query string + a Redis session check
- A ConnectionManager per user_id
- The Pub/Sub listener pushes events to connected users
Monitoring:
/metrics: cache hits/misses, rate limited, active sessions, queue length, pub/sub events- Global counters in
app/metrics.py, shared via import
The critical part: the project now integrates ALL the patterns from Modules 1-4. Capsule 05 is just the professional close: Docker Compose, end-to-end verification, a README, a portfolio.
Additional resources
- PyJWT Docs — The official JWT library
- FastAPI Background Tasks vs Workers — When to use each one
- Redis Pub/Sub limitations — Fire-and-forget, no persistence
- WebSocket Authentication Patterns — Query string vs subprotocols vs cookies
- Async Workers in FastAPI — Lifespan events
- Prometheus Python Client — For real production (this
/metricsis educational) - Cache-Aside vs Write-Through vs Write-Behind — The AWS comparison docs
What's next?
In Capsule 05 you close out the entire guide: the Docker Compose stack (FastAPI + Postgres + Redis), a verify.sh script that tests everything end-to-end, tests with pytest-asyncio, a professional README for your portfolio, and a closing summary with a bridge to your next guide (PostgreSQL & SQLAlchemy #8).
The project already works locally. Capsule 05 leaves it shipping-ready: anyone clones the repo, runs docker-compose up, and everything comes up.
Let's go.