From edcfd252cd8964ca69cf5c5b417dd0caccd00c9b Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 06:53:24 +0000 Subject: [PATCH] feat(cache): send Age on responses served from a stored entry A hit and a 304 answered from the stored ETag sent max-age= with no Age, so a downstream cache restarted the freshness clock and could reuse the response for up to twice the ttl. CacheEntry gains an optional stored_at (wall-clock epoch seconds) that @cache sets and the codec stores; Age is now - stored_at clamped to 0..ttl. Entries without stored_at, fresh renders and bypassed requests send no Age. Closes #254 --- changelog.d/254.fixed.md | 11 + docs/CACHE_FLOW.md | 32 ++- docs/HTTP_CACHING.md | 23 ++ fastapi_cachex/backends/codec.py | 14 +- fastapi_cachex/cache.py | 49 ++++- fastapi_cachex/types.py | 7 + i18n/zh-TW/docs/CACHE_FLOW.md | 23 +- i18n/zh-TW/docs/HTTP_CACHING.md | 11 + tests/test_cache_age.py | 361 +++++++++++++++++++++++++++++++ 9 files changed, 511 insertions(+), 20 deletions(-) create mode 100644 changelog.d/254.fixed.md create mode 100644 tests/test_cache_age.py diff --git a/changelog.d/254.fixed.md b/changelog.d/254.fixed.md new file mode 100644 index 0000000..5a29a18 --- /dev/null +++ b/changelog.d/254.fixed.md @@ -0,0 +1,11 @@ +**Responses served from the cache carry an `Age` header, so downstream caches no longer keep them for up to twice the `ttl`.** +A hit, and a 304 answered from the stored entry's ETag, used to send +`Cache-Control: max-age=` with no `Age`, so a browser or CDN restarted +the freshness clock on every hit. They now send `Age`, the whole seconds since +the entry was stored, clamped to `0`–`ttl` against clock skew between hosts; +downstream subtracts it from `max-age` (RFC 9111 §4.2.3). `CacheEntry` has a +new optional `stored_at` field (epoch seconds, wall clock) that `@cache` sets +and the Redis/Memcached codec stores. Entries written by older releases decode +with `stored_at=None` and are served without `Age`; responses the handler +renders (misses, `no_cache`, bypassed requests) never carry one. An `Age` +header the handler sets is no longer stored and replayed. diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index ec5d86b..57271a2 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -28,12 +28,12 @@ Read the backend entry ↓ Request carries If-None-Match? ├─ and no-cache → run the handler first to compute the current ETag; match → 304 - ├─ otherwise → compare with the cached entry's ETag; match → 304 + ├─ otherwise → compare with the cached entry's ETag; match → 304 with Age └─ no match / no header → continue ↓ Cached entry exists, ttl is set, and no-cache is off? ├─ yes → respond with the cached content (including the stored status code - │ and headers; the handler does **not** run) + │ and headers, plus Age; the handler does **not** run) └─ no → run the handler ├─ non-2xx (or 206) → return as-is and **do not write** │ (an existing good entry is not overwritten) @@ -185,12 +185,15 @@ entry = CacheEntry( media_type="application/json", status_code=200, # replayed with the original status code headers={"Vary": "Accept-Encoding"}, # headers sent back on replay + stored_at=1702650540.5, # epoch seconds when @cache stored it; drives Age ) ``` The TTL is not stored in `CacheEntry`: expiry is the backend's responsibility (`MemoryBackend` keeps it in `CacheItem.expiry`, Redis uses `SET ... EX`, -Memcached uses the exptime). +Memcached uses the exptime). `stored_at` is wall-clock time (`time.time()`), +since the process that serves an entry may not be the one that stored it; it +is `None` for entries written by releases before 0.3.9. If no backend has been configured with `BackendProxy.set()`, the decorator creates a `MemoryBackend` on the first request, registers it and logs a @@ -217,14 +220,15 @@ if client_etag and no_cache: if etag_matches(client_etag, fresh.etag): return not_modified(...) # 304 elif client_etag and entry and etag_matches(client_etag, entry.fingerprint): - return not_modified(...) # 304, handler does not run + return not_modified(..., age_headers(entry, ttl)) # 304, handler does not run if entry and not no_cache: return Response( # 200, handler does not run content=entry.content, status_code=entry.status_code, media_type=entry.media_type, - headers={**(entry.headers or {}), "ETag": entry.fingerprint, ...}, + headers={**(entry.headers or {}), "ETag": entry.fingerprint, ..., + **age_headers(entry, ttl)}, # Age: now - stored_at, clamped to 0..ttl ) response, body, etag = await render() # miss (reused if no-cache already rendered) @@ -235,10 +239,18 @@ if etag is None: if marked_private_or_no_store(response) or "set-cookie" in response.headers: return response # one caller's response: not written if not entry or entry.fingerprint != etag: - await backend.set(cache_key, CacheEntry(...), ttl=ttl) + await backend.set(cache_key, CacheEntry(..., stored_at=time.time()), ttl=ttl) return response ``` +> [!NOTE] +> **`Age` on responses served from the backend.** A hit and a 304 answered from +> the stored ETag carry `Age: `, clamped to `0`–`ttl` +> against clock skew between hosts; `Cache-Control` keeps `max-age=`, and +> a downstream cache subtracts `Age` from it (RFC 9111 §4.2.3). Responses the +> handler just rendered, including every `no_cache` response and every bypass, +> carry no `Age`, and neither do entries without `stored_at`. + > [!NOTE] > "Non-2xx is not written" is deliberate: a transient error must not wipe out > the last good cached response, nor be replayed later as a 200. `206 Partial @@ -307,6 +319,7 @@ intermediate cache would lose those fields after revalidation (RFC 9110 media_type="application/json", status_code=200, headers=None, + stored_at=1702650540.5, ), expiry=1702650600.5, # epoch seconds; None means never expires ), @@ -334,7 +347,8 @@ and the standard library `json` otherwise: "content": "", "media_type": "application/json", "status_code": 200, - "headers": {"Vary": "Accept-Encoding"} + "headers": {"Vary": "Accept-Encoding"}, + "stored_at": 1702650540.5 } ``` @@ -342,7 +356,9 @@ and the standard library `json` otherwise: onto bytes, so any byte sequence can be placed in JSON text and recovered unchanged. - Entries written by older releases, without the `status_code`/`headers` - fields, remain readable and decode to `200` with no extra headers. + fields, remain readable and decode to `200` with no extra headers. Those + without `stored_at` (before 0.3.9) decode with `stored_at=None` and are + served without an `Age` header. - Any decode failure (broken JSON, missing fields, wrong types) is treated as a **cache miss** and returns `None` instead of raising. - `increment()` leaves a **bare integer** behind (written by the Redis/Memcached diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index c347181..1484bbd 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -111,6 +111,29 @@ When a cached entry is valid (within TTL): - **Without `ttl`** (`ttl=None`): Nothing is read from or written to the backend, as with `private=True`. The handler runs on every request, and `If-None-Match` gets a 304 only when it matches the freshly rendered response, so an old ETag never gets a 304 once the content has changed - **With `ttl=0`**: Sends `max-age=0` and otherwise behaves like `ttl=None`. A negative `ttl`, a non-`int` one (such as `1.5` or `True`) and one above `MAX_TTL` (see [TTL values](BACKENDS.md#ttl-values)) are rejected with `CacheXError` when the decorator is applied +### The `Age` header + +A response served from a stored entry carries an `Age` header: the whole +number of seconds since `@cache` stored it (RFC 9111 §5.1). That covers a +cache hit and a 304 answered from the stored entry's ETag. `Cache-Control` +still says `max-age=`, and a browser or CDN subtracts `Age` from it +(RFC 9111 §4.2.3), so a response stored 50 seconds into a 60-second ttl is +reused downstream for at most 10 more seconds. Without `Age`, a hit just +before the entry expired restarted the downstream clock, and the content could +be reused for up to twice the ttl. + +``` +GET /items → 200, Cache-Control: max-age=60, no Age (the handler ran) +GET /items → 200, Cache-Control: max-age=60, Age: 42 (served 42 s after it was stored) +``` + +The time an entry was stored comes from the wall clock of the process that +stored it and is read by whichever process serves it, so `Age` is clamped to +`0`–`ttl` in case two hosts' clocks disagree. No `Age` is sent when the handler +runs (a miss, `no_cache=True`, a bypassed request) or for an entry stored by +a release before 0.3.9, which does not record the time. An `Age` header the +handler sets itself is not stored. + Only successful responses are stored. A response the handler *returns* with a non-2xx status (for example `Response(..., status_code=404)`) is passed straight through and never cached, so a transient error cannot replace or poison the last diff --git a/fastapi_cachex/backends/codec.py b/fastapi_cachex/backends/codec.py index ba88598..f06a8d3 100644 --- a/fastapi_cachex/backends/codec.py +++ b/fastapi_cachex/backends/codec.py @@ -4,6 +4,8 @@ it is installed and the standard library ``json`` module otherwise. """ +import math + from fastapi_cachex.types import COUNTER_FINGERPRINT from fastapi_cachex.types import DEFAULT_STATUS_CODE from fastapi_cachex.types import CacheEntry @@ -45,12 +47,20 @@ def encode_entry(entry: CacheEntry) -> bytes: "media_type": entry.media_type, "status_code": entry.status_code, "headers": entry.headers, + "stored_at": entry.stored_at, }, ) # orjson returns bytes, stdlib json returns str return serialized if isinstance(serialized, bytes) else serialized.encode("utf-8") +def _stored_at(value: object) -> float | None: + """``stored_at`` from a document; anything but a finite number is unknown.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) if math.isfinite(value) else None + + def decode_entry(raw: str | bytes | None) -> CacheEntry | None: """Rebuild a ``CacheEntry`` from a stored value. @@ -61,7 +71,8 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None: cache miss. Documents written before entries carried a status code and headers simply - lack those keys and decode to a plain ``200`` with no extra headers. + lack those keys and decode to a plain ``200`` with no extra headers; those + written before entries carried ``stored_at`` decode with ``None``. """ if raw is None: return None @@ -76,6 +87,7 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None: media_type=data.get("media_type"), status_code=data.get("status_code", DEFAULT_STATUS_CODE), headers=data.get("headers"), + stored_at=_stored_at(data.get("stored_at")), ) except _DECODE_ERRORS: return None diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index cbfd75b..e7a5094 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -4,6 +4,7 @@ import inspect import logging import threading +import time import warnings from collections.abc import Awaitable from collections.abc import Callable @@ -62,6 +63,11 @@ _NO_STORE = DirectiveType.NO_STORE.value +# Wall clock behind ``CacheEntry.stored_at`` and the ``Age`` header. Wall time, +# not monotonic, because an entry stored by one process or host is served by +# another. A module attribute so tests can move time without sleeping. +_now = time.time + def build_cache_key(request: Request, *components: str | int) -> str: """Build the default cache key for ``request``, plus extra components. @@ -466,6 +472,7 @@ def __str__(self) -> str: "etag", "cache-control", "content-type", + "age", } ) @@ -499,6 +506,28 @@ def _cacheable_headers(response: Response) -> dict[str, str] | None: _REVALIDATION_HEADERS = frozenset({"content-location", "expires", "vary"}) +def _age_headers(entry: CacheEntry, ttl: int | None) -> dict[str, str]: + """The ``Age`` header for a response served from a stored ``entry``. + + ``Cache-Control`` keeps ``max-age=`` on a hit: RFC 9111 §4.2.3 has a + downstream cache compute the remaining freshness as ``max-age`` minus + ``Age``, so a copy stored here N seconds ago is fresh downstream for + ``ttl - N`` more seconds, and the total never reaches twice the ttl. + + ``Age`` is a non-negative integer number of seconds (RFC 9111 §5.1). The + value is clamped to ``[0, ttl]``: ``stored_at`` may come from another + host's clock, and the backend never keeps an entry longer than ``ttl``, so + anything outside that range is clock skew. An entry without ``stored_at`` + (written by an older release) gets no ``Age`` at all. + """ + if entry.stored_at is None: + return {} + age = max(0.0, _now() - entry.stored_at) + if ttl is not None: + age = min(age, ttl) + return {"age": str(int(age))} + + def _revalidation_headers(headers: Mapping[str, str] | None) -> dict[str, str]: """The subset of a response's headers that a 304 must repeat.""" if not headers: @@ -643,14 +672,19 @@ def _etag_matches(if_none_match: str | None, etag: str) -> bool: def _not_modified( - etag: str, cache_control: str, headers: Mapping[str, str] | None = None + etag: str, + cache_control: str, + headers: Mapping[str, str] | None = None, + age: Mapping[str, str] | None = None, ) -> Response: """Build the 304 for a successful revalidation. ``headers`` is what the 200 for this resource would have carried; RFC 9110 §15.4.5 requires the fields that steer caching to be repeated on the 304, otherwise a cache that stored the 200 would drop them on refresh. ``Date`` - is added by Starlette and the other two are set here. + is added by Starlette and the other two are set here. ``age`` is the + ``Age`` header (see ``_age_headers``) when the 304 is answered from a + stored entry. """ return Response( status_code=HTTP_304_NOT_MODIFIED, @@ -658,6 +692,7 @@ def _not_modified( **_revalidation_headers(headers), "ETag": etag, "Cache-Control": cache_control, + **(age or {}), }, ) @@ -1220,8 +1255,14 @@ async def serve(*args: Any, **kwargs: Any) -> Response: logger.debug( "304 Not Modified (cached ETag match); key=%s", cache_key ) + # Answered from the stored entry, so the 304 says how old + # that entry is: a cache refreshing its copy with this 304 + # takes the new Age with it (RFC 9111 §4.3.4). return _not_modified( - cached_data.fingerprint, cache_control, cached_data.headers + cached_data.fingerprint, + cache_control, + cached_data.headers, + _age_headers(cached_data, ttl), ) # If we don't have If-None-Match header, check if we have a valid cached copy @@ -1236,6 +1277,7 @@ async def serve(*args: Any, **kwargs: Any) -> Response: **(cached_data.headers or {}), "ETag": cached_data.fingerprint, "Cache-Control": cache_control, + **_age_headers(cached_data, ttl), }, ) @@ -1284,6 +1326,7 @@ async def serve(*args: Any, **kwargs: Any) -> Response: media_type=_media_type_of(current_response), status_code=current_response.status_code, headers=_cacheable_headers(current_response), + stored_at=_now(), ), ttl=ttl, ) diff --git a/fastapi_cachex/types.py b/fastapi_cachex/types.py index 542ecfb..68ab817 100644 --- a/fastapi_cachex/types.py +++ b/fastapi_cachex/types.py @@ -59,6 +59,12 @@ class CacheEntry: ``status_code`` and ``headers`` default to a plain ``200`` with no extra headers, so entries built by older callers (and documents written by older releases) keep their previous behaviour. + + ``stored_at`` is when ``@cache`` stored the response, in epoch seconds + from the wall clock (``time.time()``), since an entry written by one + process or host may be served by another. It drives the ``Age`` header on + a hit; ``None`` (entries written by older releases, and anything not + stored by ``@cache``) sends no ``Age``. """ fingerprint: str @@ -66,6 +72,7 @@ class CacheEntry: media_type: str | None = None status_code: int = DEFAULT_STATUS_CODE headers: dict[str, str] | None = None + stored_at: float | None = None @dataclass diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 991bc65..9fa4fbd 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -24,12 +24,12 @@ private,或帶有 Authorization/Session 且未設定 public/cache_authoriz ↓ 請求帶有 If-None-Match? ├─ 且為 no-cache → 先執行 handler 計算目前的 ETag;相符 → 304 - ├─ 其他情況 → 與快取項目的 ETag 比對;相符 → 304 + ├─ 其他情況 → 與快取項目的 ETag 比對;相符 → 帶 Age 的 304 └─ 不相符/沒有此標頭 → 繼續 ↓ 快取項目存在、已設定 ttl,且未啟用 no-cache? ├─ 是 → 以快取內容回應(包含儲存的狀態碼 - │ 與標頭;handler **不會**執行) + │ 與標頭,並加上 Age;handler **不會**執行) └─ 否 → 執行 handler ├─ 非 2xx(或 206)→ 原樣回傳且**不寫入** │ (不會覆寫既有的正常項目) @@ -143,10 +143,11 @@ entry = CacheEntry( media_type="application/json", status_code=200, # 以原本的狀態碼重播 headers={"Vary": "Accept-Encoding"}, # 重播時送回的標頭 + stored_at=1702650540.5, # @cache 儲存它時的 epoch 秒數;用來計算 Age ) ``` -TTL 不儲存在 `CacheEntry` 中:過期由後端負責(`MemoryBackend` 將它存在 `CacheItem.expiry`,Redis 使用 `SET ... EX`,Memcached 使用 exptime)。 +TTL 不儲存在 `CacheEntry` 中:過期由後端負責(`MemoryBackend` 將它存在 `CacheItem.expiry`,Redis 使用 `SET ... EX`,Memcached 使用 exptime)。`stored_at` 是系統時鐘時間(`time.time()`),因為送出項目的行程不一定是儲存它的行程;0.3.9 以前的版本寫入的項目為 `None`。 若尚未以 `BackendProxy.set()` 設定後端,裝飾器會在第一個請求時建立 `MemoryBackend`、註冊它,並記錄一則警告,說明這個快取是每個行程各自一份。 @@ -171,14 +172,15 @@ if client_etag and no_cache: if etag_matches(client_etag, fresh.etag): return not_modified(...) # 304 elif client_etag and entry and etag_matches(client_etag, entry.fingerprint): - return not_modified(...) # 304,handler 不執行 + return not_modified(..., age_headers(entry, ttl)) # 304,handler 不執行 if entry and not no_cache: return Response( # 200,handler 不執行 content=entry.content, status_code=entry.status_code, media_type=entry.media_type, - headers={**(entry.headers or {}), "ETag": entry.fingerprint, ...}, + headers={**(entry.headers or {}), "ETag": entry.fingerprint, ..., + **age_headers(entry, ttl)}, # Age:now - stored_at,限制在 0..ttl ) response, body, etag = await render() # 未命中(若 no-cache 已產生過則直接沿用) @@ -189,10 +191,13 @@ if etag is None: if marked_private_or_no_store(response) or "set-cookie" in response.headers: return response # 屬於單一呼叫者的回應:不寫入 if not entry or entry.fingerprint != etag: - await backend.set(cache_key, CacheEntry(...), ttl=ttl) + await backend.set(cache_key, CacheEntry(..., stored_at=time.time()), ttl=ttl) return response ``` +> [!NOTE] +> **由後端送出的回應帶有 `Age`。** 快取命中,以及依已儲存 ETag 回應的 304,會帶有 `Age: <自 stored_at 起的秒數>`,並限制在 `0`–`ttl` 之間,以防主機之間的時鐘偏差;`Cache-Control` 保持 `max-age=`,由下游快取從中扣掉 `Age`(RFC 9111 §4.2.3)。handler 剛產生的回應(包括所有 `no_cache` 回應與所有繞過後端的回應)不帶 `Age`,沒有 `stored_at` 的項目也不帶。 + > [!NOTE] > 「非 2xx 不寫入」是刻意的設計:暫時性的錯誤不應抹除最後一次正常的快取回應,也不應在之後被當成 200 重播。`206 Partial Content` 同樣不會快取,因為它的內容只對產生它的那個 `Range` 請求有意義。非 2xx 回應也永遠不會以 `304` 回應,且回傳時不帶裝飾器的 `Cache-Control` 標頭(只有 `no_store=True` 會在每個回應加上 `no-store`)。 @@ -233,6 +238,7 @@ If-None-Match: * → 只要資源存在就相符 → 304 media_type="application/json", status_code=200, headers=None, + stored_at=1702650540.5, ), expiry=1702650600.5, # epoch 秒數;None 表示永不過期 ), @@ -259,12 +265,13 @@ Redis 與 Memcached 共用同一套 JSON 編解碼器;若已安裝 `orjson` "content": "", "media_type": "application/json", "status_code": 200, - "headers": {"Vary": "Accept-Encoding"} + "headers": {"Vary": "Accept-Encoding"}, + "stored_at": 1702650540.5 } ``` - `content` 使用 **latin-1 來回轉換**,而不是 base64:latin-1 與位元組一一對應,因此任何位元組序列都能放進 JSON 文字中,並原封不動地還原。 -- 舊版本寫入、沒有 `status_code`/`headers` 欄位的項目仍可讀取,解碼後為 `200` 且沒有額外標頭。 +- 舊版本寫入、沒有 `status_code`/`headers` 欄位的項目仍可讀取,解碼後為 `200` 且沒有額外標頭。沒有 `stored_at` 的項目(0.3.9 以前)解碼後為 `stored_at=None`,送出時不帶 `Age` 標頭。 - 任何解碼失敗(損壞的 JSON、缺少欄位、型別錯誤)都視為**快取未命中**,回傳 `None` 而不是拋出例外。 - `increment()` 會留下一個**單純的整數**(由 Redis/Memcached 的 INCR 系列指令寫入);它會解碼成 fingerprint 為 `counter` 的 `CacheEntry`。 diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 1b5705b..2e1d41d 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -89,6 +89,17 @@ async def items(): ... - **未設定 `ttl`**(`ttl=None`):與 `private=True` 相同,不從後端讀取,也不寫入。每個請求都會執行 handler,只有當 `If-None-Match` 與新產生的回應相符時才回 304,因此內容變更後,舊的 ETag 永遠不會得到 304 - **使用 `ttl=0`**:送出 `max-age=0`,其餘行為與 `ttl=None` 相同。負數、非 `int`(例如 `1.5` 或 `True`)或超過 `MAX_TTL`(見 [TTL 值](BACKENDS.md#ttl-values))的 `ttl`,都會在套用裝飾器時以 `CacheXError` 拒絕 +### `Age` 標頭 {#the-age-header} + +由已儲存項目回應的回應會帶有 `Age` 標頭:從 `@cache` 儲存它起經過的整數秒數(RFC 9111 §5.1)。這包括快取命中,以及依已儲存項目的 ETag 回應的 304。`Cache-Control` 仍然是 `max-age=`,瀏覽器或 CDN 會從中扣掉 `Age`(RFC 9111 §4.2.3),因此在 60 秒 ttl 的第 50 秒時送出的回應,下游最多只會再重複使用 10 秒。沒有 `Age` 時,在項目即將過期前的命中會讓下游重新計時,內容最多可能被重複使用到 ttl 的兩倍。 + +``` +GET /items → 200, Cache-Control: max-age=60,沒有 Age(handler 有執行) +GET /items → 200, Cache-Control: max-age=60, Age: 42(儲存後 42 秒送出) +``` + +項目的儲存時間取自儲存它的行程的系統時鐘,而由送出它的行程讀取,因此 `Age` 會限制在 `0`–`ttl` 之間,以防兩台主機的時鐘不一致。handler 有執行時(未命中、`no_cache=True`、繞過後端的請求)不會送出 `Age`;0.3.9 以前的版本儲存的項目沒有記錄時間,也不會送出。handler 自己設定的 `Age` 標頭不會被儲存。 + 只有成功的回應會被儲存。handler *回傳* 非 2xx 狀態的回應(例如 `Response(..., status_code=404)`)會原樣傳出、永不快取,因此暫時性的錯誤不會取代或污染上一筆正常的項目。`206 Partial Content` 同樣排除在外,因為它的本文只對產生它的那個 `Range` 請求有意義。 屬於單一呼叫者的回應同樣不會被儲存(#296): diff --git a/tests/test_cache_age.py b/tests/test_cache_age.py new file mode 100644 index 0000000..393d23b --- /dev/null +++ b/tests/test_cache_age.py @@ -0,0 +1,361 @@ +"""The ``Age`` header on responses served from a stored entry (#254). + +Without it a downstream cache reading ``max-age=`` on a hit restarts the +freshness clock, so a response could be reused for up to twice the ttl. With +``Age`` it computes ``max-age - Age`` (RFC 9111 §4.2.3). + +Time is moved by patching ``fastapi_cachex.cache._now``; the memory backend +keeps real time, so entries do not expire while the patched clock runs ahead. +""" + +import importlib +import json +import math +import uuid +from collections.abc import AsyncIterator +from collections.abc import Callable + +import pytest +from fastapi import FastAPI +from fastapi import Response + +from fastapi_cachex.backends import MemoryBackend +from fastapi_cachex.backends import codec +from fastapi_cachex.backends.base import BaseCacheBackend +from fastapi_cachex.backends.codec import decode_entry +from fastapi_cachex.backends.codec import encode_entry +from fastapi_cachex.cache import cache +from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.types import CacheEntry +from fastapi_cachex.types import counter_entry +from tests.live_servers import MEMCACHED_SERVER +from tests.live_servers import REDIS_HOST +from tests.live_servers import REDIS_PORT +from tests.live_servers import requires_memcached +from tests.live_servers import requires_redis + +try: # Starlette's TestClient moved to httpx2; the `lowest` env still has httpx. + import httpx2 as httpx # type: ignore[import-not-found, unused-ignore] +except ImportError: # pragma: no cover - depends on the environment + import httpx # type: ignore[no-redef, import-not-found, unused-ignore] + +# `fastapi_cachex.cache` is shadowed by the `cache` decorator on the package. +cache_module = importlib.import_module("fastapi_cachex.cache") + +TTL = 60 +START = 1_800_000_000.0 +KEY = "GET|||testserver|||/item|||" + + +class _Clock: + def __init__(self) -> None: + self.now = START + + def __call__(self) -> float: + return self.now + + +@pytest.fixture +def clock(monkeypatch: pytest.MonkeyPatch) -> _Clock: + clock = _Clock() + monkeypatch.setattr(cache_module, "_now", clock) + return clock + + +def _app(**cache_kwargs: object) -> tuple[FastAPI, dict[str, int]]: + calls = {"count": 0} + app = FastAPI() + kwargs: dict[str, object] = {"ttl": TTL, **cache_kwargs} + + @app.api_route("/item", methods=["GET", "POST"]) + @cache(**kwargs) # type: ignore[arg-type] + async def item() -> dict[str, int]: + calls["count"] += 1 + return {"value": 1} + + return app, calls + + +@pytest.fixture +async def client_for() -> AsyncIterator[Callable[[FastAPI], httpx.AsyncClient]]: + clients: list[httpx.AsyncClient] = [] + + def make(app: FastAPI) -> httpx.AsyncClient: + client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver" + ) + clients.append(client) + return client + + yield make + for client in clients: + await client.aclose() + + +async def test_miss_sends_no_age_and_stores_stored_at(clock, client_for): + app, _ = _app() + client = client_for(app) + + response = await client.get("/item") + + assert response.status_code == 200 + assert "age" not in response.headers + entry = await BackendProxy.get().get(KEY) + assert entry is not None + assert entry.stored_at == START + + +@pytest.mark.parametrize("elapsed", [0, 1, 7, 59.9]) +async def test_hit_at_t_plus_n_sends_age_n(clock, client_for, elapsed): + app, calls = _app() + client = client_for(app) + await client.get("/item") + + clock.now = START + elapsed + response = await client.get("/item") + + assert calls["count"] == 1 + assert response.headers["age"] == str(int(elapsed)) + # max-age stays the ttl: downstream subtracts Age itself. + assert response.headers["cache-control"] == f"max-age={TTL}" + assert len(response.headers.get_list("age")) == 1 + + +async def test_age_beyond_ttl_is_clamped_to_ttl(clock, client_for): + app, _ = _app() + client = client_for(app) + await client.get("/item") + + clock.now = START + TTL * 10 + response = await client.get("/item") + + assert response.headers["age"] == str(TTL) + + +async def test_stored_at_in_the_future_gives_age_zero(clock, client_for): + """Another host's clock running ahead must not produce a negative Age.""" + app, _ = _app() + client = client_for(app) + await client.get("/item") + + clock.now = START - 30 + response = await client.get("/item") + + assert response.headers["age"] == "0" + + +async def test_304_from_the_cached_etag_carries_age(clock, client_for): + app, calls = _app() + client = client_for(app) + etag = (await client.get("/item")).headers["etag"] + + clock.now = START + 12 + response = await client.get("/item", headers={"If-None-Match": etag}) + + assert response.status_code == 304 + assert calls["count"] == 1 + assert response.headers["age"] == "12" + assert response.headers["cache-control"] == f"max-age={TTL}" + + +async def test_no_cache_revalidation_sends_no_age(clock, client_for): + """``no_cache`` renders afresh on every request, 304 or not.""" + app, calls = _app(no_cache=True) + client = client_for(app) + etag = (await client.get("/item")).headers["etag"] + + clock.now = START + 12 + not_modified = await client.get("/item", headers={"If-None-Match": etag}) + full = await client.get("/item") + + assert calls["count"] == 3 + assert not_modified.status_code == 304 + assert "age" not in not_modified.headers + assert full.status_code == 200 + assert "age" not in full.headers + + +@pytest.mark.parametrize( + ("cache_kwargs", "method", "headers"), + [ + ({"no_store": True}, "GET", {}), + ({"private": True}, "GET", {}), + ({"ttl": 0}, "GET", {}), + ({}, "POST", {}), + ({}, "GET", {"Authorization": "Bearer t"}), + ], + ids=["no-store", "private", "ttl-0", "non-get", "credential"], +) +async def test_bypass_paths_send_no_age( + clock, client_for, cache_kwargs, method, headers +): + app, calls = _app(**cache_kwargs) + client = client_for(app) + await client.request(method, "/item", headers=headers) + # Something stored under the key must not leak into a bypassed answer. + await BackendProxy.get().set( + KEY, CacheEntry(fingerprint='W/"x"', content=b"{}", stored_at=START), ttl=TTL + ) + + clock.now = START + 5 + response = await client.request(method, "/item", headers=headers) + + assert calls["count"] == 2 + assert "age" not in response.headers + + +async def test_legacy_entry_without_stored_at_sends_no_age(clock, client_for): + app, calls = _app() + client = client_for(app) + legacy = CacheEntry(fingerprint='W/"legacy"', content=b'{"value": 0}') + await BackendProxy.get().set(KEY, legacy, ttl=TTL) + + hit = await client.get("/item") + not_modified = await client.get("/item", headers={"If-None-Match": 'W/"legacy"'}) + + assert calls["count"] == 0 + assert hit.json() == {"value": 0} + assert "age" not in hit.headers + assert not_modified.status_code == 304 + assert "age" not in not_modified.headers + + +async def test_handler_age_header_is_not_replayed(clock, client_for): + """A handler's own Age describes its response, not the stored copy.""" + app = FastAPI() + + @app.get("/item") + @cache(ttl=TTL) + async def item() -> Response: + return Response(b"x", headers={"Age": "999"}) + + client = client_for(app) + await client.get("/item") + entry = await BackendProxy.get().get(KEY) + assert entry is not None + assert entry.headers is None + + clock.now = START + 3 + response = await client.get("/item") + + assert response.headers.get_list("age") == ["3"] + + +def test_age_headers_without_a_ttl_is_not_clamped(clock): + entry = CacheEntry(fingerprint="f", content=b"", stored_at=START) + clock.now = START + 10_000.5 + + assert cache_module._age_headers(entry, None) == {"age": "10000"} + assert cache_module._age_headers(entry, 60) == {"age": "60"} + + +# --- codec ------------------------------------------------------------------- + + +def test_codec_round_trips_stored_at(): + entry = CacheEntry(fingerprint="f", content=b"body", stored_at=START + 0.25) + + assert decode_entry(encode_entry(entry)) == entry + + +def test_codec_decodes_a_document_without_stored_at_as_none(): + legacy = json.dumps( + { + "fingerprint": 'W/"abc"', + "content": "hello", + "media_type": "text/plain", + "status_code": 200, + "headers": None, + } + ) + + entry = decode_entry(legacy) + + assert entry is not None + assert entry.stored_at is None + assert entry.content == b"hello" + + +@pytest.mark.parametrize("value", ["1800000000", True, None, [1]]) +def test_codec_reads_a_malformed_stored_at_as_unknown(value): + raw = json.dumps({"fingerprint": "f", "content": "", "stored_at": value}) + + entry = decode_entry(raw) + + assert entry is not None + assert entry.stored_at is None + + +@pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) +def test_codec_reads_a_non_finite_stored_at_as_unknown(value): + """Only stdlib json parses these; orjson rejects the whole document.""" + assert codec._stored_at(value) is None + + +def test_codec_reads_an_integer_stored_at(): + raw = json.dumps({"fingerprint": "f", "content": "", "stored_at": 1_800_000_000}) + + entry = decode_entry(raw) + + assert entry is not None + assert entry.stored_at == START + + +def test_counter_entries_carry_no_stored_at(): + assert counter_entry(3).stored_at is None + assert encode_entry(counter_entry(3)) == b"3" + + +# --- every backend ----------------------------------------------------------- + + +async def _check_backend_serves_age( + backend: BaseCacheBackend, clock: _Clock, client_for +) -> None: + BackendProxy.set(backend) + app, calls = _app() + client = client_for(app) + try: + etag = (await client.get("/item")).headers["etag"] + stored = await backend.get(KEY) + assert stored is not None + assert stored.stored_at == START + + clock.now = START + 9 + hit = await client.get("/item") + not_modified = await client.get("/item", headers={"If-None-Match": etag}) + + assert calls["count"] == 1 + assert hit.headers["age"] == "9" + assert not_modified.status_code == 304 + assert not_modified.headers["age"] == "9" + finally: + await backend.delete(KEY) + + +async def test_memory_backend_serves_age(clock, client_for): + backend = MemoryBackend() + try: + await _check_backend_serves_age(backend, clock, client_for) + finally: + await backend.aclose() + + +@requires_redis +async def test_redis_backend_serves_age(clock, client_for): + from fastapi_cachex.backends import AsyncRedisCacheBackend + + backend = AsyncRedisCacheBackend( + host=REDIS_HOST, port=REDIS_PORT, key_prefix=f"cachex_age_{uuid.uuid4().hex}:" + ) + await _check_backend_serves_age(backend, clock, client_for) + + +@requires_memcached +async def test_memcached_backend_serves_age(clock, client_for): + from fastapi_cachex.backends import MemcachedBackend + + backend = MemcachedBackend( + servers=[MEMCACHED_SERVER], key_prefix=f"cachex_age_{uuid.uuid4().hex}:" + ) + await _check_backend_serves_age(backend, clock, client_for)