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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Use `uv` for everything (`uv sync --group dev --all-extras`, `uv run ...`).
## Invariants that span modules

- HTTP cache keys are `http:v2|method|host|path|query` (`HTTP_KEY_FORMAT_TAG`, `CACHE_KEY_SEPARATOR = "|"`), built and parsed by `CacheKey` in `cache_key.py`. Method, host, path and extra components go through `escape_key_component` so client input cannot inject the separator; a query over 200 bytes becomes `sha256:<hex>`. A format change bumps the tag. Anything that builds or parses keys (`clear_path`, `routes.py`, backends) must go through `CacheKey`.
- `@cache` fails open by default: a backend error is logged and treated as a miss, or the response is served unstored. Only GET is cached.
- `@cache` fails open by default: a backend error is logged and treated as a miss, or the response is served unstored. Only GET is stored; HEAD reads the GET entry (key built as GET) and is never stored.
- Backend lookup: `@cache` and the `CacheBackend` / `AppCache` dependencies fall back to a `MemoryBackend` when none is set. `CacheLock`, `StateManager` and a directly built `CacheManager(...)` call `BackendProxy.get()` and raise; the monitoring routes and `invalidate()` treat a missing backend as empty. The proxies' lazy creation goes through `ProxyBase.get_or_create` (per-class lock; sync dependencies run in threads).
- Memcached cannot enumerate keys. `clear_pattern`, `get_all_keys` and every `CacheManager.clear*` are no-ops with a `RuntimeWarning`, while `MemcachedBackend.clear()` issues `flush_all` and wipes the whole server.
- The atomic primitives on `BaseCacheBackend` (`increment`, `get_and_delete`, `set_if_absent`, `delete_if_equals`, `expire_if_equals`, `delete_many`) have non-atomic fallbacks. Every built-in backend must override them atomically; see "Atomic backend primitives" in `docs/BACKENDS.md`. `tests/backends/*_contract.py` covers TTL validation, counters and `clear_pattern`; the other primitives are tested in each backend's own test file, so a change needs all three.
Expand Down
9 changes: 9 additions & 0 deletions changelog.d/253.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
**`@cache` answers HEAD requests from the cached GET response.** On a route
that accepts HEAD (`@app.api_route(..., methods=["GET", "HEAD"])`), a HEAD
request reads the entry a GET stored under the same key, so health and link
checkers no longer run the handler on a hit; a matching `If-None-Match` gets a
304. A custom `key_builder` sees the request with method `GET`. On a miss the
handler runs and its response gets the same `Cache-Control` and `Vary`
handling as a GET, with an `ETag` of what it rendered, but it is not stored.
Routes that already accepted HEAD get this without any change, and skip the
handler on a hit.
26 changes: 15 additions & 11 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ handler and adding its dependencies' headers).
```
HTTP request arrives
↓
@cache decorator intercepts it (only GET goes through the cache; every other
method runs the handler directly and gets no Cache-Control header)
@cache decorator intercepts it (only GET and HEAD go through the cache, HEAD
reading the GET entry; every other method runs the handler directly and gets
no Cache-Control header)
↓
no-store? ── yes → run the handler, neither read nor write the cache,
│ respond with Cache-Control: no-store
Expand Down Expand Up @@ -65,7 +66,7 @@ Cached entry exists and no-cache is off?
Attach Cache-Control to the response (non-2xx responses are returned without it,
a handler's own private/no-store Cache-Control is never replaced, and a
Set-Cookie response gets private instead of public); with vary, add the
names to Vary on every GET response
names to Vary on every GET or HEAD response
```

## Detailed steps
Expand Down Expand Up @@ -131,8 +132,8 @@ the key stays bounded.

The key format keeps each dimension cached independently:

- **Method isolation**: GET and POST do not share a cache (and currently only GET
enters the cache flow at all)
- **Method isolation**: GET and POST do not share a cache (and only GET and HEAD
enter the cache flow at all; HEAD uses the GET key and is never stored)
- **Host isolation**: `example.com` and `api.example.com` are cached separately;
`Example.com` and `example.com:80` (on http) are `example.com`
- **Path isolation**: each endpoint has its own entries
Expand Down Expand Up @@ -241,7 +242,7 @@ warning that the cache is per process.
**Decision logic** (the `cache.py` wrapper, in order):

```python
if request.method != "GET":
if request.method not in ("GET", "HEAD"):
return await handler() # no cache, no Cache-Control

if no_store:
Expand All @@ -255,6 +256,7 @@ if bypass or (credential and not cache_authorized):
response, etag = await render() # backend neither read nor written
return not_modified(...) if etag_matches(client_etag, etag) else response

# HEAD: key_builder sees the request with method GET
cache_key = key_builder(request) + vary_components(request) # built only here
entry = await backend.get(cache_key) # expired entries are already skipped here

Expand Down Expand Up @@ -283,8 +285,10 @@ if not is_cacheable_status(response.status_code):
if etag is None:
return response # streaming/file: no ETag, not written
shareable = not (
marked_private_or_no_store(response) or "set-cookie" in response.headers
) # one caller's response is not written
marked_private_or_no_store(response)
or "set-cookie" in response.headers
or request.method == "HEAD"
) # one caller's response, or a HEAD one, is not written
if shareable and (not entry or entry.fingerprint != etag):
await backend.set(cache_key, CacheEntry(..., stored_at=time.time()), ttl=ttl)
if etag_matches(client_etag, etag):
Expand Down Expand Up @@ -576,9 +580,9 @@ matches, a 304 is returned to save bandwidth. Without the header, a 200 with the
content is returned.

**Q: Why aren't POST/PUT responses cached?**
A: `@cache` only applies to GET. Every other method runs the handler directly,
without reading or writing the cache and without adding a `Cache-Control`
header.
A: `@cache` only applies to GET (and HEAD, answered from the GET entry). Every
other method runs the handler directly, 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. `/users?page=1` and
Expand Down
40 changes: 35 additions & 5 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# HTTP Caching

The `@cache` decorator caches the responses of FastAPI GET routes and handles
`Cache-Control`, `ETag` and `If-None-Match` for you. This page covers how to use
it; [Cache flow](CACHE_FLOW.md) explains what happens inside a request.
The `@cache` decorator caches the responses of FastAPI GET routes (and answers
HEAD from them) and handles `Cache-Control`, `ETag` and `If-None-Match` for
you. This page covers how to use it; [Cache flow](CACHE_FLOW.md) explains what
happens inside a request.

Complete runnable example: [`examples/http_cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/http_cache.py).

Expand All @@ -19,7 +20,8 @@ revalidate every time, and `private=True` keeps a response out of the shared
backend. `no_store=True` keeps it out of every cache; all options are listed
under [Cache-Control directives](#cache-control-directives).

Only GET requests are cached; other methods run the handler as usual. The
Only GET requests are cached, and HEAD is answered from the GET entry (see
[HEAD requests](#head-requests)); other methods run the handler as usual. The
handler does not need to declare a `Request` parameter — the decorator adds one
when it is missing. If no backend has been configured, `@cache` falls back to a
`MemoryBackend` and logs a warning once per process (see
Expand Down Expand Up @@ -214,6 +216,34 @@ replayed the headers of the request that filled the cache (#233).
> out of the backend. Limit it to the routes that need it, or set the cookie
> only when it changes.

### HEAD requests

A route that accepts HEAD gets the same treatment as GET (#253). `@app.get`
registers GET only, so list both methods:

```python
@app.api_route("/items", methods=["GET", "HEAD"])
@cache(ttl=60)
async def list_items() -> list[str]: ...
```

A HEAD request reads the entry a GET stored under the same key, as RFC 9110
§9.3.2 allows: on a hit it gets the stored status and headers, `ETag`, `Age`
and the `Content-Length` of the stored body, without running the handler, and a
matching `If-None-Match` gets a 304. The key is the one a GET would get: a
custom `key_builder` is called with the request's method set to `GET`. `vary`,
credentials, `private`, `no_store` and dependency headers apply as for GET.

On a miss the handler runs and its response gets the same `Cache-Control` and
`Vary` handling as a GET, with an `ETag` and `Content-Length` computed from what
it rendered: a handler that skips the body for HEAD sends those of the empty
body. That response is not stored, since a later GET would then be served the
empty body. The server drops the body of every HEAD response.
`invalidate()` builds the GET key, so it clears what HEAD reads as well.

A route that already accepted HEAD ran the handler on every HEAD request before
0.4.2 and added none of these headers; it now skips the handler on a hit.

### Requests with credentials

A single-page app that sends `Authorization` on every request, or a site where
Expand Down Expand Up @@ -497,7 +527,7 @@ and a custom key builder compose:
`key_builder` returning `build_cache_key(request, "tenant-1")`. Routes without
`vary` keep their keys.

The names are also added to the `Vary` header of every response to a GET
The names are also added to the `Vary` header of every response to a GET or HEAD
request on the route, on a 200 or a 304, served from the backend or not
(`private`, `no_store`, a bypassed `Authorization` request or a response that
is not stored), so a shared cache in front of the app keys on them too. A name
Expand Down
6 changes: 3 additions & 3 deletions fastapi_cachex/_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def _with_dependency_headers(
dependency_lines: Sequence[tuple[bytes, bytes]],
private_cache_control: str,
*,
cacheable_get: bool,
cacheable_request: bool,
) -> Response:
"""Add the header lines the dependencies set on this request (#233).

Expand All @@ -97,7 +97,7 @@ def _with_dependency_headers(
merges them itself: on a miss, a hit and a 304 alike, with the values of
this request rather than those stored with the entry.

On a cacheable GET response, ``Cache-Control`` is decided as for the
On a cacheable GET or HEAD response, ``Cache-Control`` is decided as for the
handler's own lines: a handler's ``private`` or ``no-store`` header (or
``no_store=True``) is kept; otherwise a dependency's ``private`` or
``no-store`` header replaces the decorator's, and a ``Set-Cookie`` makes it
Expand All @@ -110,7 +110,7 @@ def _with_dependency_headers(
if sub_response is None or not dependency_lines:
return response
current, _ = _split_header_lines(sub_response.headers.raw, dependency_lines)
if not cacheable_get or not (
if not cacheable_request or not (
response.status_code == HTTP_304_NOT_MODIFIED
or _is_cacheable_status(response.status_code)
):
Expand Down
43 changes: 33 additions & 10 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@

logger = logging.getLogger(__name__)

# Methods `@cache` answers; HEAD shares the GET entry (#253).
_CACHED_METHODS = frozenset({"GET", "HEAD"})


def _as_get(request: Request) -> Request:
"""The same request with method GET, to build the key a GET would get.

``state`` and the session live in the scope, so a key builder that reads
them sees what the HEAD request carries.
"""
return Request({**request.scope, "method": "GET"}, request.receive)


def _log_backend_failure(
what: str, request: Request, cache_key: str, error: Exception
Expand Down Expand Up @@ -348,8 +360,13 @@ def cache(
) -> Callable[[HandlerCallable], AsyncResponseCallable]:
"""Cache decorator for FastAPI route handlers.

Only GET requests go through the cache; other methods run the handler
unchanged.
Only GET and HEAD requests go through the cache; other methods run the
handler unchanged. A HEAD request is answered like a GET: from the entry
a GET stored under the same key (RFC 9110 §9.3.2), and with the same
headers when the handler runs. Its own response is never stored, since a
handler may render HEAD differently. The route must accept HEAD:
``@app.get`` does not, ``@app.api_route(..., methods=["GET", "HEAD"])``
does.

A response is never stored when the handler marks it ``private`` or
``no-store`` in its own ``Cache-Control`` (that header is then sent
Expand Down Expand Up @@ -429,7 +446,7 @@ def cache(
missing) is appended to the key, after whatever ``key_builder``
returns, as a ``name=value`` component, so every distinct value
gets its own entry. The names are also added to the ``Vary``
header of every response to a GET request, unless it already
header of every response to a GET or HEAD request, unless it already
lists them or ``*``. Values are client-controlled: each listed
header multiplies the number of entries, so normalise them in a
``key_builder`` when only a few values matter. The credential
Expand Down Expand Up @@ -638,10 +655,9 @@ async def respond(
# is the only caller that supplies the request parameter.
raise RequestNotFoundError

# Only cache GET requests
if req.method != "GET":
if req.method not in _CACHED_METHODS:
logger.debug(
"Non-GET request; bypassing cache for method=%s", req.method
"Not GET or HEAD; bypassing cache for method=%s", req.method
)
return await _respond(
func,
Expand Down Expand Up @@ -745,8 +761,11 @@ async def respond(

# Built only here: the branches above never touch the backend, so a
# custom key builder would run for nothing.
# HEAD reads the entry of the GET (#253), whichever builder made
# the key.
cache_key = _append_key_components(
_build_key(builder, req), _vary_components(req, vary_names)
_build_key(builder, _as_get(req) if req.method == "HEAD" else req),
_vary_components(req, vary_names),
)

try:
Expand Down Expand Up @@ -883,6 +902,10 @@ async def respond(
if skip_reason is None and dependency_status is not None:
# It may hold for this request only, and a hit would replay it.
skip_reason = "a dependency set the status code"
if skip_reason is None and req.method == "HEAD":
# A handler may skip the body for HEAD; a GET hit would then
# replay the empty one.
skip_reason = "only a GET response is stored"
if skip_reason is not None:
logger.debug("Not storing key=%s: %s", cache_key, skip_reason)

Expand Down Expand Up @@ -949,7 +972,7 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
sub_response,
dependency_lines,
private_cache_control,
cacheable_get=req is not None and req.method == "GET",
cacheable_request=req is not None and req.method in _CACHED_METHODS,
)

wrapper: AsyncResponseCallable = serve
Expand All @@ -960,8 +983,8 @@ async def with_vary(*args: Any, **kwargs: Any) -> Response:
# Read before `serve` pops an injected request parameter.
req: Request | None = kwargs.get(request_name)
response = await serve(*args, **kwargs)
if req is not None and req.method == "GET":
# Every GET answer, served from the backend or not: a
if req is not None and req.method in _CACHED_METHODS:
# Every GET or HEAD answer, served from the backend or not: a
# shared cache downstream keys on these headers too.
add_vary(response.headers, vary_names)
return response
Expand Down
20 changes: 12 additions & 8 deletions i18n/zh-TW/docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
```
HTTP 請求抵達
↓
@cache 裝飾器攔截請求(只有 GET 會經過快取;其他所有
方法直接執行 handler,也不會帶上 Cache-Control 標頭)
@cache 裝飾器攔截請求(只有 GET 與 HEAD 會經過快取,HEAD
讀取 GET 的項目;其他所有方法直接執行 handler,也不會帶上
Cache-Control 標頭)
↓
no-store? ── 是 → 執行 handler,既不讀取也不寫入快取,
│ 回應帶上 Cache-Control: no-store
Expand Down Expand Up @@ -52,7 +53,7 @@ private、沒有正數的 ttl,或帶有 Authorization/Session 且未設定 p
在回應中附加 Cache-Control(非 2xx 回應回傳時不帶此標頭,
handler 自己送出的 private/no-store Cache-Control 永遠不會被取代,
設定 Set-Cookie 的回應則以 private 取代 public);設定 vary 時,
會把這些名稱加入每個 GET 回應的 Vary
會把這些名稱加入每個 GET 或 HEAD 回應的 Vary
```

## 詳細步驟 {#detailed-steps}
Expand Down Expand Up @@ -94,7 +95,7 @@ cache_key = "|".join(

這個快取鍵格式讓每個維度各自獨立快取:

- **方法隔離**:GET 與 POST 不共用快取(而且目前只有 GET 會進入快取流程)
- **方法隔離**:GET 與 POST 不共用快取(而且只有 GET 與 HEAD 會進入快取流程;HEAD 使用 GET 的快取鍵,且永遠不會儲存)
- **Host 隔離**:`example.com` 與 `api.example.com` 分開快取;`Example.com` 與(在 http 上的)`example.com:80` 都是 `example.com`
- **路徑隔離**:每個端點有各自的項目
- **查詢參數隔離**:同一端點上不同的查詢參數分開快取
Expand Down Expand Up @@ -172,7 +173,7 @@ TTL 不儲存在 `CacheEntry` 中:過期由後端負責(`MemoryBackend` 將
**判斷邏輯**(`cache.py` 的包裝函式,依序執行):

```python
if request.method != "GET":
if request.method not in ("GET", "HEAD"):
return await handler() # 不快取,不帶 Cache-Control

if no_store:
Expand All @@ -186,6 +187,7 @@ if bypass or (credential and not cache_authorized):
response, etag = await render() # 既不讀取也不寫入後端
return not_modified(...) if etag_matches(client_etag, etag) else response

# HEAD:key_builder 拿到的請求方法是 GET
cache_key = key_builder(request) + vary_components(request) # 只在這裡建立
entry = await backend.get(cache_key) # 過期的項目已在此略過

Expand Down Expand Up @@ -214,8 +216,10 @@ if not is_cacheable_status(response.status_code):
if etag is None:
return response # 串流/檔案:沒有 ETag,不寫入
shareable = not (
marked_private_or_no_store(response) or "set-cookie" in response.headers
) # 屬於單一呼叫者的回應不寫入
marked_private_or_no_store(response)
or "set-cookie" in response.headers
or request.method == "HEAD"
) # 屬於單一呼叫者的回應與 HEAD 的回應不寫入
if shareable and (not entry or entry.fingerprint != etag):
await backend.set(cache_key, CacheEntry(..., stored_at=time.time()), ttl=ttl)
if etag_matches(client_etag, etag):
Expand Down Expand Up @@ -441,7 +445,7 @@ handler 不必自行宣告 `Request`:`@cache` 會在函式簽名中注入一

**Q:為什麼快取命中不一定回傳 200?** A:視情況而定。若請求帶有 `If-None-Match` 標頭且其 ETag 相符,會回傳 304 以節省頻寬。沒有此標頭時,則回傳帶有內容的 200。

**Q:為什麼 POST/PUT 的回應不會被快取?** A:`@cache` 只適用於 GET。其他所有方法都直接執行 handler,不讀取或寫入快取,也不會加上 `Cache-Control` 標頭。
**Q:為什麼 POST/PUT 的回應不會被快取?** A:`@cache` 只適用於 GET(以及以 GET 的項目回應的 HEAD)。其他所有方法都直接執行 handler,不讀取或寫入快取,也不會加上 `Cache-Control` 標頭。

**Q:為什麼同一個端點有好幾個快取項目?** A:因為快取鍵包含查詢參數。`/users?page=1` 與 `/users?page=2` 是不同的項目;若路由設定了 `sort_query=False`,`?a=1&b=2` 與 `?b=2&a=1` 也是。

Expand Down
Loading
Loading