Skip to main content

Production API with HTTP Caching

You have a FastAPI endpoint serving product catalog data. It hits a database, does some computation, and returns JSON. Traffic is growing and you need to reduce database load and response times.

This recipe combines every layer inhouse offers: in-process caching, HTTP Cache-Control for CDN offload, ETag/304 for bandwidth savings, parameter exclusion for clean keys, and surgical invalidation when data changes.

The catalog endpoint​

from fastapi import FastAPI, Request
from inhouse import MemoryStore
from inhouse.fastapi import create_lifespan, fastapi_cache

store = MemoryStore(max_size=2048, default_ttl=300)
app = FastAPI(lifespan=create_lifespan(store))

@app.get("/catalog/{category}")
@fastapi_cache(
300,
store=store,
http_cache=True,
etag=True,
cache_visibility="public",
)
async def get_catalog(category: str, sort: str = "name") -> list[dict]:
rows = await db.fetch_all(
"SELECT * FROM products WHERE category = $1 ORDER BY $2",
category, sort,
)
return [dict(r) for r in rows]

Three layers active:

  • In-process cache: same (category, sort) combo returns instantly after the first hit
  • Cache-Control: public, max-age=300: CDN and browsers won't even send the request for 5 minutes
  • ETag: if a conditional request arrives (after expiry), a 304 Not Modified skips the response body

Drop unnecessary parameters from the cache key​

Your endpoint signature grows a request parameter for analytics. Without exclusion, every request gets a different cache key.

@app.get("/catalog/{category}")
@fastapi_cache(
300,
store=store,
http_cache=True,
etag=True,
cache_visibility="public",
exclude=("request",),
)
async def get_catalog(
request: Request, # for analytics - not part of cache key
category: str,
sort: str = "name",
) -> list[dict]:
track_request(request)
rows = await db.fetch_all(...)
return [dict(r) for r in rows]

request is excluded from the key. get_catalog("electronics", sort="price") produces the same key regardless of which user or device made the request.

Surgical invalidation after data changes​

An admin panel updates product prices. The catalog cache needs to refresh for the affected category only.

from pydantic import BaseModel

class PriceUpdate(BaseModel):
product_id: int
category: str
new_price: float

@app.post("/catalog/{category}/update-price")
async def update_price(update: PriceUpdate):
await db.execute(
"UPDATE products SET price = $1 WHERE id = $2",
update.new_price, update.product_id,
)
# Evict only this category's cached catalog
get_catalog.cache_invalidate(
request=None, # excluded anyway, but required by signature
category=update.category,
sort="name",
)
return {"ok": True}

The eviction is per-category. If an admin updates a product in "electronics", only get_catalog("electronics", "name") is evicted. "home-garden" is untouched.

Use keyword arguments matching the route signature. cache_invalidate builds the key the same way the decorator does.

Monitoring cache performance​

@app.get("/admin/cache-stats")
async def cache_stats():
s = store.stats()
total = s["hits"] + s["misses"]
hit_ratio = s["hits"] / total if total else 0.0
return {
"hit_ratio": round(hit_ratio, 3),
"entries": s["size"],
"evictions": s["evictions"],
"sets": s["sets"],
}

Check this endpoint after deployment to confirm the cache is working. High evictions mean max_size is too low. Low hit ratio means TTL is too short or keys are too specific.

Handling cache independence​

In-process TTL and HTTP Cache-Control are independent. When a price update triggers cache_invalidate, the in-process entry is deleted. But a CDN or browser that received Cache-Control: public, max-age=300 still has a stale copy until that 300 seconds passes.

@app.post("/catalog/{category}/update-price")
async def update_price(update: PriceUpdate):
await db.execute(...)

# Evict in-process cache
removed = get_catalog.cache_invalidate(
request=None,
category=update.category,
sort="name",
)

# Tell CDN the resource changed (if you control the CDN API)
await purge_cdn_cache(f"/catalog/{update.category}")

return {"ok": True, "cache_evicted": removed}

For full control, use a short max-age (60-120 seconds) so CDN copies expire quickly, and rely on in-process caching for the fast path within that window.

Putting it all together​

from fastapi import FastAPI, Request
from inhouse import MemoryStore
from inhouse.fastapi import create_lifespan, fastapi_cache

store = MemoryStore(max_size=4096, default_ttl=120)
app = FastAPI(lifespan=create_lifespan(store))

@app.get("/api/v2/products/{product_id}")
@fastapi_cache(
120,
store=store,
http_cache=True,
etag=True,
cache_visibility="public",
exclude=("request",),
)
async def get_product(request: Request, product_id: int) -> dict | None:
row = await db.fetch_row(
"SELECT * FROM products WHERE id = $1", product_id
)
return dict(row) if row else None

@app.get("/api/v2/products")
@fastapi_cache(
60,
store=store,
http_cache=True,
etag=True,
cache_visibility="public",
exclude=("request",),
)
async def list_products(
request: Request,
category: str | None = None,
sort: str = "name",
page: int = 1,
) -> list[dict]:
...

@app.post("/api/v2/products/{product_id}")
async def update_product(product_id: int, data: ProductUpdate):
await db.execute("UPDATE products SET ... WHERE id = $1", product_id)
get_product.cache_invalidate(request=None, product_id=product_id)
list_products.cache_clear() # listing may have changed
return {"ok": True}

Product detail pages are cached for 2 minutes with CDN offload and ETag support. Listings are cached for 1 minute. Writes evict the affected product's detail entry and clear the listing cache. Stats are available from the store at any time.