diff --git a/CLAUDE.md b/CLAUDE.md index b8aebba..1a520ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:`. 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. diff --git a/changelog.d/253.added.md b/changelog.d/253.added.md new file mode 100644 index 0000000..6a4b083 --- /dev/null +++ b/changelog.d/253.added.md @@ -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. diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 28c09f0..6e3089c 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -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 @@ -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 @@ -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 @@ -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: @@ -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 @@ -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): @@ -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 diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index f693a74..ec2c0fc 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -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). @@ -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 @@ -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 @@ -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 diff --git a/fastapi_cachex/_rendering.py b/fastapi_cachex/_rendering.py index 75e09cb..30cb52f 100644 --- a/fastapi_cachex/_rendering.py +++ b/fastapi_cachex/_rendering.py @@ -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). @@ -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 @@ -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) ): diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index c029de3..3846d27 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -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 @@ -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 @@ -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 @@ -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, @@ -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: @@ -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) @@ -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 @@ -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 diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 75198d2..068ef04 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -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 @@ -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} @@ -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` - **路徑隔離**:每個端點有各自的項目 - **查詢參數隔離**:同一端點上不同的查詢參數分開快取 @@ -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: @@ -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) # 過期的項目已在此略過 @@ -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): @@ -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` 也是。 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index ebe4b3c..5902ae2 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -1,6 +1,6 @@ # HTTP 快取 {#http-caching} -`@cache` 裝飾器會快取 FastAPI GET 路由的回應,並替你處理 `Cache-Control`、`ETag` 與 `If-None-Match`。本頁說明如何使用它;[快取流程](CACHE_FLOW.md)則說明請求內部發生了什麼。 +`@cache` 裝飾器會快取 FastAPI GET 路由的回應(並以它們回應 HEAD),並替你處理 `Cache-Control`、`ETag` 與 `If-None-Match`。本頁說明如何使用它;[快取流程](CACHE_FLOW.md)則說明請求內部發生了什麼。 完整可執行範例(英文):[`examples/http_cache.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/http_cache.py)。 @@ -14,7 +14,7 @@ `ttl=60` 會在 60 秒內提供儲存的回應,`no_cache=True` 讓用戶端每次都重新驗證,`private=True` 則讓回應不存入共用的後端。`no_store=True` 讓回應不存入任何快取;所有選項列在 [Cache-Control 指令](#cache-control-directives)。 -只有 GET 請求會被快取;其他方法照常執行 handler。handler 不需要宣告 `Request` 參數:缺少時裝飾器會自動加上。如果尚未設定任何後端,`@cache` 會改用 `MemoryBackend`,並在每個行程記錄一次警告(見[後端](BACKENDS.md#in-memory-default))。 +只有 GET 請求會被快取,HEAD 則以 GET 的項目回應(見 [HEAD 請求](#head-requests));其他方法照常執行 handler。handler 不需要宣告 `Request` 參數:缺少時裝飾器會自動加上。如果尚未設定任何後端,`@cache` 會改用 `MemoryBackend`,並在每個行程記錄一次警告(見[後端](BACKENDS.md#in-memory-default))。 ### 裝飾器順序 {#decorator-order} @@ -104,6 +104,22 @@ handler 回傳一般資料而非 `Response` 時,得到的處理與沒有 `@cac > [!NOTE] > 因此,每次請求都設定 cookie 的依賴項(例如套用到整個應用程式的 CSRF 或 session 更新依賴項),會讓它套用到的每個 `@cache` 路由都不寫入後端。請只把它套用到需要的路由,或只在 cookie 改變時才設定。 +### HEAD 請求 {#head-requests} + +接受 HEAD 的路由會得到與 GET 相同的處理(#253)。`@app.get` 只註冊 GET,因此要列出兩個方法: + +```python +@app.api_route("/items", methods=["GET", "HEAD"]) +@cache(ttl=60) +async def list_items() -> list[str]: ... +``` + +HEAD 請求會讀取 GET 以同一個快取鍵儲存的項目,這是 RFC 9110 §9.3.2 所允許的:命中時它會得到儲存的狀態碼與標頭、`ETag`、`Age` 以及儲存的本文的 `Content-Length`,不會執行 handler;相符的 `If-None-Match` 則得到 304。快取鍵與 GET 會得到的相同:自訂的 `key_builder` 被呼叫時,請求的方法會是 `GET`。`vary`、憑證、`private`、`no_store` 與依賴項的標頭都與 GET 相同。 + +未命中時 handler 會執行,它的回應得到與 GET 相同的 `Cache-Control` 與 `Vary` 處理,`ETag` 與 `Content-Length` 則依它實際產生的內容計算:對 HEAD 省略本文的 handler,送出的是空本文的值。這個回應不會儲存,否則之後的 GET 會拿到空的本文。伺服器會丟棄每個 HEAD 回應的本文。`invalidate()` 建立的是 GET 的快取鍵,因此也會清除 HEAD 讀取的項目。 + +0.4.2 之前,已接受 HEAD 的路由每次收到 HEAD 請求都會執行 handler,也不會加上這些標頭;現在命中時不會執行 handler。 + ### 帶有憑證的請求 {#requests-with-credentials} 每個請求都送出 `Authorization` 的單頁應用程式,或每位訪客都有 Session 的網站,在只加上 `@cache` 的路由上完全不會命中快取:每個請求都會繞過後端(見上文)。請依 handler 回傳的內容選擇: @@ -263,7 +279,7 @@ async def greeting(request: Request): 每個列出的標頭都會在鍵中加入一個 `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: *` 的回應則維持原樣。 +這些名稱也會加入該路由對 GET 或 HEAD 請求的每個回應的 `Vary` 標頭,不論是 200 或 304,也不論是否由後端提供(`private`、`no_store`、繞過後端的 `Authorization` 請求,或未儲存的回應),讓應用程式前方的共用快取也依它們區分。回應已列出的名稱(不分大小寫)不會重複加入,帶有 `Vary: *` 的回應則維持原樣。 `vary` 必須是由標頭欄位名稱組成的 list(或 tuple)。套用裝飾器時,會拒絕 `vary="Accept"` 這類單一字串,以及空名稱、`*` 與任何不是有效欄位名稱的值。 diff --git a/tests/test_cache_head.py b/tests/test_cache_head.py new file mode 100644 index 0000000..bba7a65 --- /dev/null +++ b/tests/test_cache_head.py @@ -0,0 +1,227 @@ +"""``@cache`` answers HEAD from the entry a GET stored (#253).""" + +from typing import Any + +from fastapi import Depends +from fastapi import FastAPI +from fastapi import Request +from fastapi import Response +from fastapi.testclient import TestClient + +from fastapi_cachex import build_cache_key +from fastapi_cachex.cache import cache +from fastapi_cachex.proxy import BackendProxy + +GET_KEY = "http:v2|GET|testserver|/items|" + + +def _app(**cache_kwargs: Any) -> tuple[TestClient, list[str]]: + app = FastAPI() + calls: list[str] = [] + + @app.api_route("/items", methods=["GET", "HEAD"]) + @cache(ttl=60, **cache_kwargs) + async def items(request: Request) -> Response: + calls.append(request.method) + # A handler may skip the body for HEAD; only a GET's may be stored. + body = b"" if request.method == "HEAD" else b'{"items": [1, 2, 3]}' + return Response(content=body, media_type="application/json") + + return TestClient(app), calls + + +async def test_head_is_served_from_the_get_entry() -> None: + client, calls = _app() + get = client.get("/items") + + head = client.head("/items") + + assert calls == ["GET"] + assert head.status_code == 200 + assert head.content == b"" + assert head.headers["content-length"] == str(len(get.content)) + assert head.headers["etag"] == get.headers["etag"] + assert head.headers["cache-control"] == "max-age=60" + assert "age" in head.headers + assert await BackendProxy.get().get_all_keys() == [GET_KEY] + + +def test_head_revalidates_against_the_get_entry() -> None: + client, calls = _app() + etag = client.get("/items").headers["etag"] + + head = client.head("/items", headers={"If-None-Match": etag}) + + assert calls == ["GET"] + assert head.status_code == 304 + assert head.headers["etag"] == etag + + +async def test_head_miss_runs_the_handler_and_stores_nothing() -> None: + client, calls = _app() + + head = client.head("/items") + get = client.get("/items") + + assert calls == ["HEAD", "GET"] + assert head.headers["cache-control"] == "max-age=60" + assert "etag" in head.headers + assert get.content == b'{"items": [1, 2, 3]}' + assert await BackendProxy.get().get_all_keys() == [GET_KEY] + + +def test_a_custom_key_builder_builds_the_get_key_for_head() -> None: + methods: list[str] = [] + + def per_tenant(request: Request) -> str: + methods.append(request.method) + return build_cache_key(request, request.headers.get("x-tenant", "")) + + client, calls = _app(key_builder=per_tenant) + client.get("/items", headers={"X-Tenant": "a"}) + + head = client.head("/items", headers={"X-Tenant": "a"}) + + assert calls == ["GET"] + assert head.status_code == 200 + assert methods == ["GET", "GET"] + + +def test_head_keys_on_and_sends_vary() -> None: + client, calls = _app(vary=["Accept-Language"]) + client.get("/items", headers={"Accept-Language": "de"}) + + de = client.head("/items", headers={"Accept-Language": "de"}) + en = client.head("/items", headers={"Accept-Language": "en"}) + + assert calls == ["GET", "HEAD"] + assert de.headers["vary"] == "Accept-Language" + assert en.headers["vary"] == "Accept-Language" + + +def test_head_with_credentials_bypasses_the_backend() -> None: + client, calls = _app() + client.get("/items") + + head = client.head("/items", headers={"Authorization": "Bearer t"}) + + assert calls == ["GET", "HEAD"] + assert head.headers["cache-control"] == "private, max-age=60" + + +def test_a_dependency_cookie_makes_a_head_hit_private() -> None: + app = FastAPI() + + def set_cookie(response: Response) -> None: + response.set_cookie("seen", "1") + + @app.api_route( + "/items", methods=["GET", "HEAD"], dependencies=[Depends(set_cookie)] + ) + @cache(ttl=60, public=True) + async def items() -> dict[str, int]: + return {"n": 1} + + client = TestClient(app) + client.get("/items") + + head = client.head("/items") + + assert head.headers["cache-control"] == "private, max-age=60" + assert head.headers["set-cookie"].startswith("seen=1") + + +def test_post_still_runs_the_handler() -> None: + app = FastAPI() + calls: list[str] = [] + + @app.api_route("/items", methods=["GET", "POST"]) + @cache(ttl=60) + async def items(request: Request) -> dict[str, int]: + calls.append(request.method) + return {"n": 1} + + client = TestClient(app) + client.get("/items") + + post = client.post("/items") + + assert calls == ["GET", "POST"] + assert "etag" not in post.headers + + +async def test_head_leaves_a_different_get_entry_alone() -> None: + app = FastAPI() + version = {"n": 1} + + @app.api_route("/items", methods=["GET", "HEAD"]) + @cache(ttl=60, no_cache=True) + async def items() -> dict[str, int]: + return {"n": version["n"]} + + client = TestClient(app) + client.get("/items") + version["n"] = 2 + + head = client.head("/items") + + entry = await BackendProxy.get().get(GET_KEY) + assert entry is not None + assert entry.content == b'{"n":1}' + assert head.headers["etag"] != entry.fingerprint + + +def test_head_with_no_cache_revalidates_against_a_fresh_render() -> None: + client, calls = _app(no_cache=True) + etag = client.get("/items").headers["etag"] + + # The handler renders an empty body for HEAD, so its ETag differs. + head = client.head("/items", headers={"If-None-Match": etag}) + + assert calls == ["GET", "HEAD"] + assert head.status_code == 200 + assert head.headers["cache-control"] == "no-cache" + + +def test_head_miss_answers_304_when_its_render_matches() -> None: + client, calls = _app() + etag = client.head("/items").headers["etag"] + + head = client.head("/items", headers={"If-None-Match": etag}) + + assert calls == ["HEAD", "HEAD"] + assert head.status_code == 304 + + +async def test_head_with_no_store_sends_no_store() -> None: + client, calls = _app_no_store() + + head = client.head("/items") + + assert calls == ["HEAD"] + assert head.headers["cache-control"] == "no-store" + assert await BackendProxy.get().get_all_keys() == [] + + +def _app_no_store() -> tuple[TestClient, list[str]]: + app = FastAPI() + calls: list[str] = [] + + @app.api_route("/items", methods=["GET", "HEAD"]) + @cache(no_store=True) + async def items(request: Request) -> dict[str, int]: + calls.append(request.method) + return {"n": 1} + + return TestClient(app), calls + + +def test_head_on_a_private_route_bypasses_the_backend() -> None: + client, calls = _app(private=True) + client.get("/items") + + head = client.head("/items") + + assert calls == ["GET", "HEAD"] + assert head.headers["cache-control"] == "private, max-age=60" + assert "etag" in head.headers