From 6779c9e9cfa9989bb821a8ebb3e38d30c464b412 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 17:16:51 +0000 Subject: [PATCH] feat(cache)!: sort query parameters in HTTP cache keys by default sort_query now defaults to sorting in @cache, invalidate(), build_cache_key() and CacheKey.from_request(), so ?b=2&a=1 and ?a=1&b=2 share one entry. The sort is stable: repeated values of one name keep the order sent. sort_query=False keeps the query as sent. @cache and invalidate() take sort_query=None to mean "not passed". Any explicit value together with a custom key_builder raises CacheXError, False included: build_cache_key() in the builder sorts by default, so the flag would silently not do what it says. BREAKING CHANGE: query parameters are sorted in HTTP cache keys by default. A handler that depends on the query order as sent must set @cache(sort_query=False) and pass the same to invalidate(). sort_query passed with a custom key_builder is rejected, False included. Closes #72 --- changelog.d/72.changed.md | 10 ++++ docs/CACHE_FLOW.md | 19 ++++---- docs/HTTP_CACHING.md | 61 ++++++++++++------------ docs/MIGRATING_0_4.md | 8 ++-- fastapi_cachex/cache.py | 63 +++++++++++++------------ fastapi_cachex/cache_key.py | 6 +-- i18n/zh-TW/docs/CACHE_FLOW.md | 8 ++-- i18n/zh-TW/docs/HTTP_CACHING.md | 18 +++---- i18n/zh-TW/docs/MIGRATING_0_4.md | 8 ++-- tests/test_build_cache_key.py | 3 +- tests/test_cache_key_type.py | 6 +-- tests/test_cache_sort_query.py | 80 ++++++++++++++++++++++---------- tests/test_custom_cache_key.py | 44 +++++++++--------- tests/test_query_hash.py | 14 +++--- 14 files changed, 200 insertions(+), 148 deletions(-) create mode 100644 changelog.d/72.changed.md diff --git a/changelog.d/72.changed.md b/changelog.d/72.changed.md new file mode 100644 index 0000000..d31f98c --- /dev/null +++ b/changelog.d/72.changed.md @@ -0,0 +1,10 @@ +**Query parameters are sorted by name in HTTP cache keys by default.** +`sort_query` defaults to on in `@cache`, `build_cache_key()`, +`CacheKey.from_request()` and `invalidate()`, so `?b=2&a=1` and `?a=1&b=2` +share one entry; repeated values of one name keep the order sent. A handler +whose response depends on the query order as sent should set +`@cache(sort_query=False)` and pass the same to `invalidate()`. Passing +`sort_query` together with a custom `key_builder` now raises `CacheXError`, +`False` included; call `build_cache_key(request, ..., sort_query=False)` in the +builder instead. See the +[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#cache-keys). diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index e6fd7d8..4ca3c2d 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -79,7 +79,7 @@ cache_key = "|".join( ) # For example: -# http:v2|GET|example.com|/api/users|page=1&limit=10 +# http:v2|GET|example.com|/api/users|limit=10&page=1 (for ?page=1&limit=10) # http:v2|GET|api.example.com|/api/users/123| ``` @@ -110,11 +110,10 @@ For the credential headers `Authorization`, `Proxy-Authorization`, `Cookie` and `X-Session-Token` a non-empty value is written as `sha256:`, so no token appears in the key. -Query parameters are joined in the order the request sent them -(`str(request.query_params)`) and are **not sorted** by default, so -`?page=1&limit=10` and `?limit=10&page=1` are two separate cache entries. To -treat them as one, set `@cache(sort_query=True)`, which orders the parameters -by name first (see [Cache keys](HTTP_CACHING.md#cache-keys)). A query longer +Query parameters are **sorted** by name (a stable sort: repeated values of one +name keep the order sent), so `?page=1&limit=10` and `?limit=10&page=1` share +one cache entry. `@cache(sort_query=False)` keeps the order the request sent +them instead (see [Cache keys](HTTP_CACHING.md#cache-keys)). A query longer than 200 bytes is then replaced by `sha256:`, so the query part of the key stays bounded. @@ -155,7 +154,7 @@ Arguments are validated when the decorator is applied, and a `CacheXError` is raised if `public` and `private` are both set, if only one of `stale` / `stale_ttl` is given, if `ttl` is not an `int`, is negative or is larger than `MAX_TTL`, if `vary` is not a list of header field names, if `sort_query` is -not a `bool` or is combined with a custom `key_builder`, or if `key_builder` is +not a `bool` or is passed with a custom `key_builder`, or if `key_builder` is an `async` callable. The header value is built once per decorated route: @@ -555,9 +554,9 @@ without reading or writing the cache and without adding a `Cache-Control` header. **Q: Why are there several cache entries for the same endpoint?** -A: Because the cache key includes the query parameters, and by default they are -**not sorted**. `/users?page=1` and `/users?page=2` are different entries, and -so are `?a=1&b=2` and `?b=2&a=1` unless the route sets `sort_query=True`. +A: Because the cache key includes the query parameters. `/users?page=1` and +`/users?page=2` are different entries, and so are `?a=1&b=2` and `?b=2&a=1` if +the route sets `sort_query=False`. **Q: How does MemoryBackend work across multiple processes?** A: It doesn't. Each process has its own cache; use Redis in production. diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index dd0692b..3b74320 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -300,43 +300,45 @@ This ensures that: - Different query parameters get separate cache entries - The same endpoint with different parameters can be cached independently -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: +Query parameters are ordered by name before the key is built, 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, 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 sorted or not. What the key already treats +as equal stays equal (`?a` and `?a=`, an empty segment from `&&`), and nothing +else is merged. Sorting is the default since 0.4.0 +([#72](https://github.com/allen0099/FastAPI-CacheX/issues/72), see +[Migrating to 0.4.0](MIGRATING_0_4.md#cache-keys)). + +A handler whose response depends on the query order as sent, such as a self or +pagination link copied from `request.url`, should turn it off, or the first +caller's order is cached and served to callers who sent another: ```python @app.get("/search") -@cache(ttl=60, sort_query=True) -async def search(q: str, limit: int = 10): - return await run_search(q, limit) +@cache(ttl=60, sort_query=False) +async def search(request: Request, q: str, limit: int = 10): + return {"self": str(request.url), "items": 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. 0.4.0 makes `True` the default, together with its other cache key -changes ([#72](https://github.com/allen0099/FastAPI-CacheX/issues/72), see -[Migrating to 0.4.0](MIGRATING_0_4.md#cache-keys)). A handler that depends on the -query order as sent can keep `sort_query=False`. - -`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 +Pass `sort_query=False` to `invalidate()` for such a route as well (see [Invalidating a single cached route](#invalidating-a-single-cached-route)). +`sort_query` applies to the default key builder only. `build_cache_key()` sorts +too, so a custom `key_builder` that calls it sorts without being told; passing +`sort_query` to `@cache` together with a custom `key_builder` raises +`CacheXError` when the decorator is applied. Call +`build_cache_key(request, ..., sort_query=False)` inside the builder to keep the +order as sent. + A query string longer than 200 bytes, as encoded in the key, is stored as `sha256:` and its 64-digit hex digest instead, so a client cannot make the query part of the key arbitrarily long. (Memcached still hashes a whole key over 250 bytes, prefix included; a query just under the threshold with a long host -or path can get there.) The digest is taken after sorting, so `sort_query` still -merges reordered long queries. The path stays readable, so `clear_path()` still +or path can get there.) The digest is taken after sorting, so reordered long +queries still share one entry. The path stays readable, so `clear_path()` still finds the entry (with `include_params=True`, since the query is not empty), and the monitoring routes show the digest as `query_params`. A query as sent never looks like one: the key writes `:` as `%3A`. @@ -726,7 +728,7 @@ async def update_item(item_id: int, request: Request): return {"invalidated": await invalidate(StarletteRequest(scope))} ``` -`invalidate(request, key_builder=None, vary=None, *, sort_query=False)` returns `True` when an entry existed and +`invalidate(request, key_builder=None, vary=None, *, sort_query=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: @@ -734,8 +736,9 @@ same method, host, path and query string. If the cached route uses a custom `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`. +`clear_path()` removes all of them. By default the request's query is sorted +as `@cache` sorts it, so `?b=2&a=1` deletes the entry stored for `?a=1&b=2`; +for a route with `sort_query=False` pass `sort_query=False` here too. ## Monitoring routes diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index 72433c9..6460e2e 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -254,7 +254,7 @@ CacheManagerProxy.set(CacheManager(lock=True)) - Keys start with the format tag `http:v2|` (`CacheKey.FORMAT_TAG`), so the next format change can remove old keys by pattern: `clear_pattern("http:v2|*")` removes every key of this format. - The host is normalised: lower-cased, and an empty port or the scheme's default one (`:80` on http, `:443` on https) dropped. - A query string over 200 bytes (as encoded in the key) is stored as `sha256:` and its hex digest; the path stays readable, and the monitoring routes show the digest. -- Query parameters are sorted by name: `sort_query` (opt-in since 0.3.9) defaults to `True` in `@cache`, `build_cache_key()` and `invalidate()`, so `?b=2&a=1` and `?a=1&b=2` share one entry. +- Query parameters are sorted by name by default in `@cache`, `invalidate()`, `build_cache_key()` and `CacheKey.from_request()` (`sort_query`, opt-in since 0.3.9), so `?b=2&a=1` and `?a=1&b=2` share one entry. - One public `CacheKey` type builds, encodes and parses keys. `CACHE_KEY_MIN_PARTS`, `CACHE_KEY_MAX_SPLIT` and `CACHE_KEY_MAX_PARTS` are removed from `fastapi_cachex.routes`; read a key's components with `CacheKey.parse(key)` instead. ```text @@ -265,11 +265,11 @@ After: http:v2|GET|example.com|/users/1|page=2 What to change: - `clear_pattern()` patterns that spell out the separator (`"GET|||*|||/users/*"`) need rewriting, and so do patterns that name a host in upper case or with a default port. `clear_path()` and `invalidate()` build the key themselves and need nothing. -- A custom `key_builder` that calls `build_cache_key()` follows automatically. One that builds the key itself (joining with `CACHE_KEY_SEPARATOR` or hard-coding `|||`) still caches and still works with `invalidate()` and `clear_pattern()`, but its keys lack the `http:v2` tag, so `clear_path()` no longer finds them and the monitoring routes no longer list them. Switch it to `build_cache_key(request, *components)` to keep both. -- A handler whose response depends on the order of the query string as sent, such as a self or pagination link copied from `request.url` or a signature over the raw query, should set `@cache(sort_query=False)`. Otherwise the first caller's order is cached and served to callers who sent another. `sort_query=False` works on 0.3.9 already. +- A custom `key_builder` that calls `build_cache_key()` follows automatically, sorting included; pass `sort_query=False` to `build_cache_key()` to keep the order as sent. `@cache` and `invalidate()` now reject `sort_query` passed together with a custom `key_builder`, `False` included (0.3.9 accepted `False` there, where it did nothing): drop it, the builder decides. One that builds the key itself (joining with `CACHE_KEY_SEPARATOR` or hard-coding `|||`) still caches and still works with `invalidate()` and `clear_pattern()`, but its keys lack the `http:v2` tag, so `clear_path()` no longer finds them and the monitoring routes no longer list them. Switch it to `build_cache_key(request, *components)` to keep both. +- A handler whose response depends on the order of the query string as sent, such as a self or pagination link copied from `request.url` or a signature over the raw query, should set `@cache(sort_query=False)`, and pass `sort_query=False` to `invalidate()` for that route. Otherwise the first caller's order is cached and served to callers who sent another. `sort_query=False` works on 0.3.9 already. - Entries written by 0.3.x are not read by 0.4.0. They expire on their TTL; on Redis and memory you can remove them right after the upgrade with `await backend.clear_pattern("*|||*")`. That pattern matches any key containing `|||`, so check first that none of your own keys (a `CacheManager` key, say) does. Memcached cannot enumerate keys, so there they just expire. -0.3.9 does not warn: nothing in 0.3.x can tell whether a pattern or key builder will match the new format, and the only runtime cost is the one-off miss. +0.3.9 does not warn: nothing in 0.3.x can tell whether a pattern or key builder will match the new format, and the only runtime cost is the one-off miss. It does not warn about `sort_query` passed with a custom `key_builder` either, but that raises `CacheXError` when the decorator is applied, usually at import, not at request time. ### Repeated headers {#cache-entry-headers} diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 751a998..d6f9c67 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -71,7 +71,7 @@ def build_cache_key( - request: Request, *components: str | int, sort_query: bool = False + request: Request, *components: str | int, sort_query: bool = True ) -> str: """Build the default cache key for ``request``, plus extra components. @@ -105,9 +105,9 @@ def per_user_key(request: Request) -> str: ``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. + ``?a=1&b=2`` and ``?b=2&a=1`` give the same key. On by default, + as in ``@cache``; ``False`` keeps the order the client sent, as + ``@cache(sort_query=False)`` does. Returns: Generated cache key string @@ -319,7 +319,8 @@ def __call__(self, request: Request, credential: str) -> None: def default_key_builder(request: Request) -> str: """Default cache key builder function: ``build_cache_key(request)``. - Generates cache key in format: http:v2|method|host|path|query + Generates cache key in format: http:v2|method|host|path|query, with the + query parameters sorted by name. Kept as the name ``@cache`` and ``invalidate()`` fall back to. To add components to the default key, call ``build_cache_key`` instead. @@ -333,15 +334,15 @@ 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) +def _unsorted_query_key_builder(request: Request) -> str: + """The key builder of ``@cache(sort_query=False)``.""" + return build_cache_key(request, sort_query=False) _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" + "builds its own key, so pass sort_query to build_cache_key() in it instead " + "(it sorts unless told otherwise)" ) @@ -350,20 +351,23 @@ def _resolve_key_builder( ) -> CacheKeyBuilder: """Pick the key builder for ``key_builder`` and ``sort_query``. + ``sort_query=None`` means it was not passed: the default key builder + sorts, and a custom one is used as is. + 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. + CacheXError: If ``sort_query`` is not a ``bool`` or ``None``, if it + is passed 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): + if sort_query is not None and 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: + if sort_query is not None: 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 + return _unsorted_query_key_builder if sort_query is False else default_key_builder _ASYNC_KEY_BUILDER_MSG = ( @@ -414,7 +418,7 @@ async def invalidate( key_builder: CacheKeyBuilder | None = None, vary: Sequence[str] | None = None, *, - sort_query: bool = False, + sort_query: bool | None = None, ) -> bool: """Invalidate the cache entry a ``@cache``-decorated route would use. @@ -436,10 +440,10 @@ 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 + sort_query: The target route's ``sort_query``. By default the query + is sorted as ``@cache`` sorts it, so ``?b=2&a=1`` deletes the + entry stored for ``?a=1&b=2``. It is not read from the route: for + a route with ``sort_query=False`` pass ``False`` here too, or the key will not match. Returns: @@ -447,7 +451,7 @@ async def invalidate( Raises: CacheXError: If ``vary`` is not a list of header names, if - ``sort_query`` is not a ``bool`` or is combined with + ``sort_query`` is not a ``bool`` or is passed 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. @@ -927,7 +931,7 @@ def cache( fail_open: bool = True, cache_authorized: bool = False, vary: Sequence[str] | None = None, - sort_query: bool = False, + sort_query: bool | None = None, ) -> Callable[[HandlerCallable], AsyncResponseCallable]: """Cache decorator for FastAPI route handlers. @@ -1030,11 +1034,12 @@ def cache( 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()``. + On by default (``None``); ``False`` keeps the order the client + sent, for a handler whose response depends on it. Only the + default key builder reads it: a custom ``key_builder`` is used as + is (``build_cache_key()`` sorts unless told otherwise), and + passing ``sort_query`` with one is rejected. Pass the same value + to ``invalidate()``. Returns: Decorator function that wraps route handlers with caching logic @@ -1045,7 +1050,7 @@ def cache( ``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), if - ``sort_query`` is not a ``bool`` or is combined with + ``sort_query`` is not a ``bool`` or is passed with ``key_builder``, or if ``key_builder`` is an ``async`` callable. At request time, if ``key_builder`` returns anything but a ``str``. diff --git a/fastapi_cachex/cache_key.py b/fastapi_cachex/cache_key.py index 1d9facd..e1499e3 100644 --- a/fastapi_cachex/cache_key.py +++ b/fastapi_cachex/cache_key.py @@ -52,8 +52,8 @@ def _query_component(request: Request, sort_query: bool) -> str: A query longer than 200 bytes (``_QUERY_HASH_THRESHOLD``) is replaced by ``sha256:`` and its full hex digest, so a client cannot make the query - part of the key arbitrarily long; the digest is taken after sorting, so ``sort_query`` - still merges reordered long queries. + part of the key arbitrarily long; the digest is taken after sorting, so + reordered long queries still share a key. """ if sort_query: query = urlencode(sorted(request.query_params.multi_items(), key=itemgetter(0))) @@ -157,7 +157,7 @@ def __post_init__(self) -> None: @classmethod def from_request( - cls, request: Request, *components: str | int, sort_query: bool = False + cls, request: Request, *components: str | int, sort_query: bool = True ) -> "CacheKey": """The key ``@cache`` stores ``request`` under, plus extra components. diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index a0f1274..d81c6b8 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -75,7 +75,7 @@ cache_key = "|".join( ) # 例如: -# http:v2|GET|example.com|/api/users|page=1&limit=10 +# http:v2|GET|example.com|/api/users|limit=10&page=1 (對應 ?page=1&limit=10) # http:v2|GET|api.example.com|/api/users/123| ``` @@ -87,7 +87,7 @@ cache_key = "|".join( 自訂的 `key_builder` 可以用 `build_cache_key(request, *components)` 在查詢字串之後加入其他段;這些段以同樣方式編碼,`clear_path()` 也仍會比對路徑(見 [HTTP 快取](HTTP_CACHING.md#adding-components-to-the-key)中的「在鍵中加入其他段」)。`@cache(vary=[...])` 會在 key builder 回傳的鍵之後,為每個列出的請求標頭附加一個 `name=value` 段,並把這些名稱加入回應的 `Vary` 標頭(見 [HTTP 快取](HTTP_CACHING.md#varying-on-request-headers)中的「依請求標頭區分」)。對於憑證標頭 `Authorization`、`Proxy-Authorization`、`Cookie` 與 `X-Session-Token`,非空的值會寫成 `sha256:<十六進位摘要>`,因此鍵中不會出現任何權杖。 -查詢參數依請求送出的順序串接(`str(request.query_params)`),預設**不會排序**,因此 `?page=1&limit=10` 與 `?limit=10&page=1` 是兩個不同的快取項目。若希望兩者視為同一個,請設定 `@cache(sort_query=True)`,先依名稱排序參數(見 [HTTP 快取](HTTP_CACHING.md#cache-keys)中的「快取鍵」)。超過 200 位元組的查詢接著會改寫為 `sha256:<十六進位摘要>`,讓鍵中查詢的部分維持有限長度。 +查詢參數會依名稱**排序**(穩定排序:同名參數的多個值保留送出的順序),因此 `?page=1&limit=10` 與 `?limit=10&page=1` 共用同一個快取項目。`@cache(sort_query=False)` 則保留請求送出的順序(見 [HTTP 快取](HTTP_CACHING.md#cache-keys)中的「快取鍵」)。超過 200 位元組的查詢接著會改寫為 `sha256:<十六進位摘要>`,讓鍵中查詢的部分維持有限長度。 這個快取鍵格式讓每個維度各自獨立快取: @@ -118,7 +118,7 @@ cache_key = "|".join( @cache(ttl=60, stale="error", stale_ttl=300) # stale-if-error=300 ``` -參數會在套用裝飾器時驗證;若同時設定 `public` 與 `private`、只提供 `stale`/`stale_ttl` 其中之一、`ttl` 不是 `int`、為負數或大於 `MAX_TTL`、`vary` 不是由標頭欄位名稱組成的 list、`sort_query` 不是 `bool` 或與自訂的 `key_builder` 一起使用,或 `key_builder` 是 `async` 可呼叫物件,會拋出 `CacheXError`。 +參數會在套用裝飾器時驗證;若同時設定 `public` 與 `private`、只提供 `stale`/`stale_ttl` 其中之一、`ttl` 不是 `int`、為負數或大於 `MAX_TTL`、`vary` 不是由標頭欄位名稱組成的 list、`sort_query` 不是 `bool` 或與自訂的 `key_builder` 一起傳入,或 `key_builder` 是 `async` 可呼叫物件,會拋出 `CacheXError`。 標頭值在每個被裝飾的路由上只建立一次: @@ -428,7 +428,7 @@ handler 不必自行宣告 `Request`:`@cache` 會在函式簽名中注入一 **Q:為什麼 POST/PUT 的回應不會被快取?** A:`@cache` 只適用於 GET。其他所有方法都直接執行 handler,不讀取或寫入快取,也不會加上 `Cache-Control` 標頭。 -**Q:為什麼同一個端點有好幾個快取項目?** A:因為快取鍵包含查詢參數,而且查詢參數預設**不會排序**。`/users?page=1` 與 `/users?page=2` 是不同的項目;除非路由設定了 `sort_query=True`,`?a=1&b=2` 與 `?b=2&a=1` 也是。 +**Q:為什麼同一個端點有好幾個快取項目?** A:因為快取鍵包含查詢參數。`/users?page=1` 與 `/users?page=2` 是不同的項目;若路由設定了 `sort_query=False`,`?a=1&b=2` 與 `?b=2&a=1` 也是。 **Q:MemoryBackend 在多個行程下如何運作?** A:無法運作。每個行程都有自己的快取;正式環境請使用 Redis。 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index c8bd774..62b3c7c 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -173,20 +173,22 @@ http:v2|{method}|{host}|{path}|{query_params} - 不同的查詢參數各有獨立的快取項目 - 同一個端點搭配不同參數時可以各自快取 -預設情況下,查詢參數依用戶端送出的順序取用,因此 `?a=1&b=2` 與 `?b=2&a=1` 對同一個邏輯上的請求而言是兩筆不同的快取項目。設定 `sort_query=True` 可讓它們共用同一筆項目: +查詢參數會先依名稱排序,再建立快取鍵,因此 `?a=1&b=2` 與 `?b=2&a=1` 共用同一筆項目。排序是穩定的:同名參數的多個值保留用戶端送出的順序,因為以 `tag: list[str]` 讀取的處理函式看到的正是這個順序,所以 `?tag=b&tag=a` 與 `?tag=a&tag=b` 仍是兩筆項目。名稱以解碼後的值比較(`%61` 視為 `a` 排序,快取鍵本來就這樣寫它),每個名稱與值的編碼都與未排序的快取鍵完全相同,只有順序改變:已經依序排列的查詢,不論是否排序都得到相同的鍵。快取鍵原本就視為相同的仍然相同(`?a` 與 `?a=`、`&&` 產生的空段),其餘一律不會合併。0.4.0 起預設會排序([#72](https://github.com/allen0099/FastAPI-CacheX/issues/72),見[遷移至 0.4.0](MIGRATING_0_4.md#cache-keys))。 + +回應取決於用戶端送出之查詢順序的處理函式(例如從 `request.url` 複製的自身連結或分頁連結)應關閉排序,否則第一位呼叫者的順序會被快取,並提供給送出其他順序的呼叫者: ```python @app.get("/search") -@cache(ttl=60, sort_query=True) -async def search(q: str, limit: int = 10): - return await run_search(q, limit) +@cache(ttl=60, sort_query=False) +async def search(request: Request, q: str, limit: int = 10): + return {"self": str(request.url), "items": await run_search(q, limit)} ``` -此時會先依名稱排序參數,再建立快取鍵。排序是穩定的:同名參數的多個值保留用戶端送出的順序,因為以 `tag: list[str]` 讀取的處理函式看到的正是這個順序,所以 `?tag=b&tag=a` 與 `?tag=a&tag=b` 仍是兩筆項目。名稱以解碼後的值比較(`%61` 視為 `a` 排序,快取鍵本來就這樣寫它),每個名稱與值的編碼都與未排序的快取鍵完全相同,只有順序改變:已經依序排列的查詢,不論是否開啟此選項都得到相同的鍵。快取鍵原本就視為相同的仍然相同(`?a` 與 `?a=`、`&&` 產生的空段),其餘一律不會合併。預設為 `False`,現有的快取鍵都不會改變。0.4.0 會隨其他快取鍵變更一起把預設改為 `True`([#72](https://github.com/allen0099/FastAPI-CacheX/issues/72),見[遷移至 0.4.0](MIGRATING_0_4.md#cache-keys))。依賴用戶端送出之查詢順序的處理函式可以維持 `sort_query=False`。 +對這樣的路由呼叫 `invalidate()` 時也要傳入 `sort_query=False`(見[使單一快取路由失效](#invalidating-a-single-cached-route))。 -`sort_query` 只套用於預設的 key builder。與自訂的 `key_builder` 一起使用時,套用裝飾器就會拋出 `CacheXError`;請改在 builder 中呼叫 `build_cache_key(request, ..., sort_query=True)`。對這樣的路由呼叫 `invalidate()` 時也要傳入 `sort_query=True`(見[使單一快取路由失效](#invalidating-a-single-cached-route))。 +`sort_query` 只套用於預設的 key builder。`build_cache_key()` 也會排序,因此呼叫它的自訂 `key_builder` 不需指定就會排序;在 `@cache` 中同時傳入 `sort_query` 與自訂的 `key_builder`,套用裝飾器時就會拋出 `CacheXError`。若要保留送出的順序,請在 builder 中呼叫 `build_cache_key(request, ..., sort_query=False)`。 -在鍵中編碼後超過 200 位元組的查詢字串,會改以 `sha256:` 加上 64 個十六進位字元的摘要儲存,因此用戶端無法讓鍵中查詢的部分無限變長。(超過 250 位元組的整個鍵,含前綴,Memcached 仍會整個雜湊;略低於門檻的查詢配上較長的 host 或路徑仍可能超過。)摘要在排序之後計算,因此 `sort_query` 仍會合併順序不同的長查詢。路徑維持可讀,所以 `clear_path()` 仍找得到該項目(需帶上 `include_params=True`,因為查詢不是空的),監控路由則以 `query_params` 顯示這個摘要。用戶端送出的查詢不可能看起來像摘要:鍵中的 `:` 會寫成 `%3A`。 +在鍵中編碼後超過 200 位元組的查詢字串,會改以 `sha256:` 加上 64 個十六進位字元的摘要儲存,因此用戶端無法讓鍵中查詢的部分無限變長。(超過 250 位元組的整個鍵,含前綴,Memcached 仍會整個雜湊;略低於門檻的查詢配上較長的 host 或路徑仍可能超過。)摘要在排序之後計算,因此順序不同的長查詢仍共用同一筆項目。路徑維持可讀,所以 `clear_path()` 仍找得到該項目(需帶上 `include_params=True`,因為查詢不是空的),監控路由則以 `query_params` 顯示這個摘要。用戶端送出的查詢不可能看起來像摘要:鍵中的 `:` 會寫成 `%3A`。 host 與路徑來自用戶端,因此其中的 `|` 與 `%` 會以百分比編碼寫入(`%7C` 與 `%25`)。含有 `|` 的 `Host` 標頭或路徑因此無法讓各段錯位,使某個請求的快取鍵與另一個請求相同。查詢字串本來就經過 URL 編碼。`clear_path()` 接受應用程式看到的路徑(`request.url.path`),並以同樣方式編碼;`clear_pattern()` 比對的是儲存的快取鍵,所以在模式中要把 `|` 寫成 `%7C`。0.3.8 之前兩者都照原樣儲存,因此升級後,host 或路徑含有 `|` 或 `%` 的項目會重新快取一次。 @@ -430,7 +432,7 @@ async def update_item(item_id: int, request: Request): return {"invalidated": await invalidate(StarletteRequest(scope))} ``` -`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` 儲存的項目。 +`invalidate(request, key_builder=None, vary=None, *, sort_query=None)` 在項目存在且已移除時回傳 `True`,否則回傳 `False`,包括尚未設定後端的情況。後端本身的錯誤則會拋給呼叫端(見[後端發生錯誤時](#when-the-backend-fails))。傳入的請求必須能產生快取路由的鍵:相同的方法、主機、路徑與查詢字串。如果快取路由使用自訂的 `key_builder`、`vary` 或 `sort_query`,這裡也要傳入相同的值,否則鍵不會相符:`invalidate()` 無法從路由讀取這些設定。使用 `vary` 時,只會刪除請求本身的標頭值所選中的變體,`clear_path()` 則會移除所有變體。預設情況下,請求的查詢會以 `@cache` 相同的方式排序,因此 `?b=2&a=1` 會刪除為 `?a=1&b=2` 儲存的項目;對設定了 `sort_query=False` 的路由,這裡也要傳入 `sort_query=False`。 ## 監控路由 {#monitoring-routes} diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 3147228..3e8cc73 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -253,7 +253,7 @@ CacheManagerProxy.set(CacheManager(lock=True)) - 鍵以格式標籤 `http:v2|`(`CacheKey.FORMAT_TAG`)開頭,讓下一次格式變更可以用模式移除舊鍵:`clear_pattern("http:v2|*")` 會移除這個格式的所有鍵。 - host 會正規化:轉為小寫,並去除空的連接埠或該 scheme 的預設連接埠(http 為 `:80`,https 為 `:443`)。 - 超過 200 位元組(以鍵中的編碼計算)的查詢字串會以 `sha256:` 加上十六進位摘要儲存;路徑仍保持可讀,監控路由則顯示這個摘要。 -- 查詢參數會依名稱排序:`sort_query`(0.3.9 起可選用)在 `@cache`、`build_cache_key()` 與 `invalidate()` 中預設為 `True`,因此 `?b=2&a=1` 與 `?a=1&b=2` 共用同一筆項目。 +- 查詢參數預設會依名稱排序,適用於 `@cache`、`invalidate()`、`build_cache_key()` 與 `CacheKey.from_request()`(`sort_query`,0.3.9 起可選用),因此 `?b=2&a=1` 與 `?a=1&b=2` 共用同一筆項目。 - 由單一、公開的 `CacheKey` 型別負責建立、編碼與解析鍵。`fastapi_cachex.routes` 中的 `CACHE_KEY_MIN_PARTS`、`CACHE_KEY_MAX_SPLIT` 與 `CACHE_KEY_MAX_PARTS` 已移除;請改用 `CacheKey.parse(key)` 讀取鍵的各段。 ```text @@ -264,11 +264,11 @@ CacheManagerProxy.set(CacheManager(lock=True)) 需要修改的地方: - 直接寫出分隔符號的 `clear_pattern()` 模式(`"GET|||*|||/users/*"`)需要改寫,以大寫或帶預設連接埠寫出 host 的模式也一樣。`clear_path()` 與 `invalidate()` 會自行組出鍵,不需要修改。 -- 呼叫 `build_cache_key()` 的自訂 `key_builder` 會自動跟上。自行組出鍵的(以 `CACHE_KEY_SEPARATOR` 串接或直接寫死 `|||`)仍可以快取,也仍能搭配 `invalidate()` 與 `clear_pattern()`,但其鍵沒有 `http:v2` 標籤,因此 `clear_path()` 不再找得到它們,監控路由也不再列出它們。改用 `build_cache_key(request, *components)` 即可兩者都保留。 -- 回應取決於用戶端送出的查詢字串順序的處理函式(例如從 `request.url` 複製的自身連結或分頁連結、對原始查詢字串計算的簽章),請設定 `@cache(sort_query=False)`。否則第一位呼叫者的順序會被快取,並提供給送出其他順序的呼叫者。`sort_query=False` 在 0.3.9 就能使用。 +- 呼叫 `build_cache_key()` 的自訂 `key_builder` 會自動跟上,包括排序;若要保留送出的順序,請對 `build_cache_key()` 傳入 `sort_query=False`。`@cache` 與 `invalidate()` 現在會拒絕與自訂 `key_builder` 一起傳入的 `sort_query`,`False` 也包括在內(0.3.9 接受在此傳入 `False`,但它沒有作用):請移除它,由 builder 決定。自行組出鍵的(以 `CACHE_KEY_SEPARATOR` 串接或直接寫死 `|||`)仍可以快取,也仍能搭配 `invalidate()` 與 `clear_pattern()`,但其鍵沒有 `http:v2` 標籤,因此 `clear_path()` 不再找得到它們,監控路由也不再列出它們。改用 `build_cache_key(request, *components)` 即可兩者都保留。 +- 回應取決於用戶端送出的查詢字串順序的處理函式(例如從 `request.url` 複製的自身連結或分頁連結、對原始查詢字串計算的簽章),請設定 `@cache(sort_query=False)`,並對該路由的 `invalidate()` 傳入 `sort_query=False`。否則第一位呼叫者的順序會被快取,並提供給送出其他順序的呼叫者。`sort_query=False` 在 0.3.9 就能使用。 - 0.4.0 不會讀取 0.3.x 寫入的項目。這些項目會在 TTL 到期後過期;在 Redis 與記憶體後端上,可以在升級後立即以 `await backend.clear_pattern("*|||*")` 移除。這個模式會比對任何含有 `|||` 的鍵,因此請先確認你自己的鍵(例如 `CacheManager` 的鍵)都不含它。Memcached 無法列舉鍵,只能等它們過期。 -0.3.9 不會警告:0.3.x 無從判斷某個模式或 key builder 是否符合新格式,而執行期唯一的代價只是一次未命中。 +0.3.9 不會警告:0.3.x 無從判斷某個模式或 key builder 是否符合新格式,而執行期唯一的代價只是一次未命中。與自訂 `key_builder` 一起傳入的 `sort_query` 同樣不會警告,但它會在套用裝飾器時(通常是匯入時)拋出 `CacheXError`,而不是在請求時。 ### 重複的標頭 {#cache-entry-headers} diff --git a/tests/test_build_cache_key.py b/tests/test_build_cache_key.py index a897ef7..30fc904 100644 --- a/tests/test_build_cache_key.py +++ b/tests/test_build_cache_key.py @@ -65,7 +65,8 @@ def _key_before_264(request: Request) -> str: def test_without_components_the_key_is_unchanged(kwargs: dict[str, object]) -> None: request = _request(**kwargs) # type: ignore[arg-type] - assert build_cache_key(request) == _key_before_264(request) + # Unsorted: the query was kept as sent until #72. + assert build_cache_key(request, sort_query=False) == _key_before_264(request) assert default_key_builder(request) == build_cache_key(request) diff --git a/tests/test_cache_key_type.py b/tests/test_cache_key_type.py index 6f5382d..74c0a85 100644 --- a/tests/test_cache_key_type.py +++ b/tests/test_cache_key_type.py @@ -53,11 +53,11 @@ def test_from_request_is_what_build_cache_key_returns( assert key.extra == tuple(str(arg) for arg in args) -def test_from_request_sorts_the_query_only_when_asked() -> None: +def test_from_request_sorts_the_query_unless_told_not_to() -> None: request = _request(query=b"b=2&a=1") - assert CacheKey.from_request(request).query == "b=2&a=1" - assert CacheKey.from_request(request, sort_query=True).query == "a=1&b=2" + assert CacheKey.from_request(request).query == "a=1&b=2" + assert CacheKey.from_request(request, sort_query=False).query == "b=2&a=1" def test_build_cache_key_format_is_pinned() -> None: diff --git a/tests/test_cache_sort_query.py b/tests/test_cache_sort_query.py index 610b077..521a055 100644 --- a/tests/test_cache_sort_query.py +++ b/tests/test_cache_sort_query.py @@ -1,4 +1,4 @@ -"""Tests for ``@cache(sort_query=True)`` (#267).""" +"""Tests for ``sort_query`` (#267), on by default since #72.""" from collections.abc import Callable @@ -16,6 +16,7 @@ from fastapi_cachex.cache_key import CacheKey from fastapi_cachex.exceptions import CacheXError from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.types import CacheEntry def _request(query: bytes) -> StarletteRequest: @@ -37,10 +38,12 @@ def _query(key: str) -> str: def _sorted_query(query: bytes) -> str: - return _query(build_cache_key(_request(query), sort_query=True)) + return _query(build_cache_key(_request(query))) -def _app(*, sort_query: bool, sync: bool = False) -> tuple[TestClient, list[str]]: +def _app( + *, sort_query: bool | None = None, sync: bool = False +) -> tuple[TestClient, list[str]]: app = FastAPI() calls: list[str] = [] @@ -64,8 +67,11 @@ async def async_items(request: Request) -> dict[str, str]: @pytest.mark.parametrize("sync", [False, True], ids=["async", "sync"]) -def test_reordered_parameters_share_one_entry(sync: bool) -> None: - client, calls = _app(sort_query=True, sync=sync) +@pytest.mark.parametrize("sort_query", [None, True], ids=["default", "explicit"]) +def test_reordered_parameters_share_one_entry( + sync: bool, sort_query: bool | None +) -> None: + client, calls = _app(sort_query=sort_query, sync=sync) first = client.get("/items?limit=1&q=wid") second = client.get("/items?q=wid&limit=1") @@ -76,7 +82,7 @@ def test_reordered_parameters_share_one_entry(sync: bool) -> None: @pytest.mark.parametrize("sync", [False, True], ids=["async", "sync"]) def test_repeated_name_order_stays_distinct(sync: bool) -> None: - client, calls = _app(sort_query=True, sync=sync) + client, calls = _app(sync=sync) client.get("/items?tag=b&tag=a") client.get("/items?tag=a&tag=b") @@ -88,7 +94,7 @@ def test_repeated_name_order_stays_distinct(sync: bool) -> None: @pytest.mark.parametrize("sync", [False, True], ids=["async", "sync"]) -def test_default_keeps_reordered_queries_apart(sync: bool) -> None: +def test_sort_query_false_keeps_reordered_queries_apart(sync: bool) -> None: client, calls = _app(sort_query=False, sync=sync) client.get("/items?a=1&b=2") @@ -99,7 +105,7 @@ def test_default_keeps_reordered_queries_apart(sync: bool) -> None: def test_only_parameter_order_merges() -> None: - client, calls = _app(sort_query=True) + client, calls = _app() for query in ("a=1&b=2", "b=2&a=1", "a=1&b=3", "a=1", "b=2", "a=1&b=2&c="): client.get(f"/items?{query}") @@ -107,17 +113,24 @@ def test_only_parameter_order_merges() -> None: assert calls == ["a=1&b=2", "a=1&b=3", "a=1", "b=2", "a=1&b=2&c="] -def test_default_key_is_unchanged() -> None: +def test_the_default_key_is_the_sorted_one() -> None: for query in (b"b=2&a=1", b"tag=b&tag=a", b"q=a%20b&n%26=x%3D", b""): request = _request(query) assert default_key_builder(request) == build_cache_key(request) - assert _query(build_cache_key(request)) == str(request.query_params) + assert build_cache_key(request) == build_cache_key(request, sort_query=True) + + +def test_the_unsorted_key_keeps_the_query_as_sent() -> None: + for query in (b"b=2&a=1", b"tag=b&tag=a", b"q=a%20b&n%26=x%3D", b""): + request = _request(query) + key = build_cache_key(request, sort_query=False) + assert _query(key) == str(request.query_params) -def test_sorted_key_matches_the_default_for_a_sorted_query() -> None: +def test_sorted_key_matches_the_unsorted_for_a_sorted_query() -> None: request = _request(b"a=1&b=x%26y&c=") - assert build_cache_key(request, sort_query=True) == build_cache_key(request) + assert build_cache_key(request) == build_cache_key(request, sort_query=False) @pytest.mark.parametrize( @@ -166,12 +179,14 @@ def test_names_are_compared_decoded() -> None: assert _sorted_query(b"b=0&%61=1&a=2") == "a=1&a=2&b=0" -def test_sort_query_with_custom_key_builder_is_rejected() -> None: +@pytest.mark.parametrize("value", [True, False]) +def test_sort_query_with_custom_key_builder_is_rejected(value: bool) -> None: + # False is rejected too: build_cache_key() in the builder sorts anyway. with pytest.raises(CacheXError, match="sort_query only applies"): - cache(ttl=60, key_builder=default_key_builder, sort_query=True)(lambda: None) + cache(ttl=60, key_builder=default_key_builder, sort_query=value)(lambda: None) -@pytest.mark.parametrize("value", [1, "yes", None]) +@pytest.mark.parametrize("value", [1, 0, "yes"]) def test_sort_query_must_be_a_bool(value: object) -> None: with pytest.raises(CacheXError, match="sort_query must be a bool"): cache(ttl=60, sort_query=value)(lambda: None) # type: ignore[arg-type] @@ -182,7 +197,7 @@ def test_custom_key_builder_can_sort_through_build_cache_key() -> None: calls: list[int] = [] def sorted_key(request: Request) -> str: - return build_cache_key(request, "tenant", sort_query=True) + return build_cache_key(request, "tenant") @app.get("/items") @cache(ttl=60, key_builder=sorted_key) @@ -200,10 +215,10 @@ async def items() -> dict[str, int]: async def test_invalidate_with_reordered_query_drops_the_entry() -> None: backend = MemoryBackend() BackendProxy.set(backend) - client, calls = _app(sort_query=True) + client, calls = _app() client.get("/items?a=1&b=2") - assert await invalidate(_request(b"b=2&a=1"), sort_query=True) is True + assert await invalidate(_request(b"b=2&a=1")) is True assert not backend.cache client.get("/items?a=1&b=2") assert calls == ["a=1&b=2", "a=1&b=2"] @@ -211,15 +226,16 @@ async def test_invalidate_with_reordered_query_drops_the_entry() -> None: async def test_invalidate_needs_the_routes_sort_query() -> None: # invalidate() cannot read the route's settings: without the flag it - # builds the unsorted key and misses the entry stored for a reordered - # query. + # builds the sorted key and misses the entry an unsorted route stored. backend = MemoryBackend() BackendProxy.set(backend) - client, _ = _app(sort_query=True) - client.get("/items?a=1&b=2") + client, _ = _app(sort_query=False) + client.get("/items?b=2&a=1") assert await invalidate(_request(b"b=2&a=1")) is False assert len(backend.cache) == 1 + assert await invalidate(_request(b"b=2&a=1"), sort_query=False) is True + assert not backend.cache @pytest.mark.parametrize( @@ -228,9 +244,12 @@ async def test_invalidate_needs_the_routes_sort_query() -> None: lambda: invalidate( _request(b""), key_builder=default_key_builder, sort_query=True ), + lambda: invalidate( + _request(b""), key_builder=default_key_builder, sort_query=False + ), lambda: invalidate(_request(b""), sort_query=1), # type: ignore[arg-type] ], - ids=["with-key-builder", "not-a-bool"], + ids=["true-with-key-builder", "false-with-key-builder", "not-a-bool"], ) async def test_invalidate_validates_sort_query( call: Callable[[], object], @@ -242,10 +261,23 @@ async def test_invalidate_validates_sort_query( async def test_clear_path_clears_sorted_entries() -> None: backend = MemoryBackend() BackendProxy.set(backend) - client, _ = _app(sort_query=True) + client, _ = _app() client.get("/items?b=2&a=1") client.get("/items") assert await backend.clear_path("/items") == 1 assert await backend.clear_path("/items", include_params=True) == 1 assert not backend.cache + + +async def test_invalidate_with_a_custom_key_builder_and_no_sort_query() -> None: + backend = MemoryBackend() + BackendProxy.set(backend) + request = _request(b"b=2&a=1") + await backend.set(build_cache_key(request, "t"), CacheEntry("e", b"v")) + + def tenant_key(request: Request) -> str: + return build_cache_key(request, "t") + + assert await invalidate(_request(b"a=1&b=2"), key_builder=tenant_key) is True + assert not backend.cache diff --git a/tests/test_custom_cache_key.py b/tests/test_custom_cache_key.py index 92e510d..67043ef 100644 --- a/tests/test_custom_cache_key.py +++ b/tests/test_custom_cache_key.py @@ -165,36 +165,36 @@ async def get_default(): def test_default_key_builder_function() -> None: """Test the default_key_builder function directly.""" - from unittest.mock import MagicMock - - # Create a mock request - mock_request = MagicMock(spec=Request) - mock_request.method = "GET" - mock_request.headers = {"host": "example.com"} - mock_request.url.path = "/api/items" - mock_request.query_params = "page=1&limit=10" + request = Request( + { + "type": "http", + "method": "GET", + "path": "/api/items", + "query_string": b"page=1&limit=10", + "headers": [(b"host", b"example.com")], + } + ) - # Generate cache key - cache_key = default_key_builder(mock_request) + cache_key = default_key_builder(request) - # Verify format: http:v2|method|host|path|query - expected = "http:v2|GET|example.com|/api/items|page=1&limit=10" + # Verify format: http:v2|method|host|path|query, the query sorted by name + expected = "http:v2|GET|example.com|/api/items|limit=10&page=1" assert cache_key == expected def test_default_key_builder_without_host() -> None: """Test default_key_builder when host header is missing.""" - from unittest.mock import MagicMock - - # Create a mock request without host - mock_request = MagicMock(spec=Request) - mock_request.method = "GET" - mock_request.headers = {} - mock_request.url.path = "/api/items" - mock_request.query_params = "" + request = Request( + { + "type": "http", + "method": "GET", + "path": "/api/items", + "query_string": b"", + "headers": [], + } + ) - # Generate cache key - cache_key = default_key_builder(mock_request) + cache_key = default_key_builder(request) # Should use 'unknown' as fallback for host expected = "http:v2|GET|unknown|/api/items|" diff --git a/tests/test_query_hash.py b/tests/test_query_hash.py index 818c347..fcf46e5 100644 --- a/tests/test_query_hash.py +++ b/tests/test_query_hash.py @@ -66,10 +66,10 @@ def test_the_digest_is_taken_after_sorting() -> None: forward = "a=1&" + _query(250) reverse = _query(250) + "&a=1" - sorted_keys = { - build_cache_key(_request(q), sort_query=True) for q in (forward, reverse) + sorted_keys = {build_cache_key(_request(q)) for q in (forward, reverse)} + unsorted_keys = { + build_cache_key(_request(q), sort_query=False) for q in (forward, reverse) } - unsorted_keys = {build_cache_key(_request(q)) for q in (forward, reverse)} assert len(sorted_keys) == 1 assert len(unsorted_keys) == 2 @@ -156,14 +156,14 @@ async def test_invalidate_finds_a_hashed_entry() -> None: assert backend.cache == {} -async def test_sorted_invalidate_finds_a_reordered_long_query() -> None: +async def test_invalidate_finds_a_reordered_long_query() -> None: backend = MemoryBackend() BackendProxy.set(backend) stored = _request("a=1&" + _query(250)) - await backend.set(build_cache_key(stored, sort_query=True), ENTRY) + await backend.set(build_cache_key(stored), ENTRY) reordered = _request(_query(250) + "&a=1") - assert await invalidate(reordered) is False - assert await invalidate(reordered, sort_query=True) is True + assert await invalidate(reordered, sort_query=False) is False + assert await invalidate(reordered) is True assert backend.cache == {}