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
10 changes: 10 additions & 0 deletions changelog.d/72.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**Query parameters are sorted by name in HTTP cache keys by default.**
`sort_query` defaults to on in `@cache`, `build_cache_key()`,
`CacheKey.from_request()` and `invalidate()`, so `?b=2&a=1` and `?a=1&b=2`
share one entry; repeated values of one name keep the order sent. A handler
whose response depends on the query order as sent should set
`@cache(sort_query=False)` and pass the same to `invalidate()`. Passing
`sort_query` together with a custom `key_builder` now raises `CacheXError`,
`False` included; call `build_cache_key(request, ..., sort_query=False)` in the
builder instead. See the
[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#cache-keys).
19 changes: 9 additions & 10 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ cache_key = "|".join(
)

# For example:
# http:v2|GET|example.com|/api/users|page=1&limit=10
# http:v2|GET|example.com|/api/users|limit=10&page=1 (for ?page=1&limit=10)
# http:v2|GET|api.example.com|/api/users/123|
```

Expand Down Expand Up @@ -110,11 +110,10 @@ For the credential headers `Authorization`, `Proxy-Authorization`, `Cookie`
and `X-Session-Token` a non-empty value is written as `sha256:<hex digest>`,
so no token appears in the key.

Query parameters are joined in the order the request sent them
(`str(request.query_params)`) and are **not sorted** by default, so
`?page=1&limit=10` and `?limit=10&page=1` are two separate cache entries. To
treat them as one, set `@cache(sort_query=True)`, which orders the parameters
by name first (see [Cache keys](HTTP_CACHING.md#cache-keys)). A query longer
Query parameters are **sorted** by name (a stable sort: repeated values of one
name keep the order sent), so `?page=1&limit=10` and `?limit=10&page=1` share
one cache entry. `@cache(sort_query=False)` keeps the order the request sent
them instead (see [Cache keys](HTTP_CACHING.md#cache-keys)). A query longer
than 200 bytes is then replaced by `sha256:<hex digest>`, so the query part of
the key stays bounded.

Expand Down Expand Up @@ -155,7 +154,7 @@ Arguments are validated when the decorator is applied, and a `CacheXError` is
raised if `public` and `private` are both set, if only one of `stale` /
`stale_ttl` is given, if `ttl` is not an `int`, is negative or is larger than
`MAX_TTL`, if `vary` is not a list of header field names, if `sort_query` is
not a `bool` or is combined with a custom `key_builder`, or if `key_builder` is
not a `bool` or is passed with a custom `key_builder`, or if `key_builder` is
an `async` callable.

The header value is built once per decorated route:
Expand Down Expand Up @@ -555,9 +554,9 @@ without reading or writing the cache and without adding a `Cache-Control`
header.

**Q: Why are there several cache entries for the same endpoint?**
A: Because the cache key includes the query parameters, and by default they are
**not sorted**. `/users?page=1` and `/users?page=2` are different entries, and
so are `?a=1&b=2` and `?b=2&a=1` unless the route sets `sort_query=True`.
A: Because the cache key includes the query parameters. `/users?page=1` and
`/users?page=2` are different entries, and so are `?a=1&b=2` and `?b=2&a=1` if
the route sets `sort_query=False`.

**Q: How does MemoryBackend work across multiple processes?**
A: It doesn't. Each process has its own cache; use Redis in production.
Expand Down
61 changes: 32 additions & 29 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,43 +300,45 @@ This ensures that:
- Different query parameters get separate cache entries
- The same endpoint with different parameters can be cached independently

By default query parameters are taken in the order the client sent them, so
`?a=1&b=2` and `?b=2&a=1` are two distinct cache entries for the same logical
request. Set `sort_query=True` to share one entry between them:
Query parameters are ordered by name before the key is built, so `?a=1&b=2`
and `?b=2&a=1` share one entry. The sort is stable: repeated values of one name
keep the order the client sent, because a handler reading `tag: list[str]` sees them in that order, so `?tag=b&tag=a` and
`?tag=a&tag=b` stay two entries. Names are compared decoded (`%61` sorts as
`a`, which is how the key already writes it), and each name and value is encoded
exactly as in the unsorted key, so only the order changes: a query already in
order gets the same key sorted or not. What the key already treats
as equal stays equal (`?a` and `?a=`, an empty segment from `&&`), and nothing
else is merged. Sorting is the default since 0.4.0
([#72](https://github.com/allen0099/FastAPI-CacheX/issues/72), see
[Migrating to 0.4.0](MIGRATING_0_4.md#cache-keys)).

A handler whose response depends on the query order as sent, such as a self or
pagination link copied from `request.url`, should turn it off, or the first
caller's order is cached and served to callers who sent another:

```python
@app.get("/search")
@cache(ttl=60, sort_query=True)
async def search(q: str, limit: int = 10):
return await run_search(q, limit)
@cache(ttl=60, sort_query=False)
async def search(request: Request, q: str, limit: int = 10):
return {"self": str(request.url), "items": await run_search(q, limit)}
```

The parameters are then ordered by name before the key is built. The sort is
stable: repeated values of one name keep the order the client sent, because a
handler reading `tag: list[str]` sees them in that order, so `?tag=b&tag=a` and
`?tag=a&tag=b` stay two entries. Names are compared decoded (`%61` sorts as
`a`, which is how the key already writes it), and each name and value is encoded
exactly as in the unsorted key, so only the order changes: a query already in
order gets the same key with or without the flag. What the key already treats
as equal stays equal (`?a` and `?a=`, an empty segment from `&&`), and nothing
else is merged. The default is `False`, which leaves every existing key
unchanged. 0.4.0 makes `True` the default, together with its other cache key
changes ([#72](https://github.com/allen0099/FastAPI-CacheX/issues/72), see
[Migrating to 0.4.0](MIGRATING_0_4.md#cache-keys)). A handler that depends on the
query order as sent can keep `sort_query=False`.

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

`sort_query` applies to the default key builder only. `build_cache_key()` sorts
too, so a custom `key_builder` that calls it sorts without being told; passing
`sort_query` to `@cache` together with a custom `key_builder` raises
`CacheXError` when the decorator is applied. Call
`build_cache_key(request, ..., sort_query=False)` inside the builder to keep the
order as sent.

A query string longer than 200 bytes, as encoded in the key, is stored as
`sha256:` and its 64-digit hex digest instead, so a client cannot make the
query part of the key arbitrarily long. (Memcached still hashes a whole key over
250 bytes, prefix included; a query just under the threshold with a long host
or path can get there.) The digest is taken after sorting, so `sort_query` still
merges reordered long queries. The path stays readable, so `clear_path()` still
or path can get there.) The digest is taken after sorting, so reordered long
queries still share one entry. The path stays readable, so `clear_path()` still
finds the entry (with `include_params=True`, since the query is not empty), and
the monitoring routes show the digest as `query_params`. A query as sent never
looks like one: the key writes `:` as `%3A`.
Expand Down Expand Up @@ -726,16 +728,17 @@ async def update_item(item_id: int, request: Request):
return {"invalidated": await invalidate(StarletteRequest(scope))}
```

`invalidate(request, key_builder=None, vary=None, *, sort_query=False)` returns `True` when an entry existed and
`invalidate(request, key_builder=None, vary=None, *, sort_query=None)` returns `True` when an entry existed and
was removed, `False` otherwise, including when no backend is configured. An
error from the backend itself is raised to the caller (see
[When the backend fails](#when-the-backend-fails)). The request you hand it must produce the cached route's key:
same method, host, path and query string. If the cached route uses a custom
`key_builder`, `vary` or `sort_query`, pass the same here, or the key will not
match: `invalidate()` cannot read them from the route. With `vary` only the
variant selected by the request's own header values is deleted, and
`clear_path()` removes all of them. With `sort_query=True` the request's query
is sorted the same way, so `?b=2&a=1` deletes the entry stored for `?a=1&b=2`.
`clear_path()` removes all of them. By default the request's query is sorted
as `@cache` sorts it, so `?b=2&a=1` deletes the entry stored for `?a=1&b=2`;
for a route with `sort_query=False` pass `sort_query=False` here too.

## Monitoring routes

Expand Down
8 changes: 4 additions & 4 deletions docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ CacheManagerProxy.set(CacheManager(lock=True))
- Keys start with the format tag `http:v2|` (`CacheKey.FORMAT_TAG`), so the next format change can remove old keys by pattern: `clear_pattern("http:v2|*")` removes every key of this format.
- The host is normalised: lower-cased, and an empty port or the scheme's default one (`:80` on http, `:443` on https) dropped.
- A query string over 200 bytes (as encoded in the key) is stored as `sha256:` and its hex digest; the path stays readable, and the monitoring routes show the digest.
- Query parameters are sorted by name: `sort_query` (opt-in since 0.3.9) defaults to `True` in `@cache`, `build_cache_key()` and `invalidate()`, so `?b=2&a=1` and `?a=1&b=2` share one entry.
- Query parameters are sorted by name by default in `@cache`, `invalidate()`, `build_cache_key()` and `CacheKey.from_request()` (`sort_query`, opt-in since 0.3.9), so `?b=2&a=1` and `?a=1&b=2` share one entry.
- One public `CacheKey` type builds, encodes and parses keys. `CACHE_KEY_MIN_PARTS`, `CACHE_KEY_MAX_SPLIT` and `CACHE_KEY_MAX_PARTS` are removed from `fastapi_cachex.routes`; read a key's components with `CacheKey.parse(key)` instead.

```text
Expand All @@ -265,11 +265,11 @@ After: http:v2|GET|example.com|/users/1|page=2
What to change:

- `clear_pattern()` patterns that spell out the separator (`"GET|||*|||/users/*"`) need rewriting, and so do patterns that name a host in upper case or with a default port. `clear_path()` and `invalidate()` build the key themselves and need nothing.
- A custom `key_builder` that calls `build_cache_key()` follows automatically. One that builds the key itself (joining with `CACHE_KEY_SEPARATOR` or hard-coding `|||`) still caches and still works with `invalidate()` and `clear_pattern()`, but its keys lack the `http:v2` tag, so `clear_path()` no longer finds them and the monitoring routes no longer list them. Switch it to `build_cache_key(request, *components)` to keep both.
- A handler whose response depends on the order of the query string as sent, such as a self or pagination link copied from `request.url` or a signature over the raw query, should set `@cache(sort_query=False)`. Otherwise the first caller's order is cached and served to callers who sent another. `sort_query=False` works on 0.3.9 already.
- A custom `key_builder` that calls `build_cache_key()` follows automatically, sorting included; pass `sort_query=False` to `build_cache_key()` to keep the order as sent. `@cache` and `invalidate()` now reject `sort_query` passed together with a custom `key_builder`, `False` included (0.3.9 accepted `False` there, where it did nothing): drop it, the builder decides. One that builds the key itself (joining with `CACHE_KEY_SEPARATOR` or hard-coding `|||`) still caches and still works with `invalidate()` and `clear_pattern()`, but its keys lack the `http:v2` tag, so `clear_path()` no longer finds them and the monitoring routes no longer list them. Switch it to `build_cache_key(request, *components)` to keep both.
- A handler whose response depends on the order of the query string as sent, such as a self or pagination link copied from `request.url` or a signature over the raw query, should set `@cache(sort_query=False)`, and pass `sort_query=False` to `invalidate()` for that route. Otherwise the first caller's order is cached and served to callers who sent another. `sort_query=False` works on 0.3.9 already.
- Entries written by 0.3.x are not read by 0.4.0. They expire on their TTL; on Redis and memory you can remove them right after the upgrade with `await backend.clear_pattern("*|||*")`. That pattern matches any key containing `|||`, so check first that none of your own keys (a `CacheManager` key, say) does. Memcached cannot enumerate keys, so there they just expire.

0.3.9 does not warn: nothing in 0.3.x can tell whether a pattern or key builder will match the new format, and the only runtime cost is the one-off miss.
0.3.9 does not warn: nothing in 0.3.x can tell whether a pattern or key builder will match the new format, and the only runtime cost is the one-off miss. It does not warn about `sort_query` passed with a custom `key_builder` either, but that raises `CacheXError` when the decorator is applied, usually at import, not at request time.

### Repeated headers {#cache-entry-headers}

Expand Down
Loading
Loading