From a25f687bca9de01e060dd1067c819737c96133c5 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 16:31:13 +0000 Subject: [PATCH] feat(cache)!: tag HTTP cache keys http:v2 and separate them with one | Every client-controlled component is percent-encoded, so a single `|` is enough to separate them. Keys now start with the format tag `http:v2` (`CacheKey.FORMAT_TAG`), so a later format never collides with this one and `clear_pattern("http:v2|*")` removes every key of it. `CacheKey.parse()` only reads tagged keys, so `clear_path()` and the monitoring routes skip 0.3.x keys and keys from a key builder that does not use `build_cache_key()`. The path-shaped `clear_pattern` warning now suggests a pattern that also matches a path with no glob. BREAKING CHANGE: HTTP cache keys change from `method|||host|||path|||query` to `http:v2|method|host|path|query`, and `CACHE_KEY_SEPARATOR` is `"|"`. Entries written by 0.3.x are not read. Closes #271 Closes #266 --- changelog.d/125.removed.md | 2 +- changelog.d/266.changed.md | 6 ++ changelog.d/271.changed.md | 7 ++ docs/CACHE_FLOW.md | 40 ++++---- docs/HTTP_CACHING.md | 36 ++++--- docs/MIGRATING_0_4.md | 10 +- fastapi_cachex/backends/base.py | 19 ++-- fastapi_cachex/backends/memcached.py | 4 +- fastapi_cachex/cache.py | 6 +- fastapi_cachex/cache_key.py | 42 ++++---- fastapi_cachex/routes.py | 2 +- fastapi_cachex/types.py | 12 ++- i18n/zh-TW/docs/CACHE_FLOW.md | 28 +++--- i18n/zh-TW/docs/HTTP_CACHING.md | 28 +++--- i18n/zh-TW/docs/MIGRATING_0_4.md | 10 +- tests/backends/test_clear_pattern_contract.py | 72 ++++++++++---- tests/backends/test_memcached.py | 18 ++-- tests/backends/test_memory.py | 82 ++++++++-------- tests/backends/test_redis.py | 96 ++++++++++--------- tests/session/test_cache_session_bypass.py | 6 +- tests/test_build_cache_key.py | 15 +-- tests/test_cache.py | 4 +- tests/test_cache_age.py | 2 +- tests/test_cache_backend_failure.py | 2 +- tests/test_cache_key.py | 27 +++--- tests/test_cache_key_type.py | 39 +++++--- tests/test_cache_status_headers.py | 2 +- tests/test_cache_unshareable.py | 4 +- tests/test_cache_vary.py | 36 ++++--- tests/test_custom_cache_key.py | 6 +- tests/test_routes.py | 10 +- 31 files changed, 392 insertions(+), 281 deletions(-) create mode 100644 changelog.d/266.changed.md create mode 100644 changelog.d/271.changed.md diff --git a/changelog.d/125.removed.md b/changelog.d/125.removed.md index 50ead91..e0864f8 100644 --- a/changelog.d/125.removed.md +++ b/changelog.d/125.removed.md @@ -3,4 +3,4 @@ stripped.** A pattern always matches the logical key, as on every other backend, so one that starts with the backend's `key_prefix` now clears only logical keys that themselves start with it, and the `DeprecationWarning` is gone. Leave the prefix out: `clear_pattern("fastapi_cachex:GET|||*")` becomes -`clear_pattern("GET|||*")`. +`clear_pattern("http:v2|GET|*")`. diff --git a/changelog.d/266.changed.md b/changelog.d/266.changed.md new file mode 100644 index 0000000..55557ad --- /dev/null +++ b/changelog.d/266.changed.md @@ -0,0 +1,6 @@ +**HTTP cache keys start with the format tag `http:v2`.** A key is now +`http:v2|method|host|path|query`, and `CacheKey.FORMAT_TAG` holds the tag, so a +later format change never collides with these keys and +`clear_pattern("http:v2|*")` removes every one of them. `clear_path()` and the +monitoring routes only recognise tagged keys: a custom `key_builder` that does +not use `build_cache_key()` still caches, but those two no longer see its keys. diff --git a/changelog.d/271.changed.md b/changelog.d/271.changed.md new file mode 100644 index 0000000..7405da0 --- /dev/null +++ b/changelog.d/271.changed.md @@ -0,0 +1,7 @@ +**HTTP cache keys are separated by a single `|` instead of `|||`.** +`CACHE_KEY_SEPARATOR` is now `"|"`. Every client-controlled component is +percent-encoded, so one character is enough. Entries written by 0.3.x are no +longer read: each is a cache miss once and expires on its TTL, or remove them +with `clear_pattern("*|||*")` on Redis and memory. `clear_pattern()` patterns +that spell out `|||` need rewriting; see +[Migrating to 0.4.0](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 abdae67..34756bf 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -25,7 +25,7 @@ private, no positive ttl, or Authorization/session without public/cache_authoriz (cache_authorized with Authorization or a session: the backend is used below, but every answer still says private instead of public) ↓ -Build the cache key: key_builder (default method|||host|||path|||query_params), +Build the cache key: key_builder (default http:v2|method|host|path|query_params), plus one name=value component per vary header ↓ Read the backend entry (with fail_open, a backend error counts as a miss) @@ -63,13 +63,15 @@ names to Vary on every GET response When a request arrives, the `@cache` decorator does the following: ```python -from fastapi_cachex.types import CACHE_KEY_SEPARATOR # "|||" +from fastapi_cachex import CacheKey from fastapi_cachex.types import escape_key_component -# Cache key format (build_cache_key in fastapi_cachex/cache.py) -cache_key = CACHE_KEY_SEPARATOR.join( +# Cache key format (CacheKey in fastapi_cachex/cache_key.py; +# build_cache_key(request) is CacheKey.from_request(request).to_str()) +cache_key = "|".join( [ - request.method, + CacheKey.FORMAT_TAG, # "http:v2" + escape_key_component(request.method), escape_key_component(request.headers.get("host", "unknown")), escape_key_component(request.url.path), query, @@ -77,19 +79,25 @@ cache_key = CACHE_KEY_SEPARATOR.join( ) # For example: -# GET|||example.com|||/api/users|||page=1&limit=10 -# GET|||api.example.com|||/api/users/123||| +# http:v2|GET|example.com|/api/users|page=1&limit=10 +# http:v2|GET|api.example.com|/api/users/123| ``` -The separator is `|||` rather than a colon because the host itself may contain a +Every key starts with the format tag `http:v2`. Keys written in another format +(0.3.x wrote `GET|||host|||path|||query` with no tag) never collide with these, +and `clear_pattern("http:v2|*")` removes every key of this one on Redis and +memory. `CacheKey.parse()` only reads keys with this tag. + +The separator is `|` rather than a colon because the host itself may contain a port (`127.0.0.1:8000`); with a colon the key could not be split reliably, and `clear_path()` needs to recover the path from the key. -The host and path are percent-encoded first: `|` becomes `%7C` and `%` becomes -`%25` (`escape_key_component` in `fastapi_cachex/types.py`). Both come from the -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. +The method, host and path are percent-encoded first: `|` becomes `%7C` and `%` +becomes `%25` (`escape_key_component` in `fastapi_cachex/types.py`). The host +and path come from the 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, so it never contains `|`. The monitoring routes decode the +components 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 @@ -330,7 +338,7 @@ intermediate cache would lose those fields after revalidation (RFC 9110 ```python # dict[str, CacheItem]; CacheItem wraps the CacheEntry and records its expiry { - "GET|||example.com|||/api/users|||": CacheItem( + "http:v2|GET|example.com|/api/users|": CacheItem( value=CacheEntry( fingerprint='W/"abc123"', content=b"...", @@ -385,7 +393,7 @@ and the standard library `json` otherwise: ### MemcachedBackend ``` -key: "fastapi_cachex:GET|||example.com|||/api/users|||" +key: "fastapi_cachex:http:v2|GET|example.com|/api/users|" value: the JSON document above # Characteristics: @@ -410,7 +418,7 @@ value: the JSON document above ### AsyncRedisCacheBackend ``` -key: "fastapi_cachex:GET|||example.com|||/api/users|||" +key: "fastapi_cachex:http:v2|GET|example.com|/api/users|" value: the JSON document above # Characteristics: diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 4e083c6..9591884 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -286,9 +286,13 @@ This only covers `@cache`. `invalidate()`, `CacheManager`, `StateManager`, Cache keys are generated in the following format to avoid collisions: ``` -{method}|||{host}|||{path}|||{query_params} +http:v2|{method}|{host}|{path}|{query_params} ``` +`http:v2` is the format tag (`CacheKey.FORMAT_TAG`). A later key format gets +another tag, so its keys never collide with these; on Redis and memory, +`clear_pattern("http:v2|*")` removes every HTTP cache entry of this format. + This ensures that: - Different HTTP methods (GET, POST, etc.) don't share cache @@ -328,7 +332,7 @@ 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 +(`%7C` and `%25`). A `Host` header or path containing `|` therefore cannot shift the components and make one request's key equal another's. The query string is URL-encoded already. `clear_path()` takes the path as your application sees it (`request.url.path`) and encodes it the same way; `clear_pattern()` matches the @@ -357,7 +361,7 @@ 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}|||... +http:v2|{method}|{host}|{path}|{query_params}|{component}|... ``` ```python @@ -374,10 +378,10 @@ 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: +`|` 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 +Because the path stays in its place, `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 @@ -387,7 +391,7 @@ path. The monitoring routes show the extra components, decoded, in *components)` builds it, `to_str()` gives the string `build_cache_key` returns, and `CacheKey.parse(key)` decodes a stored key into `method`, `host`, `path`, `query` and `extra`, or returns `None` for a key that is not an HTTP key -(a `CacheManager` key, say): +(a `CacheManager` key, say, or one without the `http:v2` tag): ```python from fastapi_cachex import CacheKey @@ -402,9 +406,14 @@ 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 [Application cache](APP_CACHE.md)) uses a separate, simpler `cache:`-prefixed key -namespace instead of this `|||`-separated format, since its keys aren't tied to +namespace instead of this `|`-separated format, since its keys aren't tied to HTTP requests. +A `key_builder` that returns a key of its own making, not built by +`build_cache_key()` (or `CacheKey`), still caches, invalidates with +`invalidate()` and clears with `clear_pattern()`. But the key has no `http:v2` +tag, so `clear_path()` does not find it and the monitoring routes skip it. + ### Varying on request headers The key includes no request header, so a route whose response depends on, say, @@ -423,7 +432,7 @@ 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 +`http:v2|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. @@ -449,7 +458,7 @@ holds the full hex SHA-256 of the value (trimmed and joined as above) instead of the value: ``` -GET|||example.com|||/me||||||authorization=sha256:3f0a…(64 hex digits) +http:v2|GET|example.com|/me||authorization=sha256:3f0a…(64 hex digits) ``` The same token always gives the same digest, so it hits its own entry, and two @@ -645,8 +654,8 @@ async def clear(cache: CacheBackend) -> None: # ...or every query-param variant too await cache.clear_path("/api/users", include_params=True) - # Clear by pattern: matched against the whole key method|||host|||path|||query - await cache.clear_pattern("GET|||*|||/api/users/*") + # Clear by pattern: matched against the whole key http:v2|method|host|path|query + await cache.clear_pattern("http:v2|GET|*|/api/users/*") # Keys you built yourself (e.g. CacheManager keys) match directly await cache.clear_pattern("cache:user:*") @@ -738,8 +747,9 @@ add_routes( `content_type` is always `"bytes"` and is kept for compatibility; read `media_type` instead. -Both routes list only route entries (keys in the `method|||host|||path|||query` -format); `CacheManager`, session, state and lock keys are skipped. +Both routes list only route entries (keys in the `http:v2|method|host|path|query` +format); `CacheManager`, session, state and lock keys are skipped, and so are +keys from a `key_builder` that does not use `build_cache_key()`. > [!WARNING] > **These routes have no authentication of their own.** `include_in_schema=False` diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index 7c49f80..b4e4709 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -251,7 +251,7 @@ CacheManagerProxy.set(CacheManager(lock=True)) 0.4.0 changes the format of every HTTP cache key, in one step so that the upgrade costs a single cache miss ([#271](https://github.com/allen0099/FastAPI-CacheX/issues/271), [#266](https://github.com/allen0099/FastAPI-CacheX/issues/266), [#265](https://github.com/allen0099/FastAPI-CacheX/issues/265), [#269](https://github.com/allen0099/FastAPI-CacheX/issues/269), [#270](https://github.com/allen0099/FastAPI-CacheX/issues/270), [#72](https://github.com/allen0099/FastAPI-CacheX/issues/72)): - The separator becomes a single `|` (`CACHE_KEY_SEPARATOR`). -- Keys start with a format tag, such as `http:v2|`, so the next format change can remove old keys by pattern. +- 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 the scheme's default port (`:80`, `:443`) dropped. - A long query string (over about 200 bytes) is stored as `sha256:` and its hex digest; the path stays readable. - 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. @@ -259,15 +259,15 @@ CacheManagerProxy.set(CacheManager(lock=True)) ```text Before: GET|||Example.com:80|||/users/1|||page=2 -After: http:v2|GET|example.com|/users/1|page=2 (exact tag not final) +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. `clear_path()` and `invalidate()` build the key themselves and need nothing. -- A custom `key_builder` that calls `build_cache_key()` or joins with `CACHE_KEY_SEPARATOR` follows automatically; one that hard-codes `|||` does not. +- 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. -- 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("*|||*")`. Memcached cannot enumerate keys, so there they just expire. +- 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. @@ -324,7 +324,7 @@ Before 0.3.8, a Redis `clear_pattern()` pattern that started with the backend's await backend.clear_pattern("fastapi_cachex:GET|||*") # After -await backend.clear_pattern("GET|||*") # "GET|*" with 0.4.0's key format +await backend.clear_pattern("GET|||*") # "http:v2|GET|*" with 0.4.0's key format ``` ### delete() return value {#backend-delete} diff --git a/fastapi_cachex/backends/base.py b/fastapi_cachex/backends/base.py index 319cc23..036e540 100644 --- a/fastapi_cachex/backends/base.py +++ b/fastapi_cachex/backends/base.py @@ -9,6 +9,7 @@ from typing import Any from fastapi_cachex.types import CACHE_KEY_SEPARATOR +from fastapi_cachex.types import HTTP_KEY_FORMAT_TAG from fastapi_cachex.types import CacheEntry from fastapi_cachex.types import counter_entry from fastapi_cachex.types import counter_value @@ -30,13 +31,14 @@ def warn_if_path_shaped(pattern: str, cleared: int) -> None: paths (stored directly through ``set``) stay silent when they work. """ if cleared == 0 and pattern.startswith("/") and CACHE_KEY_SEPARATOR not in pattern: + sep = CACHE_KEY_SEPARATOR warnings.warn( f"clear_pattern({pattern!r}) cleared nothing. Patterns match whole " - f"cache keys, which look like 'method{CACHE_KEY_SEPARATOR}host" - f"{CACHE_KEY_SEPARATOR}path{CACHE_KEY_SEPARATOR}query', so a bare " - "path matches no HTTP cache entry. Use clear_path(path, " - "include_params=True) to clear by path, or write the whole key out " - f"as 'GET{CACHE_KEY_SEPARATOR}*{CACHE_KEY_SEPARATOR}{pattern}'.", + f"cache keys, which look like '{HTTP_KEY_FORMAT_TAG}{sep}method{sep}" + f"host{sep}path{sep}query', so a bare path matches no HTTP cache " + "entry. Use clear_path(path, include_params=True) to clear by " + "path, or write the whole key out as " + f"'{HTTP_KEY_FORMAT_TAG}{sep}GET{sep}*{sep}{pattern}{sep}*'.", RuntimeWarning, stacklevel=3, ) @@ -311,10 +313,11 @@ async def clear_pattern(self, pattern: str) -> int: The pattern is matched against the whole logical key — the key as the caller sees it, without whatever prefix the backend adds internally. - HTTP cache keys are ``method|||host|||path|||query``, so matching a - path means writing the other components out:: + HTTP cache keys are ``http:v2|method|host|path|query`` (see + ``CacheKey``), so matching a path means writing the other components + out:: - await backend.clear_pattern("GET|||*|||/users/*") + await backend.clear_pattern("http:v2|GET|*|/users/*") await backend.clear_pattern("cache:user:*") # a CacheManager key To clear by path, prefer ``clear_path(path, include_params=...)``: it diff --git a/fastapi_cachex/backends/memcached.py b/fastapi_cachex/backends/memcached.py index 764d8e6..2952f77 100644 --- a/fastapi_cachex/backends/memcached.py +++ b/fastapi_cachex/backends/memcached.py @@ -456,7 +456,7 @@ async def clear_path(self, path: str, include_params: bool = False) -> int: """Delete the key that is exactly ``path``; warns on every call. Memcached cannot enumerate keys, so this cannot find HTTP route keys - (``method|||host|||path|||query``): it only deletes a key stored under + (``http:v2|method|host|path|query``): it only deletes a key stored under the literal name ``path``, and ``include_params`` has no effect. It warns every time, because on this backend ``clear_path()`` after a write would otherwise leave the cached response in place without a sign. Use @@ -473,7 +473,7 @@ async def clear_path(self, path: str, include_params: bool = False) -> int: warnings.warn( "Memcached backend does not support pattern-based key clearing, so " "clear_path() cannot remove HTTP cache entries " - "(method|||host|||path|||query): it only deletes a key named " + "(http:v2|method|host|path|query): it only deletes a key named " "exactly as the path, and include_params has no effect. Use " "invalidate(request) to drop a cached route's entry.", RuntimeWarning, diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 3a0def6..b61608d 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -75,7 +75,7 @@ def build_cache_key( ) -> str: """Build the default cache key for ``request``, plus extra components. - With no ``components`` the key is ``method|||host|||path|||query_params``, + With no ``components`` the key is ``http:v2|method|host|path|query``, 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:: @@ -88,7 +88,7 @@ def per_user_key(request: Request) -> str: 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 + Keys built this way keep the tag, method, host and path in front, so ``clear_path()`` still finds them and the monitoring routes still show their method, host, path and query. This is ``CacheKey.from_request(...).to_str()``; ``CacheKey.parse()`` decodes the @@ -317,7 +317,7 @@ 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: method|||host|||path|||query_params + Generates cache key in format: http:v2|method|host|path|query Kept as the name ``@cache`` and ``invalidate()`` fall back to. To add components to the default key, call ``build_cache_key`` instead. diff --git a/fastapi_cachex/cache_key.py b/fastapi_cachex/cache_key.py index d74db35..c6f355b 100644 --- a/fastapi_cachex/cache_key.py +++ b/fastapi_cachex/cache_key.py @@ -7,17 +7,18 @@ from dataclasses import dataclass from operator import itemgetter +from typing import ClassVar from urllib.parse import urlencode from fastapi import Request from .types import CACHE_KEY_SEPARATOR +from .types import HTTP_KEY_FORMAT_TAG from .types import escape_key_component from .types import unescape_key_component -# A key has at least method, host and path; the query string is optional only -# when parsing keys that were written without it. -_MIN_PARTS = 3 +# Format tag, method, host, path and query: a key never has fewer components. +_MIN_PARTS = 5 # Characters that are live in a Redis glob pattern. _GLOB_SPECIAL = frozenset("*?[]\\") @@ -62,8 +63,11 @@ def _component_text(component: str | int) -> str: class CacheKey: """An HTTP cache key, decoded into its components. - The string form is ``method|||host|||path|||query``, followed by one - component per ``extra`` item. ``method``, ``host``, ``path`` and every + The string form is ``http:v2|method|host|path|query``, followed by one + component per ``extra`` item. The leading ``FORMAT_TAG`` names the key + format; a later format gets another tag, so its keys never collide with + these and ``clear_pattern(f"{CacheKey.FORMAT_TAG}|*")`` removes every key + of this one. ``method``, ``host``, ``path`` and every ``extra`` item hold the plain text and are percent-encoded by ``to_str()`` (see ``escape_key_component``), so a client-controlled value cannot contain the separator; a method token may contain ``|``. ``query`` is @@ -91,6 +95,8 @@ class CacheKey: query: str = "" extra: tuple[str, ...] = () + FORMAT_TAG: ClassVar[str] = HTTP_KEY_FORMAT_TAG + def __post_init__(self) -> None: """Reject a ``query`` the string form could not keep apart.""" if CACHE_KEY_SEPARATOR in self.query: @@ -121,6 +127,7 @@ def to_str(self) -> str: """The key as stored in the backend.""" return CACHE_KEY_SEPARATOR.join( [ + self.FORMAT_TAG, escape_key_component(self.method), escape_key_component(self.host), escape_key_component(self.path), @@ -133,21 +140,21 @@ def to_str(self) -> str: def parse(cls, key: str) -> "CacheKey | None": """Decode a stored HTTP key, or return ``None`` for any other key. - ``key`` is the logical key, without a backend's ``key_prefix``. A key - with fewer than three components or an empty method (a ``CacheManager`` - or ``StateManager`` key, or one from a key builder that does not use - ``build_cache_key``) is not an HTTP key. A key that ends at the path - parses with an empty query. + ``key`` is the logical key, without a backend's ``key_prefix``. Only + a key that starts with ``FORMAT_TAG`` and has at least a method, host, + path and query is an HTTP key. ``CacheManager`` and ``StateManager`` + keys, keys written by 0.3.x (``method|||host|||path|||query``) and keys + from a key builder that does not use ``build_cache_key`` are not. """ parts = key.split(CACHE_KEY_SEPARATOR) - if len(parts) < _MIN_PARTS or not parts[0]: + if len(parts) < _MIN_PARTS or parts[0] != cls.FORMAT_TAG or not parts[1]: return None return cls( - method=unescape_key_component(parts[0]), - host=unescape_key_component(parts[1]), - path=unescape_key_component(parts[2]), - query=parts[3] if len(parts) > _MIN_PARTS else "", - extra=tuple(unescape_key_component(part) for part in parts[4:]), + method=unescape_key_component(parts[1]), + host=unescape_key_component(parts[2]), + path=unescape_key_component(parts[3]), + query=parts[4], + extra=tuple(unescape_key_component(part) for part in parts[5:]), ) @staticmethod @@ -161,4 +168,5 @@ def path_glob(path: str) -> str: ``key_prefix`` in front. """ escaped = escape_glob(escape_key_component(path)) - return f"*{CACHE_KEY_SEPARATOR}{escaped}{CACHE_KEY_SEPARATOR}*" + sep = CACHE_KEY_SEPARATOR + return f"{escape_glob(CacheKey.FORMAT_TAG)}{sep}*{sep}{escaped}{sep}*" diff --git a/fastapi_cachex/routes.py b/fastapi_cachex/routes.py index 7e36813..9823f5a 100644 --- a/fastapi_cachex/routes.py +++ b/fastapi_cachex/routes.py @@ -317,7 +317,7 @@ async def get_cached_records() -> CachedRecordsResponse: """Display currently cached records. Returns every route entry in the cache backend (keys in the - ``method|||host|||path|||query`` format; other keys are skipped) with + ``http:v2|method|host|path|query`` format; other keys are skipped) with its content information and expiry details. Returns: diff --git a/fastapi_cachex/types.py b/fastapi_cachex/types.py index 68ab817..b60c8ed 100644 --- a/fastapi_cachex/types.py +++ b/fastapi_cachex/types.py @@ -9,8 +9,14 @@ from fastapi_cachex.exceptions import CacheXError -# Cache key separator - using ||| to avoid conflicts with port numbers in host (e.g., 127.0.0.1:8000) -CACHE_KEY_SEPARATOR = "|||" +# Separates the components of an HTTP cache key. Every client-controlled +# component is percent-encoded first (see ``escape_key_component``), so a +# single ``|`` is enough; the query string is URL-encoded and never has one. +CACHE_KEY_SEPARATOR = "|" + +# First component of every HTTP cache key (see ``CacheKey``). 0.3.x keys had no +# tag and used ``|||``; the next format change bumps it. +HTTP_KEY_FORMAT_TAG = "http:v2" # Type for custom cache key builder function CacheKeyBuilder = Callable[[Request], str] @@ -24,7 +30,7 @@ def escape_key_component(value: str) -> str: """Percent-encode ``|`` and ``%`` so ``value`` cannot contain the separator. The host header and the decoded URL path are client-controlled and may - contain ``|||``; left as is, one request's components could line up into + contain ``|``; left as is, one request's components could line up into another request's key. Encoding ``%`` as well keeps the mapping reversible, so two different values never share an encoding. """ diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 540ff23..1e38d18 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -21,7 +21,7 @@ private、沒有正數的 ttl,或帶有 Authorization/Session 且未設定 p (設定 cache_authorized 且帶有 Authorization 或 Session:下方照常使用後端, 但每個回應仍以 private 取代 public) ↓ -建立快取鍵:key_builder(預設為 method|||host|||path|||query_params), +建立快取鍵:key_builder(預設為 http:v2|method|host|path|query_params), 再為每個 vary 標頭附加一個 name=value 段 ↓ 讀取後端項目(fail_open 時,後端錯誤視為未命中) @@ -59,13 +59,15 @@ handler 自己送出的 private/no-store Cache-Control 永遠不會被取代 請求抵達時,`@cache` 裝飾器會執行以下步驟: ```python -from fastapi_cachex.types import CACHE_KEY_SEPARATOR # "|||" +from fastapi_cachex import CacheKey from fastapi_cachex.types import escape_key_component -# 快取鍵格式(fastapi_cachex/cache.py 中的 build_cache_key) -cache_key = CACHE_KEY_SEPARATOR.join( +# 快取鍵格式(fastapi_cachex/cache_key.py 中的 CacheKey; +# build_cache_key(request) 即 CacheKey.from_request(request).to_str()) +cache_key = "|".join( [ - request.method, + CacheKey.FORMAT_TAG, # "http:v2" + escape_key_component(request.method), escape_key_component(request.headers.get("host", "unknown")), escape_key_component(request.url.path), query, @@ -73,13 +75,15 @@ cache_key = CACHE_KEY_SEPARATOR.join( ) # 例如: -# GET|||example.com|||/api/users|||page=1&limit=10 -# GET|||api.example.com|||/api/users/123||| +# http:v2|GET|example.com|/api/users|page=1&limit=10 +# http:v2|GET|api.example.com|/api/users/123| ``` -分隔符號使用 `|||` 而不是冒號,是因為 host 本身可能包含連接埠(`127.0.0.1:8000`);若使用冒號,快取鍵就無法可靠地拆分,而 `clear_path()` 需要從快取鍵中取回路徑。 +每個快取鍵都以格式標籤 `http:v2` 開頭。其他格式的鍵(0.3.x 寫入的是沒有標籤的 `GET|||host|||path|||query`)不會與這些鍵衝突,在 Redis 與記憶體後端上,`clear_pattern("http:v2|*")` 會移除這個格式的所有鍵。`CacheKey.parse()` 只會讀取帶有這個標籤的鍵。 -host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25`(`fastapi_cachex/types.py` 中的 `escape_key_component`)。兩者都來自用戶端,其中若出現未編碼的 `|||`,各段就會錯位,使某個請求的快取鍵可能與另一個請求相同。查詢字串本來就經過 URL 編碼。監控路由顯示時會再解碼。 +分隔符號使用 `|` 而不是冒號,是因為 host 本身可能包含連接埠(`127.0.0.1:8000`);若使用冒號,快取鍵就無法可靠地拆分,而 `clear_path()` 需要從快取鍵中取回路徑。 + +方法、host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25`(`fastapi_cachex/types.py` 中的 `escape_key_component`)。host 與路徑來自用戶端,其中若出現未編碼的 `|`,各段就會錯位,使某個請求的快取鍵可能與另一個請求相同。查詢字串本來就經過 URL 編碼,因此不會含有 `|`。監控路由顯示時會再解碼各段。 自訂的 `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:<十六進位摘要>`,因此鍵中不會出現任何權杖。 @@ -242,7 +246,7 @@ If-None-Match: * → 只要資源存在就相符 → 304 ```python # dict[str, CacheItem];CacheItem 包裝 CacheEntry 並記錄其過期時間 { - "GET|||example.com|||/api/users|||": CacheItem( + "http:v2|GET|example.com|/api/users|": CacheItem( value=CacheEntry( fingerprint='W/"abc123"', content=b"...", @@ -289,7 +293,7 @@ Redis 與 Memcached 共用同一套 JSON 編解碼器;若已安裝 `orjson` ### MemcachedBackend {#memcachedbackend} ``` -key: "fastapi_cachex:GET|||example.com|||/api/users|||" +key: "fastapi_cachex:http:v2|GET|example.com|/api/users|" value: 上述的 JSON 文件 # 特性: @@ -314,7 +318,7 @@ value: 上述的 JSON 文件 ### AsyncRedisCacheBackend {#asyncrediscachebackend} ``` -key: "fastapi_cachex:GET|||example.com|||/api/users|||" +key: "fastapi_cachex:http:v2|GET|example.com|/api/users|" value: 上述的 JSON 文件 # 特性: diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index ef5b3e1..5d5d9e8 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -161,9 +161,11 @@ async def report(): 快取鍵以下列格式產生,以避免衝突: ``` -{method}|||{host}|||{path}|||{query_params} +http:v2|{method}|{host}|{path}|{query_params} ``` +`http:v2` 是格式標籤(`CacheKey.FORMAT_TAG`)。之後的鍵格式會使用另一個標籤,因此其鍵不會與這些鍵衝突;在 Redis 與記憶體後端上,`clear_pattern("http:v2|*")` 會移除這個格式的所有 HTTP 快取項目。 + 這可確保: - 不同的 HTTP 方法(GET、POST 等)不共用快取 @@ -184,7 +186,7 @@ async def search(q: str, limit: int = 10): `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 或路徑含有 `|` 或 `%` 的項目會重新快取一次。 +host 與路徑來自用戶端,因此其中的 `|` 與 `%` 會以百分比編碼寫入(`%7C` 與 `%25`)。含有 `|` 的 `Host` 標頭或路徑因此無法讓各段錯位,使某個請求的快取鍵與另一個請求相同。查詢字串本來就經過 URL 編碼。`clear_path()` 接受應用程式看到的路徑(`request.url.path`),並以同樣方式編碼;`clear_pattern()` 比對的是儲存的快取鍵,所以在模式中要把 `|` 寫成 `%7C`。0.3.8 之前兩者都照原樣儲存,因此升級後,host 或路徑含有 `|` 或 `%` 的項目會重新快取一次。 host 仍是用戶端送來的任何值。除非應用程式前方的反向代理或負載平衡器已會拒絕未知的 host,否則請加上 Starlette 的 `TrustedHostMiddleware`,讓偽造的 `Host` 得到 `400`,而不是在快取中塞滿沒有其他人會請求的項目: @@ -201,7 +203,7 @@ app.add_middleware( 需要多一個維度(使用者 ID、租戶、語系)的自訂 `key_builder`,應呼叫 `build_cache_key(request, *components)`,而不是自行重組格式。不傳入任何段時,它回傳的正是預設的鍵;每個段會附加在查詢字串之後: ``` -{method}|||{host}|||{path}|||{query_params}|||{component}|||... +http:v2|{method}|{host}|{path}|{query_params}|{component}|... ``` ```python @@ -214,11 +216,11 @@ def per_tenant_key(request: Request) -> str: return build_cache_key(request, request.state.tenant_id) ``` -段必須是 `str` 或 `int`(`int` 以十進位寫入,因此 `1` 與 `"1"` 是同一個段);其他型別,包括 `None`,都會引發 `TypeError`,避免缺少的 ID 悄悄讓所有這類呼叫者共用同一個 `"None"` 鍵。每個段都與 host 和路徑一樣以百分比編碼,因此含有 `|||` 的值無法讓各段錯位。空字串仍是一個段:`build_cache_key(request, "")` 不等於預設的鍵。 +段必須是 `str` 或 `int`(`int` 以十進位寫入,因此 `1` 與 `"1"` 是同一個段);其他型別,包括 `None`,都會引發 `TypeError`,避免缺少的 ID 悄悄讓所有這類呼叫者共用同一個 `"None"` 鍵。每個段都與 host 和路徑一樣以百分比編碼,因此含有 `|` 的值無法讓各段錯位。空字串仍是一個段:`build_cache_key(request, "")` 不等於預設的鍵。 -由於路徑仍是第三段,`clear_path()` 依然找得到這些鍵:不帶 `include_params` 時,會清除該路徑下查詢字串為空的所有項目,不論其他段為何;帶上它則清除該路徑的所有項目。監控路由會把其他段解碼後列在 `extra_components` 中。`default_key_builder(request)` 就是 `build_cache_key(request)`。 +由於路徑仍在原本的位置,`clear_path()` 依然找得到這些鍵:不帶 `include_params` 時,會清除該路徑下查詢字串為空的所有項目,不論其他段為何;帶上它則清除該路徑的所有項目。監控路由會把其他段解碼後列在 `extra_components` 中。`default_key_builder(request)` 就是 `build_cache_key(request)`。 -`CacheKey` 則是以值的形式表示同一個鍵。`CacheKey.from_request(request, *components)` 建立它,`to_str()` 得到與 `build_cache_key` 相同的字串,`CacheKey.parse(key)` 則把已儲存的鍵解碼為 `method`、`host`、`path`、`query` 與 `extra`;若不是 HTTP 鍵(例如 `CacheManager` 的鍵),則回傳 `None`: +`CacheKey` 則是以值的形式表示同一個鍵。`CacheKey.from_request(request, *components)` 建立它,`to_str()` 得到與 `build_cache_key` 相同的字串,`CacheKey.parse(key)` 則把已儲存的鍵解碼為 `method`、`host`、`path`、`query` 與 `extra`;若不是 HTTP 鍵(例如 `CacheManager` 的鍵,或沒有 `http:v2` 標籤的鍵),則回傳 `None`: ```python from fastapi_cachex import CacheKey @@ -229,7 +231,9 @@ for key in await backend.get_all_keys(): print(parsed.host, parsed.query, parsed.extra) ``` -Redis 與 Memcached 後端還會在每個鍵前面加上自己的前綴(預設為 `fastapi_cachex:`),讓其他應用程式可以共用同一台伺服器;`MemoryBackend` 沒有前綴。`CacheManager`(見[應用層快取](APP_CACHE.md))則使用另一個較簡單、以 `cache:` 為前綴的鍵命名空間,而不是這種以 `|||` 分隔的格式,因為它的鍵與 HTTP 請求無關。 +Redis 與 Memcached 後端還會在每個鍵前面加上自己的前綴(預設為 `fastapi_cachex:`),讓其他應用程式可以共用同一台伺服器;`MemoryBackend` 沒有前綴。`CacheManager`(見[應用層快取](APP_CACHE.md))則使用另一個較簡單、以 `cache:` 為前綴的鍵命名空間,而不是這種以 `|` 分隔的格式,因為它的鍵與 HTTP 請求無關。 + +回傳自行組成、而非由 `build_cache_key()`(或 `CacheKey`)建立之鍵的 `key_builder`,仍可以快取、以 `invalidate()` 使項目失效,也能以 `clear_pattern()` 清除。但這種鍵沒有 `http:v2` 標籤,因此 `clear_path()` 找不到它,監控路由也會略過它。 ### 依請求標頭區分 {#varying-on-request-headers} @@ -242,7 +246,7 @@ async def greeting(request: Request): return {"text": translate("hello", request.headers.get("accept-language"))} ``` -每個列出的標頭都會在鍵中加入一個 `name=value` 段:名稱轉為小寫,值去除前後空白(重複的標頭行以 `,` 串接),缺少的標頭視同空值。這些段與鍵的其他部分一樣經過編碼,並接在 `key_builder` 回傳的鍵之後,因此 `vary` 可以與自訂的 key builder 一起使用:`key_builder` 回傳 `build_cache_key(request, "tenant-1")` 時,鍵為 `GET|||example.com|||/greeting||||||tenant-1|||accept-language=de`。沒有設定 `vary` 的路由,鍵維持不變。 +每個列出的標頭都會在鍵中加入一個 `name=value` 段:名稱轉為小寫,值去除前後空白(重複的標頭行以 `,` 串接),缺少的標頭視同空值。這些段與鍵的其他部分一樣經過編碼,並接在 `key_builder` 回傳的鍵之後,因此 `vary` 可以與自訂的 key builder 一起使用:`key_builder` 回傳 `build_cache_key(request, "tenant-1")` 時,鍵為 `http:v2|GET|example.com|/greeting||tenant-1|accept-language=de`。沒有設定 `vary` 的路由,鍵維持不變。 這些名稱也會加入該路由對 GET 請求的每個回應的 `Vary` 標頭,不論是 200 或 304,也不論是否由後端提供(`private`、`no_store`、繞過後端的 `Authorization` 請求,或未儲存的回應),讓應用程式前方的共用快取也依它們區分。回應已列出的名稱(不分大小寫)不會重複加入,帶有 `Vary: *` 的回應則維持原樣。 @@ -253,7 +257,7 @@ async def greeting(request: Request): 快取鍵並非機密:`get_all_keys()` 會列出它、`/cached-records` 與 `/cached-hits` 監控路由會顯示它,Redis 或 Memcached 的鍵空間也會原樣儲存它。因此對於攜帶憑證的標頭,也就是 `Authorization`、`Proxy-Authorization`、`Cookie` 與 `X-Session-Token`(Session 子系統預設的 `header_name`),不分大小寫,該段存放的是值(依上述方式去除空白並串接)的完整十六進位 SHA-256,而不是值本身: ``` -GET|||example.com|||/me||||||authorization=sha256:3f0a…(64 個十六進位字元) +http:v2|GET|example.com|/me||authorization=sha256:3f0a…(64 個十六進位字元) ``` 同一個權杖永遠得到同一個摘要,因此會命中自己的項目;兩個不同的權杖則得到兩筆項目。缺少或空白的憑證標頭不會雜湊,而是與其他空標頭一樣維持 `authorization=`,讓所有匿名呼叫者共用一筆項目,鍵也仍看得出這是匿名的那一筆。其他標頭(包括以其他名稱設定的 Session 標頭)都維持可讀;若你的標頭帶有機密,請透過 `key_builder`(自行雜湊)而不是 `vary` 以它作為鍵。 @@ -378,8 +382,8 @@ async def clear(cache: CacheBackend) -> None: # ……或連同所有查詢參數的變體一起清除 await cache.clear_path("/api/users", include_params=True) - # 依模式清除:比對整個鍵 method|||host|||path|||query - await cache.clear_pattern("GET|||*|||/api/users/*") + # 依模式清除:比對整個鍵 http:v2|method|host|path|query + await cache.clear_pattern("http:v2|GET|*|/api/users/*") # 你自己組成的鍵(例如 CacheManager 的鍵)可以直接比對 await cache.clear_pattern("cache:user:*") @@ -445,7 +449,7 @@ add_routes( - `GET {prefix}/cached-hits`:列出每筆快取項目,拆分為方法、主機、路徑與查詢,附上 ETag 與到期時間,另外統計有效與已過期的項目數,以及不重複的快取路徑。它不會計算命中次數。 - `GET {prefix}/cached-records`:列出每筆快取紀錄的大小、到期時間、`media_type`(儲存的回應的媒體類型,沒有時為 `null`),以及快取內容前 100 個位元組的預覽。設定 `include_content_preview=False` 時,`content_preview` 為 `null`,不會有任何回應本文離開伺服器;鍵、大小與到期時間仍會回報。`content_type` 一律是 `"bytes"`,只為相容而保留;請改讀 `media_type`。 -兩個路由都只列出路由項目(格式為 `method|||host|||path|||query` 的鍵);`CacheManager`、Session、state 與鎖的鍵都會略過。 +兩個路由都只列出路由項目(格式為 `http:v2|method|host|path|query` 的鍵);`CacheManager`、Session、state 與鎖的鍵都會略過,未使用 `build_cache_key()` 的 `key_builder` 產生的鍵也一樣。 > [!WARNING] > **這些路由本身沒有任何身分驗證。** `include_in_schema=False` 只是讓它們不出現在 OpenAPI 文件中;任何猜到路徑的人都能讀取。`/cached-records` 含有快取內容的預覽(除非設定 `include_content_preview=False`),並會暴露整個路由結構。正式環境中請務必傳入 `dependencies=[Depends(your_auth)]`,或將它們掛載在僅供內部使用的應用程式上。 diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index a0ffd74..2ab1a01 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -250,7 +250,7 @@ CacheManagerProxy.set(CacheManager(lock=True)) 0.4.0 會一次改變所有 HTTP 快取鍵的格式,讓升級只造成一次快取未命中([#271](https://github.com/allen0099/FastAPI-CacheX/issues/271)、[#266](https://github.com/allen0099/FastAPI-CacheX/issues/266)、[#265](https://github.com/allen0099/FastAPI-CacheX/issues/265)、[#269](https://github.com/allen0099/FastAPI-CacheX/issues/269)、[#270](https://github.com/allen0099/FastAPI-CacheX/issues/270)、[#72](https://github.com/allen0099/FastAPI-CacheX/issues/72)): - 分隔符號改為單一的 `|`(`CACHE_KEY_SEPARATOR`)。 -- 鍵以格式標籤開頭,例如 `http:v2|`,讓下一次格式變更可以用模式移除舊鍵。 +- 鍵以格式標籤 `http:v2|`(`CacheKey.FORMAT_TAG`)開頭,讓下一次格式變更可以用模式移除舊鍵:`clear_pattern("http:v2|*")` 會移除這個格式的所有鍵。 - 主機名稱會正規化:轉為小寫,並去除該 scheme 的預設連接埠(`:80`、`:443`)。 - 過長的查詢字串(約超過 200 位元組)會以 `sha256:` 加上十六進位摘要儲存;路徑仍保持可讀。 - 查詢參數會依名稱排序:`sort_query`(0.3.9 起可選用)在 `@cache`、`build_cache_key()` 與 `invalidate()` 中預設為 `True`,因此 `?b=2&a=1` 與 `?a=1&b=2` 共用同一筆項目。 @@ -258,15 +258,15 @@ CacheManagerProxy.set(CacheManager(lock=True)) ```text 修改前:GET|||Example.com:80|||/users/1|||page=2 -修改後:http:v2|GET|example.com|/users/1|page=2 (確切的標籤尚未定案) +修改後:http:v2|GET|example.com|/users/1|page=2 ``` 需要修改的地方: - 直接寫出分隔符號的 `clear_pattern()` 模式(`"GET|||*|||/users/*"`)需要改寫。`clear_path()` 與 `invalidate()` 會自行組出鍵,不需要修改。 -- 呼叫 `build_cache_key()` 或以 `CACHE_KEY_SEPARATOR` 串接的自訂 `key_builder` 會自動跟上;直接寫死 `|||` 的則不會。 +- 呼叫 `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 就能使用。 -- 0.4.0 不會讀取 0.3.x 寫入的項目。這些項目會在 TTL 到期後過期;在 Redis 與記憶體後端上,可以在升級後立即以 `await backend.clear_pattern("*|||*")` 移除。Memcached 無法列舉鍵,只能等它們過期。 +- 0.4.0 不會讀取 0.3.x 寫入的項目。這些項目會在 TTL 到期後過期;在 Redis 與記憶體後端上,可以在升級後立即以 `await backend.clear_pattern("*|||*")` 移除。這個模式會比對任何含有 `|||` 的鍵,因此請先確認你自己的鍵(例如 `CacheManager` 的鍵)都不含它。Memcached 無法列舉鍵,只能等它們過期。 0.3.9 不會警告:0.3.x 無從判斷某個模式或 key builder 是否符合新格式,而執行期唯一的代價只是一次未命中。 @@ -323,7 +323,7 @@ RedisConfig(host="redis") await backend.clear_pattern("fastapi_cachex:GET|||*") # 修改後 -await backend.clear_pattern("GET|||*") # 在 0.4.0 的鍵格式下為 "GET|*" +await backend.clear_pattern("GET|||*") # 在 0.4.0 的鍵格式下為 "http:v2|GET|*" ``` ### delete() 的回傳值 {#backend-delete} diff --git a/tests/backends/test_clear_pattern_contract.py b/tests/backends/test_clear_pattern_contract.py index 0bd4198..2ed4e60 100644 --- a/tests/backends/test_clear_pattern_contract.py +++ b/tests/backends/test_clear_pattern_contract.py @@ -22,10 +22,10 @@ from tests.live_servers import redis_skip_reason KEYS = ( - "GET|||localhost|||/users/1|||", - "GET|||localhost|||/users/2|||", - "POST|||localhost|||/users/1|||", - "GET|||localhost|||/posts/1|||", + "http:v2|GET|localhost|/users/1|", + "http:v2|GET|localhost|/users/2|", + "http:v2|POST|localhost|/users/1|", + "http:v2|GET|localhost|/posts/1|", "cache:user:1", "cache:post:1", ) @@ -75,13 +75,13 @@ async def _populate(backend: BaseCacheBackend) -> None: @pytest.mark.parametrize( ("pattern", "expected_removed"), [ - ("GET|||*|||/users/*", 2), - ("*|||localhost|||/users/*", 3), + ("http:v2|GET|*|/users/*", 2), + ("*|localhost|/users/*", 3), ("cache:*", 2), ("cache:user:*", 1), # `*` is an unrestricted glob on both backends: it spans the separator, # so this reaches the host component too. - ("*|||/users/*", 3), + ("*|/users/*", 3), ], ) async def test_clear_pattern_globs_the_whole_key( @@ -109,9 +109,41 @@ async def test_a_bare_path_pattern_warns_instead_of_clearing_nothing( assert removed == 0 +@pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) +@pytest.mark.parametrize("path", ["/users/*", "/users/1"]) +async def test_the_pattern_the_warning_suggests_clears_the_path( + backend: BaseCacheBackend, path: str +) -> None: + """The whole-key pattern in the warning matches the GET entries it names.""" + await _populate(backend) + await backend.set("http:v2|GET|localhost|/users/1|page=2", CacheEntry("e", b"v")) + + with pytest.warns(RuntimeWarning) as record: + await backend.clear_pattern(path) + suggested = str(record[0].message).rsplit("'", 2)[-2] + + removed = await backend.clear_pattern(suggested) + + assert removed == (3 if path == "/users/*" else 2) + assert "http:v2|POST|localhost|/users/1|" in await backend.get_all_keys() + + +@pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) +async def test_the_0_3_cleanup_pattern_leaves_current_keys( + backend: BaseCacheBackend, +) -> None: + """``clear_pattern("*|||*")`` from the migration guide removes 0.3.x keys only.""" + await _populate(backend) + await backend.set("GET|||localhost|||/users/1|||", CacheEntry("e", b"v")) + await backend.set("GET|||localhost|||/users/1|||page=2", CacheEntry("e", b"v")) + + assert await backend.clear_pattern("*|||*") == 2 + assert sorted(await backend.get_all_keys()) == sorted(KEYS) + + @pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) @pytest.mark.parametrize( - "pattern", ["GET|||*|||/users/*", "cache:user:*", "*", "user:*", "/users/*"] + "pattern", ["http:v2|GET|*|/users/*", "cache:user:*", "*", "user:*", "/users/*"] ) async def test_patterns_that_clear_something_do_not_warn( backend: BaseCacheBackend, pattern: str @@ -151,13 +183,13 @@ async def test_clear_path_finds_paths_with_encoded_characters( """``clear_path`` takes the decoded path and matches the encoded key.""" entry = CacheEntry(fingerprint="etag", content=b"x") # Keys as default_key_builder writes them for "/a|b/100%" and a neighbour. - await backend.set("GET|||h|||/a%7Cb/100%25|||", entry) - await backend.set("GET|||h|||/a%7Cb/100%25|||v=1", entry) - await backend.set("GET|||h|||/a|||b/100%25|||", entry) + await backend.set("http:v2|GET|h|/a%7Cb/100%25|", entry) + await backend.set("http:v2|GET|h|/a%7Cb/100%25|v=1", entry) + await backend.set("http:v2|GET|h|/a|b/100%25|", entry) assert await backend.clear_path("/a|b/100%") == 1 assert await backend.clear_path("/a|b/100%", include_params=True) == 1 - assert await backend.get_all_keys() == ["GET|||h|||/a|||b/100%25|||"] + assert await backend.get_all_keys() == ["http:v2|GET|h|/a|b/100%25|"] @pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) @@ -166,17 +198,17 @@ async def test_clear_path_finds_keys_with_extra_components( ) -> None: """Keys from ``build_cache_key(request, ...)`` are cleared by their path (#264).""" entry = CacheEntry(fingerprint="etag", content=b"x") - await backend.set("GET|||h|||/me|||", entry) - await backend.set("GET|||h|||/me|||||||user-1", entry) - await backend.set("GET|||h|||/me|||||||user-1|||de", entry) - await backend.set("GET|||h|||/me|||page=2|||user-1", entry) + await backend.set("http:v2|GET|h|/me|", entry) + await backend.set("http:v2|GET|h|/me||user-1", entry) + await backend.set("http:v2|GET|h|/me||user-1|de", entry) + await backend.set("http:v2|GET|h|/me|page=2|user-1", entry) # The path appears elsewhere in these keys, but not as the path. - await backend.set("GET|||h|||/other|||||||/me|||", entry) - await backend.set("GET|||/me|||/other|||", entry) + await backend.set("http:v2|GET|h|/other||/me|", entry) + await backend.set("http:v2|GET|/me|/other|", entry) assert await backend.clear_path("/me") == 3 assert await backend.clear_path("/me", include_params=True) == 1 assert sorted(await backend.get_all_keys()) == [ - "GET|||/me|||/other|||", - "GET|||h|||/other|||||||/me|||", + "http:v2|GET|/me|/other|", + "http:v2|GET|h|/other||/me|", ] diff --git a/tests/backends/test_memcached.py b/tests/backends/test_memcached.py index 38f4b03..5d62393 100644 --- a/tests/backends/test_memcached.py +++ b/tests/backends/test_memcached.py @@ -661,18 +661,18 @@ def test_legal_keys_are_left_alone() -> None: """Entries written by earlier versions must stay readable.""" backend = stubbed_backend() - assert backend._make_key("GET|||localhost|||/users/1|||") == ( - "fastapi_cachex:GET|||localhost|||/users/1|||" + assert backend._make_key("http:v2|GET|localhost|/users/1|") == ( + "fastapi_cachex:http:v2|GET|localhost|/users/1|" ) @pytest.mark.parametrize( "key", [ - "GET|||localhost|||/foo bar|||", # ASGI percent-decodes the path - "GET|||localhost|||/café|||", # non-ASCII path - "GET|||localhost|||/x|||\n", # control character - "GET|||localhost|||/search|||q=" + "a" * 400, # over 250 bytes + "http:v2|GET|localhost|/foo bar|", # ASGI percent-decodes the path + "http:v2|GET|localhost|/café|", # non-ASCII path + "http:v2|GET|localhost|/x|\n", # control character + "http:v2|GET|localhost|/search|q=" + "a" * 400, # over 250 bytes ], ) def test_keys_memcached_would_refuse_are_hashed(key: str) -> None: @@ -692,9 +692,9 @@ def test_keys_memcached_would_refuse_are_hashed(key: str) -> None: @pytest.mark.parametrize( "key", [ - "GET|||localhost|||/foo bar|||", - "GET|||localhost|||/café|||", - "GET|||localhost|||/search|||q=" + "a" * 1024, + "http:v2|GET|localhost|/foo bar|", + "http:v2|GET|localhost|/café|", + "http:v2|GET|localhost|/search|q=" + "a" * 1024, ], ) async def test_illegal_keys_round_trip_through_the_server( diff --git a/tests/backends/test_memory.py b/tests/backends/test_memory.py index 9005f6c..426839b 100644 --- a/tests/backends/test_memory.py +++ b/tests/backends/test_memory.py @@ -321,44 +321,44 @@ async def test_memory_backend_cleanup_task_impl( async def test_memory_backend_clear_path(memory_backend: MemoryBackend): - # Set up test data with proper cache key format: method|||host|||path|||query_params + # Set up test data with proper cache key format: http:v2|method|host|path|query # default_key_builder always appends a trailing separator for query_params path = "/test" value1 = CacheEntry(fingerprint="test_etag1", content=b"test_value1") value2 = CacheEntry(fingerprint="test_etag2", content=b"test_value2") value3 = CacheEntry(fingerprint="test_etag3", content=b"test_value3") - # Store data with method|||host|||path||| format (trailing separator, empty params) - await memory_backend.set(f"GET|||localhost|||{path}|||", value1) - await memory_backend.set(f"POST|||localhost|||{path}|||", value2) - await memory_backend.set("GET|||localhost|||/other|||", value3) + # Store data with http:v2|method|host|path| format (trailing separator, empty params) + await memory_backend.set(f"http:v2|GET|localhost|{path}|", value1) + await memory_backend.set(f"http:v2|POST|localhost|{path}|", value2) + await memory_backend.set("http:v2|GET|localhost|/other|", value3) # Test clearing without parameters - should clear entries with exact path cleared = await memory_backend.clear_path(path, include_params=False) assert cleared == 2 # Should clear GET and POST entries with /test path # Verify the other path's data still exists - other_value = await memory_backend.get("GET|||localhost|||/other|||") + other_value = await memory_backend.get("http:v2|GET|localhost|/other|") assert other_value == value3 async def test_memory_backend_clear_pattern(memory_backend: MemoryBackend): - # Set up test data with proper cache key format: method|||host|||path|||query_params + # Set up test data with proper cache key format: http:v2|method|host|path|query value1 = CacheEntry(fingerprint="test_etag1", content=b"test_value1") value2 = CacheEntry(fingerprint="test_etag2", content=b"test_value2") value3 = CacheEntry(fingerprint="test_etag3", content=b"test_value3") - # Store data with method|||host|||path||| format (trailing separator) - await memory_backend.set("GET|||localhost|||/users/123|||", value1) - await memory_backend.set("POST|||localhost|||/users/456|||", value2) - await memory_backend.set("GET|||localhost|||/posts/789|||", value3) + # Store data with http:v2|method|host|path| format (trailing separator) + await memory_backend.set("http:v2|GET|localhost|/users/123|", value1) + await memory_backend.set("http:v2|POST|localhost|/users/456|", value2) + await memory_backend.set("http:v2|GET|localhost|/posts/789|", value3) # The pattern matches whole keys, so the method and host must be written out - cleared = await memory_backend.clear_pattern("*|||localhost|||/users/*") + cleared = await memory_backend.clear_pattern("*|localhost|/users/*") assert cleared == 2 # Should clear both user entries # Verify the posts data still exists - posts_value = await memory_backend.get("GET|||localhost|||/posts/789|||") + posts_value = await memory_backend.get("http:v2|GET|localhost|/posts/789|") assert posts_value == value3 @@ -371,11 +371,11 @@ async def test_memory_backend_clear_pattern_needs_a_whole_key_glob( path component alone. `clear_path` is the method for clearing by path. """ value = CacheEntry(fingerprint="e1", content=b"v1") - await memory_backend.set("GET|||localhost|||/users/123|||", value) + await memory_backend.set("http:v2|GET|localhost|/users/123|", value) with pytest.warns(RuntimeWarning, match="clear_path"): assert await memory_backend.clear_pattern("/users/*") == 0 - assert await memory_backend.get("GET|||localhost|||/users/123|||") == value + assert await memory_backend.get("http:v2|GET|localhost|/users/123|") == value assert await memory_backend.clear_path("/users/123") == 1 @@ -383,7 +383,7 @@ async def test_memory_backend_clear_pattern_needs_a_whole_key_glob( async def test_memory_backend_clear_pattern_separator_less_keys( memory_backend: MemoryBackend, ): - """clear_pattern must also match keys with no method|||host|||path format, + """clear_pattern must also match keys with no http:v2|method|host|path format, e.g. CacheManager ("cache:...") or StateManager ("oauth_state:...") keys. """ value1 = CacheEntry(fingerprint="e1", content=b"v1") @@ -421,25 +421,29 @@ async def test_memory_backend_clear_path_with_colon_in_path( """Paths containing colons (e.g. gitlab:template) must be clearable.""" value = CacheEntry(fingerprint="e", content=b"v") - await memory_backend.set("GET|||localhost:8000|||/gitlab:template|||", value) + await memory_backend.set("http:v2|GET|localhost:8000|/gitlab:template|", value) await memory_backend.set( - "GET|||localhost:8000|||/gitlab:template:projects|||", value + "http:v2|GET|localhost:8000|/gitlab:template:projects|", value + ) + await memory_backend.set( + "http:v2|GET|localhost:8000|/gitlab:template|tag=v1", value ) - await memory_backend.set("GET|||localhost:8000|||/gitlab:template|||tag=v1", value) # include_params=False: only empty-query-param entries cleared = await memory_backend.clear_path("/gitlab:template", include_params=False) assert cleared == 1 assert ( - await memory_backend.get("GET|||localhost:8000|||/gitlab:template|||") is None + await memory_backend.get("http:v2|GET|localhost:8000|/gitlab:template|") is None ) # Sub-path and param variant remain assert ( - await memory_backend.get("GET|||localhost:8000|||/gitlab:template:projects|||") + await memory_backend.get( + "http:v2|GET|localhost:8000|/gitlab:template:projects|" + ) == value ) assert ( - await memory_backend.get("GET|||localhost:8000|||/gitlab:template|||tag=v1") + await memory_backend.get("http:v2|GET|localhost:8000|/gitlab:template|tag=v1") == value ) @@ -447,12 +451,14 @@ async def test_memory_backend_clear_path_with_colon_in_path( cleared = await memory_backend.clear_path("/gitlab:template", include_params=True) assert cleared == 1 # only the param variant was left assert ( - await memory_backend.get("GET|||localhost:8000|||/gitlab:template|||tag=v1") + await memory_backend.get("http:v2|GET|localhost:8000|/gitlab:template|tag=v1") is None ) # Sub-path is a different path, should remain assert ( - await memory_backend.get("GET|||localhost:8000|||/gitlab:template:projects|||") + await memory_backend.get( + "http:v2|GET|localhost:8000|/gitlab:template:projects|" + ) == value ) @@ -463,21 +469,21 @@ async def test_memory_backend_clear_path_include_params( """include_params=True should clear path entries with and without query params.""" value = CacheEntry(fingerprint="e", content=b"v") - await memory_backend.set("GET|||localhost|||/items|||", value) - await memory_backend.set("GET|||localhost|||/items|||page=2", value) - await memory_backend.set("GET|||localhost|||/other|||", value) + await memory_backend.set("http:v2|GET|localhost|/items|", value) + await memory_backend.set("http:v2|GET|localhost|/items|page=2", value) + await memory_backend.set("http:v2|GET|localhost|/other|", value) cleared = await memory_backend.clear_path("/items", include_params=True) assert cleared == 2 - assert await memory_backend.get("GET|||localhost|||/items|||") is None - assert await memory_backend.get("GET|||localhost|||/items|||page=2") is None - assert await memory_backend.get("GET|||localhost|||/other|||") == value + assert await memory_backend.get("http:v2|GET|localhost|/items|") is None + assert await memory_backend.get("http:v2|GET|localhost|/items|page=2") is None + assert await memory_backend.get("http:v2|GET|localhost|/other|") == value async def test_memory_backend_clear_path_direct_key( memory_backend: MemoryBackend, ) -> None: - """clear_path should delete direct keys stored without ||| separators.""" + """clear_path should delete direct keys that are not HTTP keys.""" value = CacheEntry(fingerprint="e", content=b"v") await memory_backend.set("gitlab:template", value) @@ -498,12 +504,12 @@ async def test_memory_backend_clear_path_direct_key_and_separator_key( value = CacheEntry(fingerprint="e", content=b"v") await memory_backend.set("my:path", value) - await memory_backend.set("GET|||localhost|||my:path|||", value) + await memory_backend.set("http:v2|GET|localhost|my:path|", value) cleared = await memory_backend.clear_path("my:path", include_params=False) assert cleared == 2 assert await memory_backend.get("my:path") is None - assert await memory_backend.get("GET|||localhost|||my:path|||") is None + assert await memory_backend.get("http:v2|GET|localhost|my:path|") is None async def test_memory_backend_get_all_keys_empty(memory_backend: MemoryBackend): @@ -516,9 +522,9 @@ async def test_memory_backend_get_all_keys_with_entries( memory_backend: MemoryBackend, ) -> None: """Test get_all_keys returns all cache keys.""" - key1 = "GET|||localhost|||/users" - key2 = "POST|||localhost|||/users" - key3 = "GET|||localhost|||/posts" + key1 = "http:v2|GET|localhost|/users" + key2 = "http:v2|POST|localhost|/users" + key3 = "http:v2|GET|localhost|/posts" value = CacheEntry(fingerprint="test_etag", content=b"test_value") @@ -541,8 +547,8 @@ async def test_memory_backend_get_cache_data_with_entries( memory_backend: MemoryBackend, ) -> None: """Test get_cache_data returns all cache data with expiry.""" - key1 = "GET|||localhost|||/users" - key2 = "POST|||localhost|||/users" + key1 = "http:v2|GET|localhost|/users" + key2 = "http:v2|POST|localhost|/users" value1 = CacheEntry(fingerprint="etag1", content=b"value1") value2 = CacheEntry(fingerprint="etag2", content=b"value2") diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index 747718b..5adeec9 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -130,14 +130,14 @@ async def test_redis_non_utf8_key_under_the_prefix( async_redis_backend: AsyncRedisCacheBackend, ) -> None: """A key that is not UTF-8 is not listed or matched by path, but clear() removes it.""" - foreign = async_redis_backend.key_prefix.encode() + b"GET|||h|||/p|||\xff" + foreign = async_redis_backend.key_prefix.encode() + b"http:v2|GET|h|/p|\xff" entry = CacheEntry(fingerprint="f", content=b"v") await async_redis_backend.client.set(foreign, b"junk") - await async_redis_backend.set("GET|||h|||/p|||", entry) + await async_redis_backend.set("http:v2|GET|h|/p|", entry) - assert await async_redis_backend.get_all_keys() == ["GET|||h|||/p|||"] + assert await async_redis_backend.get_all_keys() == ["http:v2|GET|h|/p|"] assert await async_redis_backend.get_cache_data() == { - "GET|||h|||/p|||": (entry, None) + "http:v2|GET|h|/p|": (entry, None) } assert await async_redis_backend.clear_path("/p", include_params=True) == 1 assert await async_redis_backend.client.exists(foreign) == 1 @@ -160,9 +160,9 @@ async def test_redis_accepts_a_pool_that_decodes_replies() -> None: ) entry = CacheEntry(fingerprint="f", content="\u00e9t\u00e9".encode()) try: - await backend.set("GET|||h|||/p|||", entry) - assert await backend.get("GET|||h|||/p|||") == entry - assert await backend.get_all_keys() == ["GET|||h|||/p|||"] + await backend.set("http:v2|GET|h|/p|", entry) + assert await backend.get("http:v2|GET|h|/p|") == entry + assert await backend.get_all_keys() == ["http:v2|GET|h|/p|"] assert await backend.clear_path("/p") == 1 await backend.set("k", entry) assert await backend.delete_if_equals("k", entry) is True @@ -368,21 +368,21 @@ async def test_clear(self, async_redis_backend: AsyncRedisCacheBackend): @requires_redis async def test_clear_path(self, async_redis_backend: AsyncRedisCacheBackend): value = CacheEntry(fingerprint="test-etag", content=b"test-content") - # Use proper cache key format: method|||host|||path|||query_params + # Use proper cache key format: http:v2|method|host|path|query # Keys without query params end with empty string after last separator - await async_redis_backend.set("GET|||localhost|||/users/1|||", value) - await async_redis_backend.set("POST|||localhost|||/users/1|||param=1", value) - await async_redis_backend.set("GET|||localhost|||/posts/1|||", value) + await async_redis_backend.set("http:v2|GET|localhost|/users/1|", value) + await async_redis_backend.set("http:v2|POST|localhost|/users/1|param=1", value) + await async_redis_backend.set("http:v2|GET|localhost|/posts/1|", value) # Clear all /users/1 entries regardless of method/params cleared = await async_redis_backend.clear_path("/users/1", include_params=True) assert cleared == 2 - assert await async_redis_backend.get("GET|||localhost|||/users/1|||") is None + assert await async_redis_backend.get("http:v2|GET|localhost|/users/1|") is None assert ( - await async_redis_backend.get("POST|||localhost|||/users/1|||param=1") + await async_redis_backend.get("http:v2|POST|localhost|/users/1|param=1") is None ) - assert await async_redis_backend.get("GET|||localhost|||/posts/1|||") == value + assert await async_redis_backend.get("http:v2|GET|localhost|/posts/1|") == value @requires_redis async def test_clear_pattern(self, async_redis_backend: AsyncRedisCacheBackend): @@ -446,13 +446,13 @@ async def test_redis_clear_path_no_matches(async_redis_backend: AsyncRedisCacheB async def test_redis_clear_path_direct_key( async_redis_backend: AsyncRedisCacheBackend, ) -> None: - """clear_path should also delete direct keys stored without ||| separators. + """clear_path should also delete direct keys that are not HTTP keys. Users may store keys like 'gitlab:template' directly via backend.set(), bypassing the default_key_builder format. """ value = CacheEntry(fingerprint="test-etag", content=b"test-content") - # Store direct keys (no method|||host|||path||| format) + # Store direct keys (no http:v2|method|host|path|query format) await async_redis_backend.set("gitlab:template", value) await async_redis_backend.set("gitlab:template:projects", value) await async_redis_backend.set("gitlab:template:by_tag", value) @@ -483,12 +483,12 @@ async def test_redis_clear_path_direct_key_and_separator_key( value = CacheEntry(fingerprint="test-etag", content=b"test-content") # Store a direct key and a separator-format key for the same path await async_redis_backend.set("my:path", value) - await async_redis_backend.set("GET|||localhost|||my:path|||", value) + await async_redis_backend.set("http:v2|GET|localhost|my:path|", value) cleared = await async_redis_backend.clear_path("my:path", include_params=False) assert cleared == 2 assert await async_redis_backend.get("my:path") is None - assert await async_redis_backend.get("GET|||localhost|||my:path|||") is None + assert await async_redis_backend.get("http:v2|GET|localhost|my:path|") is None @requires_redis @@ -569,14 +569,16 @@ async def test_redis_clear_path_exact_without_params( """Cover include_params=False branch: only exact path without params gets removed.""" value = CacheEntry(fingerprint="test-etag", content=b"test-content") # Proper key format always has trailing separator (empty query params) - await async_redis_backend.set("GET|||localhost|||/users/42|||", value) - await async_redis_backend.set("GET|||localhost|||/users/42|||id=42", value) + await async_redis_backend.set("http:v2|GET|localhost|/users/42|", value) + await async_redis_backend.set("http:v2|GET|localhost|/users/42|id=42", value) cleared = await async_redis_backend.clear_path("/users/42", include_params=False) assert cleared == 1 - assert await async_redis_backend.get("GET|||localhost|||/users/42|||") is None + assert await async_redis_backend.get("http:v2|GET|localhost|/users/42|") is None # Param variant should remain - assert await async_redis_backend.get("GET|||localhost|||/users/42|||id=42") == value + assert ( + await async_redis_backend.get("http:v2|GET|localhost|/users/42|id=42") == value + ) @requires_redis @@ -590,12 +592,12 @@ async def test_redis_clear_path_with_colon_in_path( """ value = CacheEntry(fingerprint="test-etag", content=b"test-content") # Simulate keys created by default_key_builder for colon-containing paths - await async_redis_backend.set("GET|||localhost:8000|||/gitlab:template|||", value) + await async_redis_backend.set("http:v2|GET|localhost:8000|/gitlab:template|", value) await async_redis_backend.set( - "GET|||localhost:8000|||/gitlab:template:projects|||", value + "http:v2|GET|localhost:8000|/gitlab:template:projects|", value ) await async_redis_backend.set( - "GET|||localhost:8000|||/gitlab:template|||tag=v1", value + "http:v2|GET|localhost:8000|/gitlab:template|tag=v1", value ) # include_params=False should only clear the exact path (empty query params) @@ -604,19 +606,19 @@ async def test_redis_clear_path_with_colon_in_path( ) assert cleared == 1 assert ( - await async_redis_backend.get("GET|||localhost:8000|||/gitlab:template|||") + await async_redis_backend.get("http:v2|GET|localhost:8000|/gitlab:template|") is None ) # Sub-path and param variant should remain assert ( await async_redis_backend.get( - "GET|||localhost:8000|||/gitlab:template:projects|||" + "http:v2|GET|localhost:8000|/gitlab:template:projects|" ) == value ) assert ( await async_redis_backend.get( - "GET|||localhost:8000|||/gitlab:template|||tag=v1" + "http:v2|GET|localhost:8000|/gitlab:template|tag=v1" ) == value ) @@ -628,14 +630,14 @@ async def test_redis_clear_path_with_colon_in_path( assert cleared == 1 # only the param variant is left assert ( await async_redis_backend.get( - "GET|||localhost:8000|||/gitlab:template|||tag=v1" + "http:v2|GET|localhost:8000|/gitlab:template|tag=v1" ) is None ) # Sub-path should still remain (it's a different path) assert ( await async_redis_backend.get( - "GET|||localhost:8000|||/gitlab:template:projects|||" + "http:v2|GET|localhost:8000|/gitlab:template:projects|" ) == value ) @@ -687,9 +689,9 @@ async def test_redis_get_all_keys_with_entries( # Clear all keys first await async_redis_backend.clear() - key1 = "GET|||localhost|||/users" - key2 = "POST|||localhost|||/users" - key3 = "GET|||localhost|||/posts" + key1 = "http:v2|GET|localhost|/users" + key2 = "http:v2|POST|localhost|/users" + key3 = "http:v2|GET|localhost|/posts" value = CacheEntry(fingerprint="test_etag", content=b"test_value") @@ -728,8 +730,8 @@ async def test_redis_get_cache_data_with_entries( # Clear all keys first await async_redis_backend.clear() - key1 = "GET|||localhost|||/users" - key2 = "POST|||localhost|||/users" + key1 = "http:v2|GET|localhost|/users" + key2 = "http:v2|POST|localhost|/users" value1 = CacheEntry(fingerprint="etag1", content=b"value1") value2 = CacheEntry(fingerprint="etag2", content=b"value2") @@ -899,12 +901,12 @@ async def test_redis_scan_walks_every_page( total = _BATCH_SIZE * 3 for index in range(total): await async_redis_backend.set( - f"page|||localhost|||/item/{index}|||", + f"page|localhost|/item/{index}|", CacheEntry(fingerprint="e", content=b"v"), ) assert len(await async_redis_backend.get_all_keys()) == total - assert await async_redis_backend.clear_pattern("page|||*") == total + assert await async_redis_backend.clear_pattern("page|*") == total assert await async_redis_backend.get_all_keys() == [] @@ -921,7 +923,7 @@ async def test_redis_scan_results_are_deduplicated( total = _BATCH_SIZE * 3 for index in range(total): await async_redis_backend.set( - f"dup|||localhost|||/item/{index}|||", + f"dup|localhost|/item/{index}|", CacheEntry(fingerprint="e", content=b"v"), ) @@ -963,7 +965,7 @@ async def _fill_pages(backend: AsyncRedisCacheBackend) -> int: total = _BATCH_SIZE * 3 for index in range(total): await backend.set( - f"GET|||localhost|||/item|||page={index}", + f"http:v2|GET|localhost|/item|page={index}", CacheEntry(fingerprint="e", content=b"v"), ) return total @@ -974,7 +976,7 @@ async def _fill_pages(backend: AsyncRedisCacheBackend) -> int: "clear", [ lambda backend: backend.clear(), - lambda backend: backend.clear_pattern("GET|||*"), + lambda backend: backend.clear_pattern("http:v2|GET|*"), lambda backend: backend.clear_path("/item", include_params=True), ], ids=["clear", "clear_pattern", "clear_path"], @@ -1029,7 +1031,7 @@ async def scan_with_repeats(*args: Any, **kwargs: Any) -> tuple[int, list[bytes] monkeypatch.setattr(async_redis_backend.client, "scan", scan_with_repeats) - assert await async_redis_backend.clear_pattern("GET|||*") == total + assert await async_redis_backend.clear_pattern("http:v2|GET|*") == total assert len(returned) > total # the repeat was actually injected @@ -1318,17 +1320,17 @@ async def test_redis_clear_path_matches_glob_characters_literally( """A path with glob metacharacters clears its HTTP entries, and only those.""" entry = CacheEntry(fingerprint="etag", content=b"x") path = "/files/[draft]*?\\" - await async_redis_backend.set(f"GET|||host|||{path}|||", entry) - await async_redis_backend.set(f"GET|||host|||{path}|||v=1", entry) + await async_redis_backend.set(f"http:v2|GET|host|{path}|", entry) + await async_redis_backend.set(f"http:v2|GET|host|{path}|v=1", entry) # Keys the unescaped pattern would have caught. - await async_redis_backend.set("GET|||host|||/files/d|||", entry) - await async_redis_backend.set("GET|||host|||/files/[draft]xy\\|||", entry) + await async_redis_backend.set("http:v2|GET|host|/files/d|", entry) + await async_redis_backend.set("http:v2|GET|host|/files/[draft]xy\\|", entry) assert await async_redis_backend.clear_path(path) == 1 assert await async_redis_backend.clear_path(path, include_params=True) == 1 assert sorted(await async_redis_backend.get_all_keys()) == [ - "GET|||host|||/files/[draft]xy\\|||", - "GET|||host|||/files/d|||", + "http:v2|GET|host|/files/[draft]xy\\|", + "http:v2|GET|host|/files/d|", ] diff --git a/tests/session/test_cache_session_bypass.py b/tests/session/test_cache_session_bypass.py index f4f0be4..c483adb 100644 --- a/tests/session/test_cache_session_bypass.py +++ b/tests/session/test_cache_session_bypass.py @@ -29,7 +29,7 @@ def _key(path: str) -> str: """The key `default_key_builder` produces for a TestClient GET.""" - return f"GET|||testserver|||{path}|||" + return f"http:v2|GET|testserver|{path}|" def _app( @@ -209,8 +209,8 @@ def per_user_key(request: Request) -> str: client.get("/whoami", headers=alice) assert client.get("/whoami", headers=bob).json() == {"user": "bob"} - assert await BackendProxy.get().get("/whoami|||alice") is not None - assert await BackendProxy.get().get("/whoami|||bob") is not None + assert await BackendProxy.get().get("/whoami|alice") is not None + assert await BackendProxy.get().get("/whoami|bob") is not None def test_bypass_is_logged( diff --git a/tests/test_build_cache_key.py b/tests/test_build_cache_key.py index 4febb8b..a897ef7 100644 --- a/tests/test_build_cache_key.py +++ b/tests/test_build_cache_key.py @@ -39,9 +39,12 @@ def _request( def _key_before_264(request: Request) -> str: - """``default_key_builder`` as it was written before ``build_cache_key``.""" + """``default_key_builder`` as it was written before ``build_cache_key``. + + Only the format tag (#266) is added. + """ return ( - f"{request.method}{CACHE_KEY_SEPARATOR}" + f"http:v2{CACHE_KEY_SEPARATOR}{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}" @@ -69,14 +72,14 @@ def test_without_components_the_key_is_unchanged(kwargs: dict[str, object]) -> N def test_default_key_is_pinned() -> None: request = _request(path="/a|b", query=b"x=1", host="h:1") - assert build_cache_key(request) == "GET|||h:1|||/a%7Cb|||x=1" + assert build_cache_key(request) == "http:v2|GET|h:1|/a%7Cb|x=1" def test_components_are_appended_after_the_query() -> None: request = _request(query=b"x=1") assert build_cache_key(request, "user-1", 42) == ( - "GET|||example.com|||/items|||x=1|||user-1|||42" + "http:v2|GET|example.com|/items|x=1|user-1|42" ) @@ -89,7 +92,7 @@ def test_int_and_str_components_are_the_same() -> None: def test_empty_component_is_a_component() -> None: request = _request() - assert build_cache_key(request, "") == build_cache_key(request) + "|||" + assert build_cache_key(request, "") == build_cache_key(request) + "|" assert build_cache_key(request, "") != build_cache_key(request) @@ -98,7 +101,7 @@ def test_components_cannot_inject_the_separator() -> None: request = _request() injected = build_cache_key(request, "a|||b") - assert injected == build_cache_key(request) + "|||a%7C%7C%7Cb" + assert injected == build_cache_key(request) + "|a%7C%7C%7Cb" assert injected != build_cache_key(request, "a", "b") assert build_cache_key(request, "100%7C") != build_cache_key(request, "100|") diff --git a/tests/test_cache.py b/tests/test_cache.py index 7e8fdfe..dbbf77b 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -697,7 +697,7 @@ async def legacy_endpoint(): return Response(content=b"new", media_type="text/plain") legacy_client = TestClient(legacy_app) - key = "GET|||testserver|||/legacy|||" + key = "http:v2|GET|testserver|/legacy|" stale = CacheEntry(fingerprint='W/"old"', content=b"old", media_type="text/plain") await backend.set(key, stale) @@ -752,7 +752,7 @@ async def etag_mismatch_endpoint(): # Directly inject a different cache entry into the backend (simulates content change). # We bypass the async interface to avoid cross-event-loop issues in a sync test. # TestClient uses "testserver" as the default Host header. - cache_key = "GET|||testserver|||/etag-mismatch|||" + cache_key = "http:v2|GET|testserver|/etag-mismatch|" new_entry = CacheEntry( fingerprint='W/"newetag"', content=b"changed", media_type="text/plain" ) diff --git a/tests/test_cache_age.py b/tests/test_cache_age.py index 393d23b..6b1c083 100644 --- a/tests/test_cache_age.py +++ b/tests/test_cache_age.py @@ -44,7 +44,7 @@ TTL = 60 START = 1_800_000_000.0 -KEY = "GET|||testserver|||/item|||" +KEY = "http:v2|GET|testserver|/item|" class _Clock: diff --git a/tests/test_cache_backend_failure.py b/tests/test_cache_backend_failure.py index f4fc56b..0875d20 100644 --- a/tests/test_cache_backend_failure.py +++ b/tests/test_cache_backend_failure.py @@ -166,7 +166,7 @@ async def callback() -> dict[str, str]: for r in caplog.records if r.levelno == logging.WARNING and message in r.getMessage() ] - for secret in ("s3cr3t-token", "alice", "tenant-secret", "|||"): + for secret in ("s3cr3t-token", "alice", "tenant-secret", "http:v2|"): assert secret not in warning assert "method=GET" in warning assert "path='/callback'" in warning diff --git a/tests/test_cache_key.py b/tests/test_cache_key.py index c12a21a..714e0b5 100644 --- a/tests/test_cache_key.py +++ b/tests/test_cache_key.py @@ -108,7 +108,7 @@ async def search_endpoint(q: str = ""): def test_cache_key_with_ipv6_address(self): """Test cache key parsing with IPv6 address containing colons.""" # IPv6 addresses contain multiple colons, test that our separator doesn't break this - cache_key = f"GET{CACHE_KEY_SEPARATOR}[::1]:8000{CACHE_KEY_SEPARATOR}/api/data{CACHE_KEY_SEPARATOR}" + cache_key = f"http:v2{CACHE_KEY_SEPARATOR}GET{CACHE_KEY_SEPARATOR}[::1]:8000{CACHE_KEY_SEPARATOR}/api/data{CACHE_KEY_SEPARATOR}" method, host, path, query_params = _key_parts(cache_key) assert method == "GET" @@ -122,7 +122,7 @@ class TestCacheKeyParsing: def test_parse_valid_cache_key(self): """Test parsing a valid cache key.""" - cache_key = f"GET{CACHE_KEY_SEPARATOR}localhost:8000{CACHE_KEY_SEPARATOR}/api/test{CACHE_KEY_SEPARATOR}id=123" + cache_key = f"http:v2{CACHE_KEY_SEPARATOR}GET{CACHE_KEY_SEPARATOR}localhost:8000{CACHE_KEY_SEPARATOR}/api/test{CACHE_KEY_SEPARATOR}id=123" method, host, path, query_params = _key_parts(cache_key) assert method == "GET" @@ -132,7 +132,7 @@ def test_parse_valid_cache_key(self): def test_parse_cache_key_without_query_params(self): """Test parsing cache key without query parameters.""" - cache_key = f"POST{CACHE_KEY_SEPARATOR}127.0.0.1:3000{CACHE_KEY_SEPARATOR}/api/create{CACHE_KEY_SEPARATOR}" + cache_key = f"http:v2{CACHE_KEY_SEPARATOR}POST{CACHE_KEY_SEPARATOR}127.0.0.1:3000{CACHE_KEY_SEPARATOR}/api/create{CACHE_KEY_SEPARATOR}" method, host, path, query_params = _key_parts(cache_key) assert method == "POST" @@ -142,7 +142,7 @@ def test_parse_cache_key_without_query_params(self): def test_parse_cache_key_with_complex_host(self): """Test parsing cache key with complex host (subdomain + port).""" - cache_key = f"GET{CACHE_KEY_SEPARATOR}api.example.com:443{CACHE_KEY_SEPARATOR}/v1/users{CACHE_KEY_SEPARATOR}limit=10" + cache_key = f"http:v2{CACHE_KEY_SEPARATOR}GET{CACHE_KEY_SEPARATOR}api.example.com:443{CACHE_KEY_SEPARATOR}/v1/users{CACHE_KEY_SEPARATOR}limit=10" method, host, path, query_params = _key_parts(cache_key) assert method == "GET" @@ -160,7 +160,7 @@ def test_parse_invalid_cache_key(self): def test_cache_key_separator_constant(self): """Test that CACHE_KEY_SEPARATOR constant is correctly defined.""" - assert CACHE_KEY_SEPARATOR == "|||" + assert CACHE_KEY_SEPARATOR == "|" # Verify it doesn't conflict with common URL characters assert ":" not in CACHE_KEY_SEPARATOR assert "/" not in CACHE_KEY_SEPARATOR @@ -237,10 +237,10 @@ async def data_endpoint(): class TestCacheKeySeparatorInComponents: - """A ``|||`` in the Host header or path must not shift the key components.""" + """A ``|`` in the Host header or path must not shift the key components.""" def test_host_header_cannot_poison_another_path(self) -> None: - """Host ``h|||/p`` + path ``/x`` used to share a key with path ``/p|||/x``.""" + """Host ``h|/p`` + path ``/x`` would share a key with path ``/p|/x`` unescaped.""" app = FastAPI() backend = MemoryBackend() BackendProxy.set(backend) @@ -251,11 +251,11 @@ async def echo(p: str) -> dict[str, str]: return {"p": p} client = TestClient(app) - poisoned = client.get("/x", headers={"host": "testserver|||/p"}) + poisoned = client.get("/x", headers={"host": "testserver|/p"}) assert poisoned.json() == {"p": "x"} - victim = client.get("/p%7C%7C%7C/x") - assert victim.json() == {"p": "p|||/x"} + victim = client.get("/p%7C/x") + assert victim.json() == {"p": "p|/x"} assert len(backend.cache) == 2 def test_percent_is_encoded_so_the_encoding_is_unambiguous(self) -> None: @@ -275,8 +275,8 @@ def key_for(path: str) -> str: } return default_key_builder(Request(scope)) - assert key_for("/a|") == "GET|||h|||/a%7C|||" - assert key_for("/a%7C") == "GET|||h|||/a%257C|||" + assert key_for("/a|") == "http:v2|GET|h|/a%7C|" + assert key_for("/a%7C") == "http:v2|GET|h|/a%257C|" def test_ordinary_keys_are_unchanged(self) -> None: """Only components with ``|`` or ``%`` change, so existing entries still hit.""" @@ -292,7 +292,7 @@ async def items() -> dict[str, str]: client = TestClient(app, base_url="http://127.0.0.1:8000") client.get("/api/items", params={"q": "a|b%c"}) assert list(backend.cache) == [ - "GET|||127.0.0.1:8000|||/api/items|||q=a%7Cb%25c" + "http:v2|GET|127.0.0.1:8000|/api/items|q=a%7Cb%25c" ] def test_escape_round_trips_and_never_contains_the_separator(self) -> None: @@ -308,6 +308,7 @@ def test_monitoring_parser_decodes_host_and_path(self) -> None: """The monitoring routes show the host and path as the client sent them.""" key = CACHE_KEY_SEPARATOR.join( [ + "http:v2", "GET", escape_key_component("evil|||host"), escape_key_component("/p|||/100%"), diff --git a/tests/test_cache_key_type.py b/tests/test_cache_key_type.py index e93664d..2798358 100644 --- a/tests/test_cache_key_type.py +++ b/tests/test_cache_key_type.py @@ -1,6 +1,7 @@ """``CacheKey`` is the one encoder and decoder of HTTP cache keys (#270).""" import dataclasses +import fnmatch import pytest from fastapi import Request @@ -63,7 +64,7 @@ def test_build_cache_key_format_is_pinned() -> None: request = _request("/p|q", b"b=2&a=1", "Host|||x") assert build_cache_key(request, "a|b", 100, sort_query=True) == ( - "GET|||Host%7C%7C%7Cx|||/p%7Cq|||a=1&b=2|||a%7Cb|||100" + "http:v2|GET|Host%7C%7C%7Cx|/p%7Cq|a=1&b=2|a%7Cb|100" ) @@ -72,6 +73,7 @@ def test_to_str_escapes_every_component_but_the_query() -> None: assert key.to_str() == SEP.join( [ + "http:v2", "G%7CT%25", "evil%7C%7C%7Chost", "/p%7C%7C%7C/100%25", @@ -94,19 +96,16 @@ def test_parse_reverses_to_str(key: CacheKey) -> None: assert CacheKey.parse(key.to_str()) == key -def test_parse_reads_a_key_that_ends_at_the_path() -> None: - assert CacheKey.parse(f"GET{SEP}example.com{SEP}/items") == CacheKey( - "GET", "example.com", "/items", "" - ) - - @pytest.mark.parametrize( "key", [ - "cache:user:1", - "oauth_state:abc", - f"GET{SEP}example.com", - f"{SEP}example.com{SEP}/items{SEP}", + pytest.param("cache:user:1", id="cache-manager"), + pytest.param("oauth_state:abc", id="state-manager"), + pytest.param("GET|||example.com|||/items|||", id="0.3.x"), + pytest.param("http:v1|GET|example.com|/items|", id="other-tag"), + pytest.param("GET|example.com|/items|", id="no-tag"), + pytest.param("http:v2|GET|example.com|/items", id="no-query"), + pytest.param("http:v2||example.com|/items|", id="no-method"), ], ) def test_parse_returns_none_for_keys_that_are_not_http_keys(key: str) -> None: @@ -132,7 +131,7 @@ def test_a_method_with_the_separator_is_escaped_not_rejected() -> None: key = build_cache_key(request) - assert key == SEP.join(["A%7C%7C%7CB", "h", "/p", ""]) + assert key == SEP.join(["http:v2", "A%7C%7C%7CB", "h", "/p", ""]) assert CacheKey.parse(key) == CacheKey("A|||B", "h", "/p") @@ -145,5 +144,19 @@ def test_cache_key_is_frozen() -> None: def test_path_glob_matches_the_escaped_path_literally() -> None: assert CacheKey.path_glob("/files/[draft]*?\\|x%") == ( - f"*{SEP}/files/\\[draft\\]\\*\\?\\\\%7Cx%25{SEP}*" + "http:v2|*|/files/\\[draft\\]\\*\\?\\\\%7Cx%25|*" ) + + +def test_format_tag_is_the_first_component() -> None: + assert CacheKey.FORMAT_TAG == "http:v2" + assert CacheKey("GET", "h", "/").to_str() == "http:v2|GET|h|/|" + + +def test_path_glob_matches_only_tagged_keys_for_the_path() -> None: + glob = CacheKey.path_glob("/me") + + assert fnmatch.fnmatchcase("http:v2|GET|h|/me|", glob) + assert fnmatch.fnmatchcase("http:v2|GET|h|/me|q=1|user", glob) + assert not fnmatch.fnmatchcase("GET|||h|||/me|||", glob) + assert not fnmatch.fnmatchcase("http:v1|GET|h|/me|", glob) diff --git a/tests/test_cache_status_headers.py b/tests/test_cache_status_headers.py index 9ef0471..10c8454 100644 --- a/tests/test_cache_status_headers.py +++ b/tests/test_cache_status_headers.py @@ -26,7 +26,7 @@ def _key(path: str) -> str: """The key `default_key_builder` produces for a TestClient GET.""" - return f"GET|||testserver|||{path}|||" + return f"http:v2|GET|testserver|{path}|" def test_returned_error_is_not_cached_and_keeps_its_status(): diff --git a/tests/test_cache_unshareable.py b/tests/test_cache_unshareable.py index ce4fdf1..fa5d0f6 100644 --- a/tests/test_cache_unshareable.py +++ b/tests/test_cache_unshareable.py @@ -21,7 +21,7 @@ def _key(path: str) -> str: """The key `default_key_builder` produces for a TestClient GET.""" - return f"GET|||testserver|||{path}|||" + return f"http:v2|GET|testserver|{path}|" async def test_issue_repro_private_no_store_with_authorization(): @@ -226,7 +226,7 @@ async def dashboard(request: Request): assert alice_hit.json() == {"for": "Bearer alice", "n": 1} assert bob.json() == {"for": "Bearer bob", "n": 2} - assert await BackendProxy.get().get("/dashboard|||Bearer alice") is not None + assert await BackendProxy.get().get("/dashboard|Bearer alice") is not None async def test_unshareable_render_leaves_an_existing_entry_alone(): diff --git a/tests/test_cache_vary.py b/tests/test_cache_vary.py index 4aed291..7dafde5 100644 --- a/tests/test_cache_vary.py +++ b/tests/test_cache_vary.py @@ -19,7 +19,7 @@ from fastapi_cachex.exceptions import CacheXError from fastapi_cachex.proxy import BackendProxy -BASE_KEY = "GET|||testserver|||/greet|||" +BASE_KEY = "http:v2|GET|testserver|/greet|" def _app(**cache_kwargs: Any) -> tuple[TestClient, dict[str, int]]: @@ -47,8 +47,8 @@ async def test_each_header_value_gets_its_own_entry() -> None: assert de_again.json() == {"lang": "de", "n": 1} assert calls["n"] == 2 assert sorted(await BackendProxy.get().get_all_keys()) == [ - f"{BASE_KEY}|||accept-language=de", - f"{BASE_KEY}|||accept-language=en", + f"{BASE_KEY}|accept-language=de", + f"{BASE_KEY}|accept-language=en", ] @@ -75,8 +75,8 @@ async def test_values_are_trimmed_joined_and_escaped() -> None: ) assert sorted(await BackendProxy.get().get_all_keys()) == [ - f"{BASE_KEY}|||accept-language=de|||x-variant=", - f"{BASE_KEY}|||accept-language=fr,en|||x-variant=a%7C%7C%7Cb", + f"{BASE_KEY}|accept-language=de|x-variant=", + f"{BASE_KEY}|accept-language=fr,en|x-variant=a%7C%7C%7Cb", ] @@ -87,7 +87,7 @@ async def test_missing_and_empty_headers_share_an_entry() -> None: client.get("/greet", headers={"Accept-Language": ""}) assert calls["n"] == 1 - assert await BackendProxy.get().get_all_keys() == [f"{BASE_KEY}|||accept-language="] + assert await BackendProxy.get().get_all_keys() == [f"{BASE_KEY}|accept-language="] async def test_vary_components_follow_a_custom_key_builder() -> None: @@ -98,7 +98,7 @@ def per_tenant(request: Request) -> str: client.get("/greet", headers={"Accept-Language": "de"}) assert await BackendProxy.get().get_all_keys() == [ - f"{BASE_KEY}|||tenant-1|||accept-language=de" + f"{BASE_KEY}|tenant-1|accept-language=de" ] @@ -136,9 +136,7 @@ async def reset(request: Request) -> dict[str, bool]: result = client.post("/greet", headers={"Accept-Language": "de"}).json() assert result == {"without_vary": False, "with_vary": True} - assert await BackendProxy.get().get_all_keys() == [ - f"{BASE_KEY}|||accept-language=en" - ] + assert await BackendProxy.get().get_all_keys() == [f"{BASE_KEY}|accept-language=en"] def test_vary_header_on_miss_hit_and_304() -> None: @@ -282,7 +280,7 @@ async def test_invalid_vary_is_rejected_by_invalidate() -> None: TOKEN_A = "Bearer secret-token-a" TOKEN_B = "Bearer secret-token-b" -ME_KEY = "GET|||testserver|||/me|||" +ME_KEY = "http:v2|GET|testserver|/me|" def _digest(value: str) -> str: @@ -323,8 +321,8 @@ async def test_authorization_value_is_hashed_everywhere_the_key_shows() -> None: keys = await BackendProxy.get().get_all_keys() assert sorted(keys) == sorted( [ - f"{ME_KEY}|||authorization={_digest(TOKEN_A)}", - f"{ME_KEY}|||authorization={_digest(TOKEN_B)}", + f"{ME_KEY}|authorization={_digest(TOKEN_A)}", + f"{ME_KEY}|authorization={_digest(TOKEN_B)}", ] ) records = client.get("/cache/cached-records").text @@ -351,7 +349,7 @@ async def test_credential_headers_are_hashed_in_any_case( client.get("/me", headers={header.upper(): " s3cret "}) assert await BackendProxy.get().get_all_keys() == [ - f"{ME_KEY}|||{header}={_digest('s3cret')}" + f"{ME_KEY}|{header}={_digest('s3cret')}" ] @@ -368,7 +366,7 @@ async def test_cookie_is_hashed_with_repeated_lines_joined() -> None: client.get("/me", headers=[("Cookie", "sid=abc"), ("Cookie", " theme=dark ")]) assert await BackendProxy.get().get_all_keys() == [ - f"{ME_KEY}|||cookie={_digest('sid=abc,theme=dark')}" + f"{ME_KEY}|cookie={_digest('sid=abc,theme=dark')}" ] @@ -379,7 +377,7 @@ async def test_missing_or_empty_credential_header_gives_the_empty_component() -> client.get("/me", headers={"Authorization": " "}) assert calls["n"] == 1 - assert await BackendProxy.get().get_all_keys() == [f"{ME_KEY}|||authorization="] + assert await BackendProxy.get().get_all_keys() == [f"{ME_KEY}|authorization="] async def test_authorization_in_vary_still_bypasses_without_opt_in() -> None: @@ -390,7 +388,7 @@ async def test_authorization_in_vary_still_bypasses_without_opt_in() -> None: client.get("/me") assert calls["n"] == 3 - assert await BackendProxy.get().get_all_keys() == [f"{ME_KEY}|||authorization="] + assert await BackendProxy.get().get_all_keys() == [f"{ME_KEY}|authorization="] async def test_non_credential_headers_stay_readable() -> None: @@ -399,7 +397,7 @@ async def test_non_credential_headers_stay_readable() -> None: client.get("/me", headers={"X-Tenant": "acme", "Authorization": TOKEN_A}) assert await BackendProxy.get().get_all_keys() == [ - f"{ME_KEY}|||x-tenant=acme|||authorization={_digest(TOKEN_A)}" + f"{ME_KEY}|x-tenant=acme|authorization={_digest(TOKEN_A)}" ] @@ -414,7 +412,7 @@ async def test_invalidate_deletes_the_hashed_variant() -> None: assert deleted == {"deleted": True} assert again == {"deleted": False} assert await BackendProxy.get().get_all_keys() == [ - f"{ME_KEY}|||authorization={_digest(TOKEN_B)}" + f"{ME_KEY}|authorization={_digest(TOKEN_B)}" ] diff --git a/tests/test_custom_cache_key.py b/tests/test_custom_cache_key.py index 72a9a2b..92e510d 100644 --- a/tests/test_custom_cache_key.py +++ b/tests/test_custom_cache_key.py @@ -177,8 +177,8 @@ def test_default_key_builder_function() -> None: # Generate cache key cache_key = default_key_builder(mock_request) - # Verify format: method|||host|||path|||query_params - expected = "GET|||example.com|||/api/items|||page=1&limit=10" + # Verify format: http:v2|method|host|path|query + expected = "http:v2|GET|example.com|/api/items|page=1&limit=10" assert cache_key == expected @@ -197,5 +197,5 @@ def test_default_key_builder_without_host() -> None: cache_key = default_key_builder(mock_request) # Should use 'unknown' as fallback for host - expected = "GET|||unknown|||/api/items|||" + expected = "http:v2|GET|unknown|/api/items|" assert cache_key == expected diff --git a/tests/test_routes.py b/tests/test_routes.py index a9a98c2..3fe4415 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -312,7 +312,7 @@ async def text_endpoint(): def test_cached_records_media_type_null_when_unset(self, app, client, setup_cache): """An entry stored without a media type reports ``null``.""" add_routes(app, dependencies=[]) - setup_cache.cache["GET|||h|||/raw|||"] = CacheItem( + setup_cache.cache["http:v2|GET|h|/raw|"] = CacheItem( value=CacheEntry(fingerprint="e", content=b"x"), expiry=None ) @@ -610,7 +610,7 @@ def test_cached_hits_shows_expired_entry(self, app, client, setup_cache): add_routes(app, dependencies=[]) # TestClient sends Host: testserver by default - cache_key = "GET|||testserver|||/expired-route|||" + cache_key = "http:v2|GET|testserver|/expired-route|" expired_entry = CacheEntry( fingerprint='W/"expiredtag"', content=b"old data", media_type="text/plain" ) @@ -631,7 +631,7 @@ def test_cached_records_shows_expired_entry(self, app, client, setup_cache): """/cached-records marks is_expired=True for entries whose TTL has passed.""" add_routes(app, dependencies=[]) - cache_key = "GET|||testserver|||/expired-data|||" + cache_key = "http:v2|GET|testserver|/expired-data|" expired_entry = CacheEntry( fingerprint='W/"expireddata"', content=b"stale", media_type="text/plain" ) @@ -653,12 +653,12 @@ class TestMonitoringEdgeCases: """Entries that are not route responses, and a proxy with no backend at all.""" def test_non_route_keys_are_skipped(self, app, client, setup_cache): - """A CacheManager/state key has no method|||host|||path shape and must not be listed.""" + """A CacheManager/state key has no http:v2|method|host|path shape and must not be listed.""" add_routes(app, dependencies=[]) setup_cache.cache["cache:plain-value"] = CacheItem( value=CacheEntry(fingerprint="x", content=b"1"), expiry=None ) - setup_cache.cache["GET|||testserver|||/route|||"] = CacheItem( + setup_cache.cache["http:v2|GET|testserver|/route|"] = CacheItem( value=CacheEntry(fingerprint="y", content=b"2"), expiry=None )