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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changelog.d/125.removed.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|*")`.
6 changes: 6 additions & 0 deletions changelog.d/266.changed.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions changelog.d/271.changed.md
Original file line number Diff line number Diff line change
@@ -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).
40 changes: 24 additions & 16 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -63,33 +63,41 @@ 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,
]
)

# 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
Expand Down Expand Up @@ -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"...",
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
36 changes: 23 additions & 13 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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:*")

Expand Down Expand Up @@ -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`
Expand Down
10 changes: 5 additions & 5 deletions docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,23 +251,23 @@ 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.
- One public `CacheKey` type builds, encodes and parses keys. `CACHE_KEY_MIN_PARTS`, `CACHE_KEY_MAX_SPLIT` and `CACHE_KEY_MAX_PARTS` are removed from `fastapi_cachex.routes`; read a key's components with `CacheKey.parse(key)` instead.

```text
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.

Expand Down Expand Up @@ -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}
Expand Down
19 changes: 11 additions & 8 deletions fastapi_cachex/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions fastapi_cachex/backends/memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading
Loading