Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions changelog.d/268.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
**`@cache(vary=[...])` caches one entry per value of the listed request
headers.** A route whose response depends on `Accept-Language` or `Accept` no
longer serves the first cached variant to everyone: each listed header adds a
`name=value` component (name lower-cased, value trimmed, missing as empty) to
the key, after whatever the `key_builder` returns, and the names are added to
the `Vary` header of every GET response, 200 or 304, stored or not, without
repeating names already there and leaving `Vary: *` alone. `vary` is checked
when the decorator is applied; a bare string such as `vary="Accept"` is
rejected. `invalidate()` takes the same `vary` list, and `clear_path()` clears
every variant of a path. Routes without `vary` keep their keys. The header
values are client-controlled, so HTTP_CACHING.md shows how to normalise them
with a `key_builder` and `build_cache_key` instead.
5 changes: 4 additions & 1 deletion docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ The monitoring routes decode them again for display.
A custom `key_builder` can add components after the query string with
`build_cache_key(request, *components)`; they are encoded the same way, and
`clear_path()` still matches the path (see "Adding components to the key" in
[HTTP caching](HTTP_CACHING.md#adding-components-to-the-key)).
[HTTP caching](HTTP_CACHING.md#adding-components-to-the-key)). `@cache(vary=[...])`
appends one `name=value` component per listed request header after whatever
the key builder returns, and adds the names to the response's `Vary` header
(see [Varying on request headers](HTTP_CACHING.md#varying-on-request-headers)).

Query parameters are joined in the order the request sent them
(`str(request.query_params)`) and are **not sorted**, so `?page=1&limit=10` and
Expand Down
71 changes: 69 additions & 2 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,71 @@ default) in front of every key, so other applications can share the server;
namespace instead of this `|||`-separated format, since its keys aren't tied to
HTTP requests.

### Varying on request headers

The key includes no request header, so a route whose response depends on, say,
`Accept-Language` would serve the first cached language to everyone. List such
headers in `vary`:

```python
@app.get("/greeting")
@cache(ttl=300, vary=["Accept-Language"])
async def greeting(request: Request):
return {"text": translate("hello", request.headers.get("accept-language"))}
```

Each listed header adds a `name=value` component to the key: the name
lower-cased, the value trimmed (repeated header lines joined with `,`), and a
missing header treated as an empty one. The components are escaped like the
rest of the key and come after whatever the `key_builder` returns, so `vary`
and a custom key builder compose:
`GET|||example.com|||/greeting|||||||tenant-1|||accept-language=de` for
`key_builder` returning `build_cache_key(request, "tenant-1")`. Routes without
`vary` keep their keys.

The names are also added to the `Vary` header of every response to a GET
request on the route, on a 200 or a 304, served from the backend or not
(`private`, `no_store`, a bypassed `Authorization` request or a response that
is not stored), so a shared cache in front of the app keys on them too. A name
the response already lists (in any case) is not repeated, and a response with
`Vary: *` is left alone.

`vary` must be a list (or tuple) of header field names. A bare string such as
`vary="Accept"` is rejected when the decorator is applied, as are empty names,
`*` and anything that is not a valid field name.

> [!WARNING]
> **Every distinct header value is its own entry, and the values come from the
> client.** `Accept-Language: de`, `de-DE`, `de-DE,de;q=0.9` and every other
> spelling are separate keys, and a client can send a new one on every request
> to fill the backend. Each listed header multiplies the number of entries.
> When only a few values matter, normalise in a `key_builder` instead, and add
> the header to `Vary` yourself:
>
> ```python
> SUPPORTED = ("en", "de", "fr")
>
>
> def locale_key(request: Request) -> str:
> wanted = request.headers.get("accept-language", "")
> locale = next(
> (tag for tag in SUPPORTED if wanted.lower().startswith(tag)), "en"
> )
> return build_cache_key(request, locale)
>
>
> @app.get("/greeting")
> @cache(ttl=300, key_builder=locale_key)
> async def greeting(request: Request, response: Response):
> response.headers["Vary"] = "Accept-Language"
> return {"text": translate("hello", locale_of(request))}
> ```

`clear_path()` clears every variant of a path (see
[Adding components to the key](#adding-components-to-the-key)).
`invalidate()` takes the same `vary` list and deletes only the variant the
request it is given selects.

### Authenticated endpoints

> [!WARNING]
Expand Down Expand Up @@ -375,12 +440,14 @@ async def update_item(item_id: int, request: Request):
return {"invalidated": await invalidate(StarletteRequest(scope))}
```

`invalidate(request, key_builder=None)` returns `True` when an entry existed and
`invalidate(request, key_builder=None, vary=None)` returns `True` when an entry existed and
was removed, `False` otherwise, including when no backend is configured. An
error from the backend itself is raised to the caller (see
[When the backend fails](#when-the-backend-fails)). The request you hand it must produce the cached route's key:
same method, host, path and query string. If the cached route uses a custom
`key_builder`, pass the same one here, or the key will not match.
`key_builder` or `vary`, pass the same here, or the key will not match; with
`vary` only the variant selected by the request's own header values is
deleted, and `clear_path()` removes all of them.

## Monitoring routes

Expand Down
127 changes: 113 additions & 14 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from collections.abc import Awaitable
from collections.abc import Callable
from collections.abc import Mapping
from collections.abc import Sequence
from functools import update_wrapper
from functools import wraps
from inspect import Parameter
Expand Down Expand Up @@ -35,6 +36,7 @@
from .exceptions import BackendNotFoundError
from .exceptions import CacheXError
from .exceptions import RequestNotFoundError
from .headers import add_vary
from .proxy import BackendProxy
from .proxy import get_backend_or_fallback
from .types import CACHE_KEY_SEPARATOR
Expand Down Expand Up @@ -92,12 +94,24 @@ def per_user_key(request: Request) -> str:
rejected too), e.g. ``None`` from a missing user ID, which would
otherwise put every such caller under one ``"None"`` key.
"""
parts = [
request.method,
escape_key_component(request.headers.get("host", "unknown")),
escape_key_component(request.url.path),
str(request.query_params),
]
key = _append_key_components(
CACHE_KEY_SEPARATOR.join(
[
request.method,
escape_key_component(request.headers.get("host", "unknown")),
escape_key_component(request.url.path),
str(request.query_params),
]
),
components,
)
logger.debug("Built cache key: %s", key)
return key


def _append_key_components(key: str, components: Sequence[str | int]) -> str:
"""Append each component to ``key``, escaped, after another separator."""
parts = [key]
for component in components:
if isinstance(component, bool) or not isinstance(component, (str, int)):
msg = (
Expand All @@ -106,9 +120,54 @@ def per_user_key(request: Request) -> str:
)
raise TypeError(msg)
parts.append(escape_key_component(str(component)))
key = CACHE_KEY_SEPARATOR.join(parts)
logger.debug("Built cache key: %s", key)
return key
return CACHE_KEY_SEPARATOR.join(parts)


# RFC 9110 §5.1: a field name is a token.
_FIELD_NAME_CHARS = frozenset(
"!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
)


def _validate_vary(vary: Sequence[str] | None) -> list[str]:
"""Check ``@cache(vary=...)`` and return the names, first spelling of each.

Raises:
CacheXError: If ``vary`` is a single string instead of a sequence of
names, or a name is not a non-empty header field name, or is ``*``.
"""
if vary is None:
return []
if isinstance(vary, (str, bytes)) or not isinstance(vary, Sequence):
msg = (
"vary must be a list of header names, e.g. vary=['Accept-Language'], "
f"got {type(vary).__name__}"
)
raise CacheXError(msg)
names: dict[str, str] = {}
for name in vary:
if not isinstance(name, str) or not name or not set(name) <= _FIELD_NAME_CHARS:
msg = f"vary entries must be header field names, got {name!r}"
raise CacheXError(msg)
if name == "*":
msg = "vary cannot contain '*': the key can only vary on named headers"
raise CacheXError(msg)
names.setdefault(name.lower(), name)
return list(names.values())


def _vary_components(request: Request, names: Sequence[str]) -> list[str]:
"""The key components for ``@cache(vary=names)``: ``name=value`` each.

The name is lower-cased and the value trimmed; repeated header lines are
joined with ``,`` as RFC 9110 §5.3 allows, and a missing header gives an
empty value, the same as an empty one.
"""
return [
f"{name.lower()}="
+ ",".join(value.strip() for value in request.headers.getlist(name))
for name in names
]


def default_key_builder(request: Request) -> str:
Expand All @@ -131,6 +190,7 @@ def default_key_builder(request: Request) -> str:
async def invalidate(
request: Request,
key_builder: CacheKeyBuilder | None = None,
vary: Sequence[str] | None = None,
) -> bool:
"""Invalidate the cache entry a ``@cache``-decorated route would use.

Expand All @@ -145,12 +205,21 @@ async def invalidate(
it via ``request.app.url_path_for(...)`` for a GET route).
key_builder: Custom key builder used by the target route's ``@cache``
decorator, if any. If None, uses ``default_key_builder``.
vary: The target route's ``vary`` names, if any. Only the variant
selected by ``request``'s own values for those headers is
deleted; ``clear_path()`` clears every variant of a path.

Returns:
True if a cache entry existed and was deleted, False otherwise.

Raises:
CacheXError: If ``vary`` is not a list of header names.
"""
builder = key_builder or default_key_builder
cache_key = builder(request)
vary_names = _validate_vary(vary)
cache_key = _append_key_components(
builder(request), _vary_components(request, vary_names)
)

try:
cache_backend = BackendProxy.get()
Expand Down Expand Up @@ -591,6 +660,7 @@ def cache(
key_builder: CacheKeyBuilder | None = None,
fail_open: bool = True,
cache_authorized: bool = False,
vary: Sequence[str] | None = None,
) -> Callable[[HandlerCallable], AsyncResponseCallable]:
"""Cache decorator for FastAPI route handlers.

Expand Down Expand Up @@ -650,15 +720,25 @@ def cache(
``key_builder`` puts the verified caller's identity into the key;
with the default key builder one user's response would be served
to the next.
vary: Request header names the response depends on, e.g.
``["Accept-Language"]``. Each header's value (trimmed; empty when
missing) is appended to the key, after whatever ``key_builder``
returns, as a ``name=value`` component, so every distinct value
gets its own entry. The names are also added to the ``Vary``
header of every response to a GET request, unless it already
lists them or ``*``. Values are client-controlled: each listed
header multiplies the number of entries, so normalise them in a
``key_builder`` when only a few values matter.

Returns:
Decorator function that wraps route handlers with caching logic

Raises:
CacheXError: When the decorator is applied, if ``stale`` and
``stale_ttl`` are not given together, if ``public`` and
``private`` are both set, or if ``ttl`` is not an ``int``, is
negative or is larger than ``MAX_TTL``.
``private`` are both set, if ``ttl`` is not an ``int``, is
negative or is larger than ``MAX_TTL``, or if ``vary`` is not a
list of header field names (a single string is rejected).
"""

def decorator(func: HandlerCallable) -> AsyncResponseCallable:
Expand All @@ -683,6 +763,7 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable:
if ttl is not None and ttl > MAX_TTL:
msg = f"ttl must be at most {MAX_TTL} seconds"
raise CacheXError(msg)
vary_names = _validate_vary(vary)

# Analyze the original function's signature
sig: Signature = inspect.signature(func)
Expand Down Expand Up @@ -761,7 +842,7 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable:
bypass_backend = private or not ttl

@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Response:
async def serve(*args: Any, **kwargs: Any) -> Response:
# Resolve backend on every request to support lifespan-configured backends
cache_backend = get_backend_or_fallback()

Expand Down Expand Up @@ -850,7 +931,9 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response:

# Built only here: the branches above never touch the backend, so a
# custom key builder would run for nothing.
cache_key = builder(req)
cache_key = _append_key_components(
builder(req), _vary_components(req, vary_names)
)

try:
cached_data = await cache_backend.get(cache_key)
Expand Down Expand Up @@ -989,6 +1072,22 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response:
current_response, cache_control, private_cache_control
)

wrapper: AsyncResponseCallable = serve
if vary_names:

@wraps(func)
async def with_vary(*args: Any, **kwargs: Any) -> Response:
# Read before `serve` pops an injected request parameter.
req: Request | None = kwargs.get(request_name)
response = await serve(*args, **kwargs)
if req is not None and req.method == "GET":
# Every GET answer, served from the backend or not: a
# shared cache downstream keys on these headers too.
add_vary(response.headers, vary_names)
return response

wrapper = with_vary

# Update the wrapper with the new signature
update_wrapper(wrapper, func)
wrapper.__signature__ = sig # type: ignore[attr-defined]
Expand Down
27 changes: 27 additions & 0 deletions fastapi_cachex/headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Response header helpers shared by ``@cache`` and the session middleware."""

from starlette.datastructures import MutableHeaders


def add_vary(headers: MutableHeaders, names: list[str]) -> None:
"""Add header names to ``Vary``, keeping the values already there.

Starlette's ``add_vary_header`` appends unconditionally, so names the
response already varies on (compared case-insensitively) are skipped, as
is everything when it already varies on ``*``.

Args:
headers: Mutable response headers to write to
names: Request header names the response depends on
"""
present = {
value.strip().lower()
for line in headers.getlist("vary")
for value in line.split(",")
}
if "*" in present:
return
for name in names:
if name.lower() not in present:
headers.add_vary_header(name)
present.add(name.lower())
Loading
Loading