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/264.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
**`build_cache_key(request, *components)` builds the default cache key plus
extra components.** A custom `key_builder` that adds a user ID, tenant or
locale no longer rebuilds `method|||host|||path|||query` by hand: with no
components the helper returns exactly the default key (existing entries keep
their keys), and each `str` or `int` component is appended after the query
string, percent-encoded like the host and path so it cannot inject the
separator. `clear_path()` on the memory and Redis backends still clears such
keys by path, and without `include_params` no longer mistakes the extra
components for a query string; the monitoring routes report them, decoded, in
a new `extra_components` field. The per-user example in HTTP_CACHING.md
("Authenticated endpoints") now uses it. `default_key_builder` stays and
returns `build_cache_key(request)`.
7 changes: 6 additions & 1 deletion docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ When a request arrives, the `@cache` decorator does the following:
from fastapi_cachex.types import CACHE_KEY_SEPARATOR # "|||"
from fastapi_cachex.types import escape_key_component

# Cache key format (default_key_builder in fastapi_cachex/cache.py)
# Cache key format (build_cache_key in fastapi_cachex/cache.py)
cache_key = CACHE_KEY_SEPARATOR.join(
[
request.method,
Expand All @@ -83,6 +83,11 @@ client, and a raw `|||` in either would shift the components so that one
request's key could equal another's. The query string is URL-encoded already.
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)).

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
`?limit=10&page=1` are two separate cache entries. If you want them treated as
Expand Down
46 changes: 37 additions & 9 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,40 @@ app.add_middleware(
)
```

### Adding components to the key

A custom `key_builder` that needs one more dimension (a user ID, a tenant, a
locale) should call `build_cache_key(request, *components)` rather than
rebuilding the format by hand. With no components it returns exactly the
default key; each component is appended after the query string:

```
{method}|||{host}|||{path}|||{query_params}|||{component}|||...
```

```python
from fastapi import Request

from fastapi_cachex import build_cache_key


def per_tenant_key(request: Request) -> str:
return build_cache_key(request, request.state.tenant_id)
```

Components are `str` or `int` (an `int` is written in decimal, so `1` and `"1"`
are the same component); anything else, `None` included, raises `TypeError`, so
a missing ID cannot quietly put every such caller under one `"None"` key. Each
component is percent-encoded like the host and path, so a value containing
`|||` cannot shift the components. An empty string is still a component:
`build_cache_key(request, "")` is not the default key.

Because the path stays the third component, `clear_path()` still finds these
keys: without `include_params` it clears every entry for the path with an empty
query string whatever its extra components, and with it every entry for the
path. The monitoring routes show the extra components, decoded, in
`extra_components`. `default_key_builder(request)` is `build_cache_key(request)`.

The Redis and Memcached backends also put their own prefix (`fastapi_cachex:` by
default) in front of every key, so other applications can share the server;
`MemoryBackend` has no prefix. `CacheManager` (see
Expand Down Expand Up @@ -217,9 +251,8 @@ HTTP requests.
```python
from fastapi import Request, Response

from fastapi_cachex import build_cache_key
from fastapi_cachex import cache
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
from fastapi_cachex.types import escape_key_component


# 1. Keep it out of the shared cache entirely.
Expand All @@ -235,13 +268,8 @@ def per_user_key(request: Request) -> str:
# it has verified the caller — never read the identity straight off an
# unverified request header (see the note below).
user_id = getattr(request.state, "user_id", "anonymous")
return (
f"{request.method}{CACHE_KEY_SEPARATOR}"
f"{escape_key_component(request.headers.get('host', 'unknown'))}"
f"{CACHE_KEY_SEPARATOR}"
f"{escape_key_component(request.url.path)}{CACHE_KEY_SEPARATOR}"
f"{request.query_params}{CACHE_KEY_SEPARATOR}{user_id}"
)
# The default key plus the user ID, escaped like the host and path.
return build_cache_key(request, user_id)


@app.get("/me/dashboard")
Expand Down
2 changes: 2 additions & 0 deletions docs/api/http-caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ configured backend.

::: fastapi_cachex.cache.invalidate

::: fastapi_cachex.cache.build_cache_key

::: fastapi_cachex.cache.default_key_builder

::: fastapi_cachex.proxy.BackendProxy
Expand Down
2 changes: 2 additions & 0 deletions fastapi_cachex/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version

from .cache import build_cache_key as build_cache_key
from .cache import cache as cache
from .cache import default_key_builder as default_key_builder
from .cache import invalidate as invalidate
Expand Down Expand Up @@ -107,6 +108,7 @@ def _read_version() -> str:
"StateManagerProxy",
"__version__",
"add_routes",
"build_cache_key",
"cache",
"default_key_builder",
"get_app_cache",
Expand Down
4 changes: 3 additions & 1 deletion fastapi_cachex/backends/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ def _split_http_key(key: str) -> tuple[str, bool] | None:

Keys without separators (CacheManager/StateManager keys or custom key
builders) are not HTTP keys and are matched on their raw value instead.
Components after the query string (see ``build_cache_key``) do not count
as query params.
"""
parts = key.split(CACHE_KEY_SEPARATOR, _QUERY_INDEX)
parts = key.split(CACHE_KEY_SEPARATOR)
if len(parts) <= _PATH_INDEX:
return None
has_params = len(parts) > _QUERY_INDEX and bool(parts[_QUERY_INDEX])
Expand Down
46 changes: 37 additions & 9 deletions fastapi_cachex/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import time
import warnings
from collections.abc import Callable
from collections.abc import Iterable
from typing import TYPE_CHECKING
from typing import Any
Expand Down Expand Up @@ -32,6 +33,10 @@
_PTTL_NO_EXPIRY = -1
_PTTL_MISSING = -2

# Positions of the path and the query string among an HTTP key's components.
_PATH_INDEX = 2
_QUERY_INDEX = 3

# SCAN page size and DEL batch size; keeps individual commands small.
_BATCH_SIZE = 100

Expand Down Expand Up @@ -229,9 +234,14 @@ async def _scan_keys(self, pattern: str) -> list[str]:
if cursor == 0:
return list(keys)

async def _delete_matching(self, pattern: str) -> int:
async def _delete_matching(
self, pattern: str, keep: Callable[[str], bool] | None = None
) -> int:
"""Delete every key matching ``pattern``, one SCAN page at a time.

With ``keep``, only the matching keys it returns ``True`` for (given
the full, prefixed key) are deleted.

Each page is deleted as it arrives, so the keyspace is never held in
memory. Deleting keys SCAN already returned is safe: SCAN still returns
every key present for the whole iteration. A key SCAN repeats is
Expand All @@ -247,6 +257,8 @@ async def _delete_matching(self, pattern: str) -> int:
cursor, page = await self.client.scan(
cursor, match=pattern, count=_BATCH_SIZE
)
if keep is not None:
page = [key for key in page if keep(key)]
if page:
deleted += await self.client.delete(*page)
if cursor == 0:
Expand Down Expand Up @@ -392,17 +404,33 @@ async def clear_path(self, path: str, include_params: bool = False) -> int:
Returns:
Number of cache entries cleared
"""
# Keys are method|||host|||path|||query. Without include_params only the
# exact path is matched: default_key_builder always appends a separator
# after the path, so keys with no query params end with "|||". The
# path is a literal, not a glob: "/files/[draft]" means those brackets.
# It is stored with "|" and "%" percent-encoded, so match it that way.
suffix = "*" if include_params else ""
# Keys are method|||host|||path|||query, optionally followed by extra
# components (build_cache_key). The glob finds every key with the path
# between two separators; a glob cannot pin it to the third component
# or tell an empty query from extra components after one, so each key
# SCAN returns is checked here. Without include_params only keys with
# an empty query match. The path is a literal, not a glob:
# "/files/[draft]" means those brackets. It is stored with "|" and "%"
# percent-encoded, so match it that way.
key_path = escape_key_component(path)
pattern = (
f"{self._prefix_pattern}*{CACHE_KEY_SEPARATOR}"
f"{_escape_glob(escape_key_component(path))}{CACHE_KEY_SEPARATOR}{suffix}"
f"{_escape_glob(key_path)}{CACHE_KEY_SEPARATOR}*"
)
cleared_count = await self._delete_matching(pattern)

def matches(key: str) -> bool:
parts = key.removeprefix(self.key_prefix).split(CACHE_KEY_SEPARATOR)
return (
len(parts) > _PATH_INDEX
and parts[_PATH_INDEX] == key_path
and (
include_params
or len(parts) <= _QUERY_INDEX
or not parts[_QUERY_INDEX]
)
)

cleared_count = await self._delete_matching(pattern, matches)

# Also match direct keys (custom key formats without separators)
# e.g. key_prefix + "gitlab:template" stored directly via backend.set().
Expand Down
77 changes: 62 additions & 15 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,33 +56,78 @@
_NO_STORE = DirectiveType.NO_STORE.value


def default_key_builder(request: Request) -> str:
"""Default cache key builder function.
def build_cache_key(request: Request, *components: str | int) -> str:
"""Build the default cache key for ``request``, plus extra components.

Generates cache key in format: method|||host|||path|||query_params
With no ``components`` the key is ``method|||host|||path|||query_params``,
exactly what ``@cache`` uses by default. Each extra component is appended
after another separator, so a custom ``key_builder`` can add a dimension
(user ID, tenant, locale) without rebuilding the default key by hand::

def per_user_key(request: Request) -> str:
return build_cache_key(request, request.state.user_id)

``|`` and ``%`` in the host and path are percent-encoded (see
``escape_key_component``), so a ``Host`` header or path containing
``|||`` cannot make one request's key equal another's. The query string
is already URL-encoded and never contains ``|``.
``|`` and ``%`` in the host, the path and every extra component are
percent-encoded (see ``escape_key_component``), so none of them can
contain the separator and make one request's key equal another's. The
query string is already URL-encoded and never contains ``|``.

Keys built this way keep the path in the third component, so
``clear_path()`` still finds them and the monitoring routes still show
their method, host, path and query.

Args:
request: The FastAPI Request object
*components: Extra key components, appended in order. A ``str`` is
used as is and an ``int`` is written in decimal, so ``1`` and
``"1"`` give the same key. An empty string is a component of its
own: ``build_cache_key(request, "")`` differs from
``build_cache_key(request)``.

Returns:
Generated cache key string

Raises:
TypeError: If a component is not a ``str`` or ``int`` (``bool`` is
rejected too), e.g. ``None`` from a missing user ID, which would
otherwise put every such caller under one ``"None"`` key.
"""
key = (
f"{request.method}{CACHE_KEY_SEPARATOR}"
f"{escape_key_component(request.headers.get('host', 'unknown'))}"
f"{CACHE_KEY_SEPARATOR}"
f"{escape_key_component(request.url.path)}{CACHE_KEY_SEPARATOR}"
f"{request.query_params}"
)
parts = [
request.method,
escape_key_component(request.headers.get("host", "unknown")),
escape_key_component(request.url.path),
str(request.query_params),
]
for component in components:
if isinstance(component, bool) or not isinstance(component, (str, int)):
msg = (
"build_cache_key components must be str or int, "
f"got {type(component).__name__}"
)
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


def default_key_builder(request: Request) -> str:
"""Default cache key builder function: ``build_cache_key(request)``.

Generates cache key in format: method|||host|||path|||query_params

Kept as the name ``@cache`` and ``invalidate()`` fall back to. To add
components to the default key, call ``build_cache_key`` instead.

Args:
request: The FastAPI Request object

Returns:
Generated cache key string
"""
return build_cache_key(request)


async def invalidate(
request: Request,
key_builder: CacheKeyBuilder | None = None,
Expand Down Expand Up @@ -588,7 +633,9 @@ def cache(
immutable: Send ``immutable``.
must_revalidate: Send ``must-revalidate``.
key_builder: Custom function to build cache keys. If None, uses
``default_key_builder``.
``default_key_builder``. To add a component (user ID, tenant,
locale) to the default key, return
``build_cache_key(request, component)``.
fail_open: When the backend raises, log a warning and answer without
the cache: a failed read counts as a miss and a failed write
leaves the response unstored. ``False`` lets the error propagate,
Expand Down
Loading
Loading