Caching is a performance win that quietly changes correctness. The database can be right, the code can be right, and users still see wrong balances, ghost inventory, or another user’s cart — because a cache lied. Understanding failure modes is how you keep the speed without shipping “works on my machine” consistency bugs.
A cache is an optimized rumor. Most of the time the rumor matches reality. Under retries, partial invalidation, bad keys, or expiry stampedes, the rumor diverges — and your API will confidently return it. Systems go “wrong” not because Redis is evil, but because you treated a hint as truth.
Main failure modes.
cart instead of cart:{user_id}; cross-user data leak.null or an error payload into Redis with a long TTL; every hit is broken until purge.Mitigations that actually work.
Dangerous vs safer patterns in FastAPI + Redis: keying, single-flight lock, and refusing to cache balances for checkout.
import json
import time
import redis
from fastapi import FastAPI, HTTPException
app = FastAPI()
r = redis.Redis(decode_responses=True)
def cache_key(user_id: int, name: str) -> str:
# Always include user (and tenant if multi-tenant)
return f"u:{user_id}:{name}"
# --- Wrong: shared key → data leak ---
# r.set("cart", json.dumps(cart))
# --- Right: per-user key ---
def save_cart(user_id: int, cart: dict) -> None:
r.setex(cache_key(user_id, "cart"), 600, json.dumps(cart))
def get_product_singleflight(product_id: int, load_fn):
"""Coalesce stampedes: one loader, others wait briefly."""
key = f"product:{product_id}"
if cached := r.get(key):
return json.loads(cached)
lock = f"lock:product:{product_id}"
got_lock = r.set(lock, "1", nx=True, ex=5)
if got_lock:
try:
data = load_fn(product_id)
r.setex(key, 60 + (product_id % 15), json.dumps(data)) # TTL jitter
return data
finally:
r.delete(lock)
# Someone else is loading — brief wait then re-read
time.sleep(0.05)
if cached := r.get(key):
return json.loads(cached)
return load_fn(product_id) # fallback
@app.get("/checkout/balance/{user_id}")
async def checkout_balance(user_id: int):
# Critical path: hit source of truth, skip cache
row = await app.state.pool.fetchrow(
"SELECT balance_cents FROM accounts WHERE user_id = $1 FOR SHARE",
user_id,
)
if not row:
raise HTTPException(404)
return {"balance_cents": row["balance_cents"], "source": "db"}
Caches trade freshness for speed — design keys, invalidation, and critical paths so speed never silently becomes wrong answers.