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
9 changes: 9 additions & 0 deletions changelog.d/267.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
**Add `@cache(sort_query=True)` so reordered query strings share one entry.**
The default key builder then orders the query parameters by name, so
`?a=1&b=2` and `?b=2&a=1` hit the same entry. The sort is stable: repeated
values of one name keep the order the client sent, so `?tag=b&tag=a` and
`?tag=a&tag=b` stay distinct, and names and values are encoded exactly as in
the unsorted key. The default `False` leaves every existing key unchanged.
Combining it with a custom `key_builder` raises `CacheXError`; such a builder
can call `build_cache_key(request, sort_query=True)` instead.
`invalidate()` takes the same `sort_query` keyword.
39 changes: 33 additions & 6 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,33 @@ This ensures that:
- Different query parameters get separate cache entries
- The same endpoint with different parameters can be cached independently

Query parameters are taken in the order the client sent them, without sorting, so
`?a=1&b=2` and `?b=2&a=1` are two distinct cache entries for the same logical request.
By default query parameters are taken in the order the client sent them, so
`?a=1&b=2` and `?b=2&a=1` are two distinct cache entries for the same logical
request. Set `sort_query=True` to share one entry between them:

```python
@app.get("/search")
@cache(ttl=60, sort_query=True)
async def search(q: str, limit: int = 10):
return await run_search(q, limit)
```

The parameters are then ordered by name before the key is built. The sort is
stable: repeated values of one name keep the order the client sent, because a
handler reading `tag: list[str]` sees them in that order, so `?tag=b&tag=a` and
`?tag=a&tag=b` stay two entries. Names are compared decoded (`%61` sorts as
`a`, which is how the key already writes it), and each name and value is encoded
exactly as in the unsorted key, so only the order changes: a query already in
order gets the same key with or without the flag. What the key already treats
as equal stays equal (`?a` and `?a=`, an empty segment from `&&`), and nothing
else is merged. The default is `False`, which leaves every existing key
unchanged.

`sort_query` applies to the default key builder only. Combined with a custom
`key_builder` it raises `CacheXError` when the decorator is applied; call
`build_cache_key(request, ..., sort_query=True)` inside the builder instead.
Pass `sort_query=True` to `invalidate()` for such a route as well (see
[Invalidating a single cached route](#invalidating-a-single-cached-route)).

The host and path come from the client, so `|` and `%` in them are percent-encoded
(`%7C` and `%25`). A `Host` header or path containing `|||` therefore cannot shift
Expand Down Expand Up @@ -635,14 +660,16 @@ async def update_item(item_id: int, request: Request):
return {"invalidated": await invalidate(StarletteRequest(scope))}
```

`invalidate(request, key_builder=None, vary=None)` returns `True` when an entry existed and
`invalidate(request, key_builder=None, vary=None, *, sort_query=False)` 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` 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.
`key_builder`, `vary` or `sort_query`, pass the same here, or the key will not
match: `invalidate()` cannot read them from the route. With `vary` only the
variant selected by the request's own header values is deleted, and
`clear_path()` removes all of them. With `sort_query=True` the request's query
is sorted the same way, so `?b=2&a=1` deletes the entry stored for `?a=1&b=2`.

## Monitoring routes

Expand Down
99 changes: 87 additions & 12 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from functools import wraps
from inspect import Parameter
from inspect import Signature
from operator import itemgetter
from typing import TYPE_CHECKING
from typing import Annotated
from typing import Any
Expand All @@ -22,6 +23,7 @@
from typing import get_args
from typing import get_origin
from typing import get_type_hints
from urllib.parse import urlencode

from fastapi import Request
from fastapi import Response
Expand Down Expand Up @@ -63,7 +65,24 @@
_NO_STORE = DirectiveType.NO_STORE.value


def build_cache_key(request: Request, *components: str | int) -> str:
def _query_component(request: Request, sort_query: bool) -> str:
"""The query string as it appears in the key.

Starlette parses the query (blank values kept, empty ``&&`` segments
dropped, names and values percent-decoded) and ``str()`` re-encodes the
pairs in the order sent. ``sort_query`` stable-sorts the same decoded
pairs by name before encoding them the same way, so only the order of
differently named parameters changes: repeated values of one name keep
their relative order, and an already sorted query gives the unsorted key.
"""
if not sort_query:
return str(request.query_params)
return urlencode(sorted(request.query_params.multi_items(), key=itemgetter(0)))


def build_cache_key(
request: Request, *components: str | int, sort_query: bool = False
) -> str:
"""Build the default cache key for ``request``, plus extra components.

With no ``components`` the key is ``method|||host|||path|||query_params``,
Expand All @@ -90,6 +109,11 @@ def per_user_key(request: Request) -> str:
``"1"`` give the same key. An empty string is a component of its
own: ``build_cache_key(request, "")`` differs from
``build_cache_key(request)``.
sort_query: Order the query parameters by name (a stable sort, so
``?tag=b&tag=a`` stays distinct from ``?tag=a&tag=b``), so that
``?a=1&b=2`` and ``?b=2&a=1`` give the same key. This is what
``@cache(sort_query=True)`` uses; a custom ``key_builder`` passes
it here instead.

Returns:
Generated cache key string
Expand All @@ -105,7 +129,7 @@ def per_user_key(request: Request) -> str:
request.method,
escape_key_component(request.headers.get("host", "unknown")),
escape_key_component(request.url.path),
str(request.query_params),
_query_component(request, sort_query),
]
),
components,
Expand Down Expand Up @@ -332,6 +356,39 @@ def default_key_builder(request: Request) -> str:
return build_cache_key(request)


def _sorted_query_key_builder(request: Request) -> str:
"""The key builder of ``@cache(sort_query=True)``."""
return build_cache_key(request, sort_query=True)


_SORT_QUERY_WITH_KEY_BUILDER_MSG = (
"sort_query only applies to the default key builder; a custom key_builder "
"builds its own key, so return build_cache_key(request, sort_query=True) "
"from it instead"
)


def _resolve_key_builder(
key_builder: CacheKeyBuilder | None, sort_query: object
) -> CacheKeyBuilder:
"""Pick the key builder for ``key_builder`` and ``sort_query``.

Raises:
CacheXError: If ``sort_query`` is not a ``bool``, if it is combined
with a custom ``key_builder`` (the flag would silently do
nothing), or if ``key_builder`` is an ``async`` callable.
"""
if not isinstance(sort_query, bool):
msg = f"sort_query must be a bool, got {type(sort_query).__name__}"
raise CacheXError(msg)
if key_builder is not None:
if sort_query:
raise CacheXError(_SORT_QUERY_WITH_KEY_BUILDER_MSG)
_validate_key_builder(key_builder)
return key_builder
return _sorted_query_key_builder if sort_query else default_key_builder


_ASYNC_KEY_BUILDER_MSG = (
"key_builder must be a sync function returning str; async key builders "
"are not supported (the key is built without awaiting)"
Expand Down Expand Up @@ -379,6 +436,8 @@ async def invalidate(
request: Request,
key_builder: CacheKeyBuilder | None = None,
vary: Sequence[str] | None = None,
*,
sort_query: bool = False,
) -> bool:
"""Invalidate the cache entry a ``@cache``-decorated route would use.

Expand All @@ -400,18 +459,23 @@ async def invalidate(
Credential headers (``Authorization``, ``Cookie``, ...) are
hashed exactly as ``@cache`` hashes them, so pass a request
carrying the same header value.
sort_query: The target route's ``sort_query``. With ``True`` the
query is sorted as ``@cache(sort_query=True)`` sorts it, so
``?b=2&a=1`` deletes the entry stored for ``?a=1&b=2``. It is not
read from the route: pass the same value the route uses, or the
key will not match.

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

Raises:
CacheXError: If ``vary`` is not a list of header names, if
``key_builder`` is an ``async`` callable, or if it returns
something other than a ``str``. Raised before the backend is
touched.
``sort_query`` is not a ``bool`` or is combined with
``key_builder``, if ``key_builder`` is an ``async`` callable, or
if it returns something other than a ``str``. Raised before the
backend is touched.
"""
builder = key_builder or default_key_builder
_validate_key_builder(builder)
builder = _resolve_key_builder(key_builder, sort_query)
vary_names = _validate_vary(vary)
cache_key = _append_key_components(
_build_key(builder, request), _vary_components(request, vary_names)
Expand Down Expand Up @@ -857,6 +921,7 @@ def cache(
fail_open: bool = True,
cache_authorized: bool = False,
vary: Sequence[str] | None = None,
sort_query: bool = False,
) -> Callable[[HandlerCallable], AsyncResponseCallable]:
"""Cache decorator for FastAPI route handlers.

Expand Down Expand Up @@ -947,6 +1012,16 @@ def cache(
A request with ``Authorization`` or a session still bypasses the
backend unless ``public`` or ``cache_authorized`` is set, and a response
that sets a cookie is still not stored.
sort_query: Order the query parameters by name before building the
key, so ``?a=1&b=2`` and ``?b=2&a=1`` share one entry. The sort is
stable: repeated values of one name keep the order the client
sent, so ``?tag=b&tag=a`` and ``?tag=a&tag=b`` stay distinct, and
the names and values are encoded exactly as in the unsorted key.
Off by default, which keeps every existing key unchanged. Only
the default key builder sorts: combined with a custom
``key_builder`` it is rejected; call
``build_cache_key(request, sort_query=True)`` in the builder
instead. Pass the same value to ``invalidate()``.

Returns:
Decorator function that wraps route handlers with caching logic
Expand All @@ -956,8 +1031,10 @@ def cache(
``stale_ttl`` are not given together, if ``public`` and
``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), or if
``key_builder`` is an ``async`` callable. At request time, if
list of header field names (a single string is rejected), if
``sort_query`` is not a ``bool`` or is combined with
``key_builder``, or if ``key_builder`` is an ``async`` callable.
At request time, if
``key_builder`` returns anything but a ``str``.
"""

Expand All @@ -984,8 +1061,7 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable:
msg = f"ttl must be at most {MAX_TTL} seconds"
raise CacheXError(msg)
vary_names = _validate_vary(vary)
if key_builder is not None:
_validate_key_builder(key_builder)
builder = _resolve_key_builder(key_builder, sort_query)
if any(name.lower() == "cookie" for name in vary_names):
# stacklevel=2: the caller applying the decorator, i.e. the line
# of the user's @cache(...).
Expand Down Expand Up @@ -1059,7 +1135,6 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable:
must_revalidate=must_revalidate,
)
)
builder = key_builder or default_key_builder
# Without a positive ttl nothing may be served from storage, and a 304
# answered from a stored ETag would be exactly that: it would keep
# confirming a copy that the handler no longer produces (#110). Such
Expand Down
15 changes: 13 additions & 2 deletions i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,18 @@ async def report():
- 不同的查詢參數各有獨立的快取項目
- 同一個端點搭配不同參數時可以各自快取

查詢參數依用戶端送出的順序取用,不會排序,因此 `?a=1&b=2` 與 `?b=2&a=1` 對同一個邏輯上的請求而言是兩筆不同的快取項目。
預設情況下,查詢參數依用戶端送出的順序取用,因此 `?a=1&b=2` 與 `?b=2&a=1` 對同一個邏輯上的請求而言是兩筆不同的快取項目。設定 `sort_query=True` 可讓它們共用同一筆項目:

```python
@app.get("/search")
@cache(ttl=60, sort_query=True)
async def search(q: str, limit: int = 10):
return await run_search(q, limit)
```

此時會先依名稱排序參數,再建立快取鍵。排序是穩定的:同名參數的多個值保留用戶端送出的順序,因為以 `tag: list[str]` 讀取的處理函式看到的正是這個順序,所以 `?tag=b&tag=a` 與 `?tag=a&tag=b` 仍是兩筆項目。名稱以解碼後的值比較(`%61` 視為 `a` 排序,快取鍵本來就這樣寫它),每個名稱與值的編碼都與未排序的快取鍵完全相同,只有順序改變:已經依序排列的查詢,不論是否開啟此選項都得到相同的鍵。快取鍵原本就視為相同的仍然相同(`?a` 與 `?a=`、`&&` 產生的空段),其餘一律不會合併。預設為 `False`,現有的快取鍵都不會改變。

`sort_query` 只套用於預設的 key builder。與自訂的 `key_builder` 一起使用時,套用裝飾器就會拋出 `CacheXError`;請改在 builder 中呼叫 `build_cache_key(request, ..., sort_query=True)`。對這樣的路由呼叫 `invalidate()` 時也要傳入 `sort_query=True`(見[使單一快取路由失效](#invalidating-a-single-cached-route))。

host 與路徑來自用戶端,因此其中的 `|` 與 `%` 會以百分比編碼寫入(`%7C` 與 `%25`)。含有 `|||` 的 `Host` 標頭或路徑因此無法讓各段錯位,使某個請求的快取鍵與另一個請求相同。查詢字串本來就經過 URL 編碼。`clear_path()` 接受應用程式看到的路徑(`request.url.path`),並以同樣方式編碼;`clear_pattern()` 比對的是儲存的快取鍵,所以在模式中要把 `|` 寫成 `%7C`。0.3.8 之前兩者都照原樣儲存,因此升級後,host 或路徑含有 `|` 或 `%` 的項目會重新快取一次。

Expand Down Expand Up @@ -407,7 +418,7 @@ async def update_item(item_id: int, request: Request):
return {"invalidated": await invalidate(StarletteRequest(scope))}
```

`invalidate(request, key_builder=None, vary=None)` 在項目存在且已移除時回傳 `True`,否則回傳 `False`,包括尚未設定後端的情況。後端本身的錯誤則會拋給呼叫端(見[後端發生錯誤時](#when-the-backend-fails))。傳入的請求必須能產生快取路由的鍵:相同的方法、主機、路徑與查詢字串。如果快取路由使用自訂的 `key_builder` 或 `vary`,這裡也要傳入相同的值,否則鍵不會相符;使用 `vary` 時,只會刪除請求本身的標頭值所選中的變體,`clear_path()` 則會移除所有變體。
`invalidate(request, key_builder=None, vary=None, *, sort_query=False)` 在項目存在且已移除時回傳 `True`,否則回傳 `False`,包括尚未設定後端的情況。後端本身的錯誤則會拋給呼叫端(見[後端發生錯誤時](#when-the-backend-fails))。傳入的請求必須能產生快取路由的鍵:相同的方法、主機、路徑與查詢字串。如果快取路由使用自訂的 `key_builder`、`vary` 或 `sort_query`,這裡也要傳入相同的值,否則鍵不會相符:`invalidate()` 無法從路由讀取這些設定。使用 `vary` 時,只會刪除請求本身的標頭值所選中的變體,`clear_path()` 則會移除所有變體。使用 `sort_query=True` 時,請求的查詢會以相同方式排序,因此 `?b=2&a=1` 會刪除為 `?a=1&b=2` 儲存的項目。

## 監控路由 {#monitoring-routes}

Expand Down
Loading
Loading