Skip to content

Response Cache

A catalog endpoint reads the same rows for every caller and answers the same bytes every time. Under load it runs that query thousands of times a minute, and every replica runs its own copy.

@cached is not the answer. It caches what a function returns, and what a route answers is not that: the framework still has to serialize it, and a header the handler set is not part of the return value. Cache the response.

from fastapi import FastAPI
from pydantic import BaseModel

from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.http import CachedResponses
from grelmicro.integrations.fastapi import CachedResponse
from grelmicro.providers.redis import RedisProvider

redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(uses=[Cache(redis), CachedResponses()])
app = FastAPI()

micro.install(app)


class Product(BaseModel):
    id: int
    name: str


@app.get("/products", dependencies=[CachedResponse(ttl=60)])
async def list_products() -> list[Product]:
    return await load_products()


async def load_products() -> list[Product]:
    return [Product(id=1, name="Anvil")]

Declare it on the route and the second caller is answered without reaching the handler. It rides the registered Cache, so a response one replica computed answers the callers of every other one.

CachedResponse is declared rather than called, because a hit is answered before the handler runs. It is the same shape as Conditional: the component holds the rules every route shares, and the route says it wants them.

What a hit looks like

A hit carries Age, which says how many seconds it has been in the store. That is the standard header a cache answers with, so a client, a CDN, and a proxy all read it without knowing anything about grelmicro.

Every stored response carries an ETag. When the client already holds it and sends If-None-Match, the answer is 304 Not Modified with no body at all:

sequenceDiagram
    autonumber
    participant C as Client
    participant S as Your service
    participant H as Handler

    C->>S: GET /products
    S->>H: the cache is cold
    H-->>S: 200, 40 KB
    S-->>C: 200, ETag "a1b2", Age 0

    C->>S: GET /products, If-None-Match "a1b2"
    Note over S: the entry is still fresh
    S-->>C: 304 Not Modified

The handler ran once. The second request cost a cache read and 100 bytes on the wire.

One cold key runs the handler once

The moment an entry expires, every request for it misses at the same time. A plain cache answers that by running the handler once per caller, which is the load spike the cache was there to prevent.

Here the first request runs the handler and every other one waits for it, in the process by default. CachedResponses(lock="cluster") also folds them across replicas through the lock backend of the app's Coordination, the same as @cached(lock=...). lock=None turns folding off. A lock backend that fails, or is missing, is reported and does not fail the request: the handler answers it.

A request's own Cache-Control is not read. This answers for the resource rather than for one caller, so honouring no-cache from an unauthenticated caller would let anybody spend the handler at will.

A path whose responses are never storable, a stream above all, stops taking the lock once the first one has shown that nothing is kept for it, so it is never queued behind itself. A HEAD takes it never: it reads what a GET stored and fills nothing, so folding it would hold up the read it shares a key with.

What is never stored

A response cache is dangerous in exactly one way: answering one caller with another caller's response. These rules are not configurable, because every one of them is that mistake.

Not stored Why
A request carrying Cookie it was answered for one caller
A request carrying Authorization, unless its route declares CachedResponse(shared=True) it was answered for one caller
A request whose ASGI scope carries an authenticated user or authentication scopes, unless its route declares CachedResponse(shared=True) an authentication middleware identified one caller
A response carrying Set-Cookie it is one caller's session
A response carrying Cache-Control: no-store, no-cache or private it said so
A response carrying Cache-Control: max-age=0 or s-maxage=0 it is stale already
Anything but 200 a failure is not an answer to hand on
A response carrying Content-Encoding compression sits outside this middleware
A HEAD response its body is empty, and would answer the GET after it with nothing
A request carrying Range what is stored is the whole resource, not the part asked for

A HEAD the route answers beside its GET reads what the GET stored, and is answered with the same headers and no body. A HEAD route of its own is answered by its handler.

The cache runs at the route, once its gate admitted the request, with AuthenticatedRequests registered or without it. A URL no route answers is never cached. With AuthenticatedRequests, a request the route refuses never reaches the cache, so a hit is never served to a caller without a credential on a protected route, or to a caller lacking a scope the route requires. A request carrying a credential is answered by its handler, on every route, unless the route declares CachedResponse(shared=True).

CachedResponse(shared=True) on a route that requires a caller keeps one response for every caller the route admits, a credential included. The stored key carries what the route requires, so callers of another scope never read it. CachedResponse() without it on a route that requires a caller fails install, naming the route, because such a route would never answer from the cache. shared=True on an Anonymous() route fails install too: a request carrying a credential there is answered by its handler, so nothing is shared.

Only a route declares shared=True, for itself. CachedResponse(shared=True) on a router, an include or the app fails install, naming the router's prefix, and so does a route added later under it. A plain CachedResponse() there still covers the reads under it.

Never set shared=True on a per-caller handler

A shared response is the same for every caller the route admits. A route that answers each caller differently, such as /me or one reading CurrentPrincipal, Claims or the caller's scopes, must never declare it: the next caller would be handed the first caller's response. Cache the data behind such a route with @cached, keyed by the caller.

A shared route whose handler takes CurrentPrincipal, OptionalPrincipal, Claims, CurrentToken or a parameter annotated with Authenticated() fails install, naming the route and the parameter. A handler reading the caller off the raw Request is not caught: keep shared=True off it.

The route's declaration decides, as it does for the gate and idempotency. app.dependency_overrides does not change what the cache does, so overriding CachedResponse() in a test leaves the route cached.

Only GET is cached. CachedResponse() on a route that answers anything else is refused when micro.install(app) reads it, naming the path.

On FastAPI, CachedResponse() written on a route that runs a dependency of its own fails install too, naming the path. A dependency on the route or on a router above it counts. grelmicro's Anonymous(), Authenticated(), CurrentPrincipal, OptionalPrincipal, Claims and CurrentToken do not. CachedResponse() declared on a router leaves such a read uncached.

Vary

Vary is where naive response caches leak. A response that says Vary: Accept-Language is only an answer to a client that asked for the same language, and a cache that stores it under the URL alone will hand the French page to the next caller who wanted German.

Declare what the key reads:

CachedResponses(vary_by_headers=("accept-language",))

A response whose own Vary names a header outside that set is not stored, and the refusal is logged. So a handler that starts varying on something new stops being cached rather than starting to answer the wrong callers.

What the key reads is written into the stored response's Vary, joined with whatever the handler set. The cache in front of yours has to be told too: a CDN, a corporate proxy, or the browser would otherwise hand one caller's copy to the next one who sent a different value.

Vary: * is never stored.

A response naming its own freshness is kept no longer than it says. Cache-Control: max-age=30 caps the entry at 30 seconds whatever the TTL is, and s-maxage wins over max-age where both are named, because it is the one written for a shared cache. Named twice, the smaller one decides.

Every occurrence of a header counts. A response carrying two Vary lines, or two Cache-Control lines, says all of what they say, and reading only the last of them is how the one that refused the store goes missing.

The key

By default the key is the scheme, the host, the prefix the app is served under, the path, and the whole parsed query. Distinct parameter names are put in one fixed order, so page=1&sort=name and sort=name&page=1 share an entry. Repeated values keep their request order: role=admin&role=user is not the same resource as role=user&role=admin. Percent-encoded names are compared after decoding. The key also carries every occurrence of each header named in vary_by_headers, and distinguishes an absent selected query parameter or header from one present with an empty value.

The host and the prefix are in it so an app answering for two hostnames, and two services behind one gateway sharing one store, never hand out each other's responses.

At a route behind AuthenticatedRequests, the stored key also carries what the route requires: no credential, or a caller and the scopes it names. A route turned from anonymous to protected, or given another scope, never serves what was stored before, even from a store shared across a restart. That holds for a key= of your own too.

Name the parameters that matter and the rest is ignored, so a tracking parameter does not turn one resource into a thousand:

CachedResponses(vary_by_query=("page", "size"))

key= replaces the whole thing. It takes the ASGI scope and returns the key, or None to leave that request uncached.

Keying on the caller

The scope carries the client address, and building it into the key turns a shared cache into a per-caller one: the hit rate collapses, the store grows with the number of callers, and a mistake in the key answers one of them with another one's response. A per-user result belongs in @cached, on the data rather than the response.

Naming paths instead of routes

CachedResponse() is a FastAPI dependency, on a route or on the whole router it is included with:

app.include_router(products, dependencies=[CachedResponse(ttl=60)])

APIRouter(dependencies=[CachedResponse(ttl=60)]) and FastAPI(dependencies=[CachedResponse(ttl=60)]) say the same thing, for a router and for a whole app.

A router holds more than reads, so what a cache cannot answer for is left to its handler rather than refused: a write under it is simply not cached. Declared on one route, the same thing is a mistake, and it is refused where it is written. The nearest declaration decides, so a route beats the router it sits in, an inner router beats the one that includes it, and a route that declared one is not overridden by an include pattern naming it.

The cache runs at the route, after any middleware around a mounted application or configured on that application or its router. A route under such middleware is cached only when it declares CachedResponse(): an include= pattern never caches it, because the middleware may answer each caller differently. Middleware configured on an individual Starlette route runs checks of its own, so an include rule never caches that route.

CachedResponsesMiddleware added by hand answers before routing instead. It reads no route, so a CachedResponse() declaration does nothing there, and it caches what include= names, as the request headers allow.

Added by hand, it does not see a route's own checks

A hit is answered before an authentication dependency, or before middleware on a mount that reads a header such as a tenant. Do not name such a route in include=, or register CachedResponses() and call micro.install(app), which reads them.

Starlette and Litestar resolve no dependencies to hang it on, and a router you did not write cannot be changed either, so name the URLs and how long each is kept:

from fastapi import FastAPI

from grelmicro import Grelmicro
from grelmicro.cache import Cache
from grelmicro.http import CachedResponses
from grelmicro.providers.redis import RedisProvider

redis = RedisProvider("redis://localhost:6379/0")
micro = Grelmicro(
    uses=[
        Cache(redis),
        CachedResponses(include={"/catalog": 300, "/products/*": 60}),
    ]
)
app = FastAPI()

micro.install(app)

Exact match, unless the pattern ends with *, which matches as a prefix. It is the matching every grelmicro middleware uses, the same as ConditionalRequests(include=...). The most specific pattern decides, so {"/products/*": 60, "/products/hot": 300} keeps the hot one for 300 seconds.

A TTL takes whole seconds or a timedelta, here and on CachedResponse(ttl=...). A float is refused. From a file or an environment variable, a TTL reads whole seconds (60) or an ISO 8601 duration ("PT0.5S").

A tuple says the same thing when every path is kept for the same time, and reads like every other middleware:

CachedResponses(ttl=60, include=("/products/*", "/catalog"))

exclude= carves a path out again, whatever a route or a pattern says.

Registered, a pattern naming a read that runs checks of its own leaves it uncached: a FastAPI security scheme or ordinary dependency, whether declared on the route, router, app, or include, a Litestar guard, dependency or hook, and middleware on the route. Added by hand, or on a framework grelmicro cannot read the routes of, nothing checks that for you: name paths that answer everybody the same.

Invalidating

The TTL is the floor, not the only lever. A write that changes what a read answers drops what the cache would otherwise go on serving:

@app.post("/products")
async def create(product: ProductIn) -> Product:
    created = await save(product)
    await cached_responses.purge()
    return created

purge() deletes every response that component stored, and nothing else in the cache, because each entry carries its tag. On a memory Cache it clears the replica it runs on, and every other replica serves what it holds until the TTL runs out. Put the Cache on Redis when a write has to reach every replica.

When the store is down

A cache that cannot be reached is a cache miss. A read that fails is logged and answered by the handler, and a response that cannot be written still goes out to the caller who waited for it. Adding the cache never makes a path less available than it was without it.

Where it sits

Register it before or after ConditionalRequests(). A hit carries the entity tag it was stored with, so a caller holding that tag gets 304 either way. Both are added inside whatever middleware the app itself installed, so a request still passes middleware authentication before either can answer.

A hit skips the handler and its dependencies

A hit is answered once the route admitted the request, before its handler runs. A plain Depends that reads Request or Header never runs on a hit, so a route that runs one fails install or stays uncached, as What is never stored describes.

CachedResponse() on a route behind a FastAPI security scheme, such as APIKeyHeader or HTTPBearer, remains a configuration error and is refused when micro.install(app) reads it, naming the route. Cache what answers everybody the same, and use @cached on the data behind the ones that do not.

Changing it while it runs

Every option below except cache, key, skip, lock, namespace and name is tuned from a mounted ConfigMap, so a TTL is raised under load without a redeploy:

grel:
  cached_responses:
    ttl: 60
    include:
      "/products/*": 60
      "/products/hot": 300

A pattern arriving that way is checked against the app's routes the same way micro.install(app) checks one, so a file cannot start caching a write or a read that runs checks of its own. Read Where a rule applies.

Options

Every option of CachedResponsesMiddleware is taken by CachedResponses and forwarded, so a registered component and a hand-added middleware read the same options. Only the registered one reads the routes.

Option What it does
ttl how long a response is kept when its route names none, in whole seconds or as a timedelta
include path patterns and how long each is cached for
exclude paths never cached, whatever else says
vary_by_headers request headers the key reads
vary_by_query query parameters the key reads
key builds the key itself
skip leaves one response unstored
max_body_size largest body stored, 1 MB by default
cache the TTLCache to store in
lock how far concurrent misses on one key wait for one handler call, "process" by default
namespace, name keep two sets of rules apart on one app