From afc94f9625cdb3880ef7eaf1071c850a1019976f Mon Sep 17 00:00:00 2001 From: allen0099 Date: Wed, 30 Sep 2026 01:24:29 +0000 Subject: [PATCH] fix(cache)!: store and replay repeated response headers line by line CacheEntry.headers becomes a tuple of (name, value) pairs in the order sent instead of dict[str, str] | None, so a header the handler sends more than once (several Link lines, a custom header read with getlist()) comes back from the cache as sent, and a 304 repeats every Vary line. Values are no longer joined, so one holding a comma stays one line. The constructor still accepts a dict, any iterable of pairs, or None, and rejects anything but (str, str) pairs. Redis and Memcached write the headers as a JSON list of [name, value] and still decode the 0.3.x object; malformed headers make the entry a miss. A hit sets its own Content-Type, Content-Length, ETag, Cache-Control and Age, even when an entry built outside @cache carries them. add_vary() puts the missing names on a new Vary line: Starlette's add_vary_header rewrote the first line and dropped the others, which lost a Vary line whenever @cache(vary=...) or the session middleware added a name. BREAKING CHANGE: CacheEntry.headers is a tuple of (name, value) pairs; code that reads it as a dict must iterate the pairs. 0.3.x cannot serve an entry 0.4.0 wrote with headers, which matters only for keys both versions share (a custom key_builder that does not call build_cache_key()). Closes #105 --- changelog.d/105.changed.md | 9 ++ docs/CACHE_FLOW.md | 43 +++--- docs/HTTP_CACHING.md | 2 +- docs/MIGRATING_0_4.md | 6 +- fastapi_cachex/backends/codec.py | 6 +- fastapi_cachex/cache.py | 89 ++++++----- fastapi_cachex/headers.py | 13 +- fastapi_cachex/types.py | 43 +++++- i18n/zh-TW/docs/CACHE_FLOW.md | 41 ++--- i18n/zh-TW/docs/HTTP_CACHING.md | 2 +- i18n/zh-TW/docs/MIGRATING_0_4.md | 6 +- tests/test_cache_age.py | 2 +- tests/test_cache_repeated_headers.py | 220 +++++++++++++++++++++++++++ tests/test_cache_status_headers.py | 9 +- tests/test_cache_vary.py | 5 +- 15 files changed, 403 insertions(+), 93 deletions(-) create mode 100644 changelog.d/105.changed.md create mode 100644 tests/test_cache_repeated_headers.py diff --git a/changelog.d/105.changed.md b/changelog.d/105.changed.md new file mode 100644 index 0000000..750c220 --- /dev/null +++ b/changelog.d/105.changed.md @@ -0,0 +1,9 @@ +**A header the handler sends more than once is cached and replayed line by +line.** `CacheEntry.headers` is a tuple of `(name, value)` pairs in the order +sent instead of `dict[str, str] | None`, so several `Link` lines, or a custom +header read with `getlist()`, come back from the cache as they were sent, and a +`304` repeats every `Vary` line; `vary=` adds its names on a new line instead +of rewriting the first one. Building a `CacheEntry` still accepts a +`dict`; code that reads `headers` as a `dict` needs updating. Redis and +Memcached still decode entries with the old header object. See the +[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#cache-entry-headers). diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 4ca3c2d..555209f 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -207,7 +207,10 @@ entry = CacheEntry( content=b'{"data": "response"}', # raw response bytes media_type="application/json", status_code=200, # replayed with the original status code - headers={"Vary": "Accept-Encoding"}, # headers sent back on replay + headers=( + ("link", "; rel=preload"), + ("link", "; rel=preload"), + ), # header lines sent back on replay, in order stored_at=1702650540.5, # epoch seconds when @cache stored it; drives Age ) ``` @@ -226,45 +229,48 @@ warning that the cache is per process. ```python if request.method != "GET": - return await handler() # no cache, no Cache-Control + return await handler() # no cache, no Cache-Control if no_store: - return await render() # no read, no write + return await render() # no read, no write bypass = private or not ttl # Authorization header, a session the middleware loaded, or non-empty request.session credential = None if private or public else request_credential(request) header = private_header if credential else decorator_header # for every answer below if bypass or (credential and not cache_authorized): - response, etag = await render() # backend neither read nor written + response, etag = await render() # backend neither read nor written return not_modified(...) if etag_matches(client_etag, etag) else response cache_key = key_builder(request) + vary_components(request) # built only here -entry = await backend.get(cache_key) # expired entries are already skipped here +entry = await backend.get(cache_key) # expired entries are already skipped here if client_etag and no_cache: - fresh = await render() # no-cache: always re-render first + fresh = await render() # no-cache: always re-render first if etag_matches(client_etag, fresh.etag): - return not_modified(...) # 304 + return not_modified(...) # 304 elif client_etag and entry and etag_matches(client_etag, entry.fingerprint): 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 + hit = 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, ..., - **age_headers(entry, ttl)}, # Age: now - stored_at, clamped to 0..ttl ) + for name, value in entry.headers: # every stored line, in order + hit.headers.append(name, value) + hit.headers["ETag"] = entry.fingerprint # then ETag, Cache-Control and + ... # Age: now - stored_at, clamped to 0..ttl + return hit -response, body, etag = await render() # miss (reused if no-cache already rendered) +response, body, etag = await render() # miss (reused if no-cache already rendered) if not is_cacheable_status(response.status_code): - return response # non-2xx: returned as-is, not written + return response # non-2xx: returned as-is, not written if etag is None: - return response # streaming/file: no ETag, not written + return response # streaming/file: no ETag, not written if marked_private_or_no_store(response) or "set-cookie" in response.headers: - return response # one caller's response: not written + return response # one caller's response: not written if not entry or entry.fingerprint != etag: await backend.set(cache_key, CacheEntry(..., stored_at=time.time()), ttl=ttl) return response @@ -346,7 +352,7 @@ intermediate cache would lose those fields after revalidation (RFC 9110 content=b"...", media_type="application/json", status_code=200, - headers=None, + headers=(), stored_at=1702650540.5, ), expiry=1702650600.5, # epoch seconds; None means never expires @@ -375,7 +381,7 @@ 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 } ``` @@ -387,6 +393,9 @@ and the standard library `json` otherwise: 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. +- `headers` is a list of `[name, value]` lines, so a header sent more than once + keeps every line. The object 0.3.x wrote (one value per name) still decodes. + Anything else there makes the whole entry a miss. - 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 @@ -502,7 +511,7 @@ class CacheEntry: content: bytes # raw response bytes media_type: str | None = None status_code: int = 200 # replayed as-is - headers: dict[str, str] | None = None # sent back on replay + headers: tuple[tuple[str, str], ...] = () # (name, value) lines sent back on replay stored_at: float | None = None # epoch seconds when @cache stored it; drives Age diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 3b74320..bd649dc 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -90,7 +90,7 @@ a matching `If-None-Match` gets a 304. When a cached entry is valid (within TTL): -- **Default behavior**: Returns the cached content directly, with the status code and headers the handler originally produced, without re-executing the endpoint handler +- **Default behavior**: Returns the cached content directly, with the status code and headers the handler originally produced (every line of a header sent more than once), without re-executing the endpoint handler - **With `If-None-Match` header**: Returns HTTP 304 Not Modified if the ETag matches - **With `no-cache` directive**: Forces revalidation with fresh content before deciding on 304 - **With `private=True`**: Nothing is read from or written to the shared backend; the handler runs every time and only `If-None-Match` revalidation applies diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md index 6460e2e..0dfbbf8 100644 --- a/docs/MIGRATING_0_4.md +++ b/docs/MIGRATING_0_4.md @@ -43,7 +43,7 @@ Every warning below names the setting to change and links to its issue. `FutureW | `UserSessionDep` requires a user | [#127](https://github.com/allen0099/FastAPI-CacheX/issues/127) | No | [UserSessionDep](#user-session-dep) | | `memcache` extra removed | [#202](https://github.com/allen0099/FastAPI-CacheX/issues/202) | No | [memcache extra](#memcache-extra) | | `BaseCacheBackend.delete()` returns `bool` | [#71](https://github.com/allen0099/FastAPI-CacheX/issues/71) | No | [delete() return value](#backend-delete) | -| `CacheEntry.headers` becomes a list of pairs | [#105](https://github.com/allen0099/FastAPI-CacheX/issues/105) | No | [Repeated headers](#cache-entry-headers) | +| `CacheEntry.headers` becomes a tuple of pairs | [#105](https://github.com/allen0099/FastAPI-CacheX/issues/105) | No | [Repeated headers](#cache-entry-headers) | | HTTP cache key format | [#271](https://github.com/allen0099/FastAPI-CacheX/issues/271), [#270](https://github.com/allen0099/FastAPI-CacheX/issues/270), [#269](https://github.com/allen0099/FastAPI-CacheX/issues/269), [#266](https://github.com/allen0099/FastAPI-CacheX/issues/266), [#265](https://github.com/allen0099/FastAPI-CacheX/issues/265), [#72](https://github.com/allen0099/FastAPI-CacheX/issues/72) | No | [Cache keys](#cache-keys) | | Conditional session writes | [#128](https://github.com/allen0099/FastAPI-CacheX/issues/128) | No | [Session writes](#session-writes) | @@ -273,7 +273,7 @@ What to change: ### Repeated headers {#cache-entry-headers} -0.4.0 stores every line of a header the handler sends more than once (several `Link` headers, say) instead of only the last one ([#105](https://github.com/allen0099/FastAPI-CacheX/issues/105)). `CacheEntry.headers` becomes an ordered list of `(name, value)` pairs instead of `dict[str, str]`. This affects custom backends and code that builds or reads `CacheEntry`: +0.4.0 stores every line of a header the handler sends more than once (several `Link` headers, say) instead of only the last one ([#105](https://github.com/allen0099/FastAPI-CacheX/issues/105)). `CacheEntry.headers` becomes a tuple of `(name, value)` pairs in the order sent, instead of `dict[str, str] | None`; no headers is `()`. A replayed value is no longer joined or split, so a value holding a comma stays one line. This affects custom backends and code that reads `CacheEntry`. Code that builds one can keep passing a `dict` (or any iterable of pairs, or `None`): the constructor converts it, though a type checker flags the `dict`. ```python # Before @@ -283,7 +283,7 @@ entry.headers["link"] [value for name, value in entry.headers if name == "link"] ``` -0.4.0 still reads entries written by 0.3.x, but 0.3.x cannot read entries written by 0.4.0. In a rolling deploy where both versions share a backend, clear the HTTP cache once all instances run 0.4.0 (or keep the old instances away from the shared cache during the rollout). Whether the stored format carries a version marker instead is not decided yet. +Redis and Memcached store the headers as a JSON list of `[name, value]` lines. 0.4.0 still decodes the object 0.3.x wrote, but 0.3.x fails with a `500` on an entry 0.4.0 wrote with any header. For `@cache` with the default key builder, or one that calls `build_cache_key()`, a rolling deploy needs nothing extra: the [cache key format](#cache-keys) changes in the same release, so neither version looks up an HTTP entry the other wrote. A custom `key_builder` that builds the key itself gives the same key in both versions; for such a route, keep 0.3.x instances off the shared backend during the rollout, or switch the builder to `build_cache_key()` first. The same applies to an application that stores `CacheEntry` values under its own keys and shares them across versions. ### Monitoring routes {#add-routes} diff --git a/fastapi_cachex/backends/codec.py b/fastapi_cachex/backends/codec.py index f06a8d3..94bf934 100644 --- a/fastapi_cachex/backends/codec.py +++ b/fastapi_cachex/backends/codec.py @@ -46,7 +46,7 @@ def encode_entry(entry: CacheEntry) -> bytes: "content": entry.content.decode("latin-1"), "media_type": entry.media_type, "status_code": entry.status_code, - "headers": entry.headers, + "headers": [[name, value] for name, value in entry.headers], "stored_at": entry.stored_at, }, ) @@ -72,7 +72,9 @@ def decode_entry(raw: str | bytes | None) -> CacheEntry | None: Documents written before entries carried a status code and headers simply lack those keys and decode to a plain ``200`` with no extra headers; those - written before entries carried ``stored_at`` decode with ``None``. + written before entries carried ``stored_at`` decode with ``None``. Headers + are a list of ``[name, value]`` lines; the object 0.3.x wrote (one value + per name) is still read. """ if raw is None: return None diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index d6f9c67..c96017b 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -8,6 +8,7 @@ import warnings from collections.abc import Awaitable from collections.abc import Callable +from collections.abc import Iterable from collections.abc import Mapping from collections.abc import Sequence from functools import partial @@ -48,6 +49,7 @@ from .types import CACHE_KEY_SEPARATOR from .types import CacheEntry from .types import CacheKeyBuilder +from .types import HeaderPairs from .types import escape_key_component from .types import log_ref @@ -529,14 +531,23 @@ def _is_cacheable_status(status_code: int) -> bool: ) -def _cacheable_headers(response: Response) -> dict[str, str] | None: - """The handler's own headers worth storing, or None when there are none.""" - headers = { - key: value - for key, value in response.headers.items() - if key.lower() not in _UNCACHEABLE_HEADERS - } - return headers or None +def _cacheable_headers(response: Response) -> HeaderPairs: + """The handler's own header lines worth storing, in the order sent. + + Every line is kept, so a header sent more than once (several ``Link`` + lines) is replayed line by line rather than as its last value. + """ + return tuple( + (name, value) + for name, value in response.headers.items() + if name.lower() not in _UNCACHEABLE_HEADERS + ) + + +def _append_headers(response: Response, headers: Iterable[tuple[str, str]]) -> None: + """Add every ``(name, value)`` line to ``response``, duplicates included.""" + for name, value in headers: + response.headers.append(name, value) # Fields RFC 9110 §15.4.5 asks a 304 to repeat from the 200 it stands in for. @@ -567,15 +578,13 @@ def _age_headers(entry: CacheEntry, ttl: int | None) -> dict[str, str]: 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: - return {} - return { - key: value - for key, value in headers.items() - if key.lower() in _REVALIDATION_HEADERS - } +def _revalidation_headers(headers: Iterable[tuple[str, str]]) -> HeaderPairs: + """The lines of a response's headers that a 304 must repeat.""" + return tuple( + (name, value) + for name, value in headers + if name.lower() in _REVALIDATION_HEADERS + ) def _media_type_of(response: Response) -> str | None: @@ -713,27 +722,25 @@ 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, + headers: Iterable[tuple[str, str]] = (), 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 + ``headers`` is the header lines the 200 for this resource would have + carried, every line of a repeated field included; 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. ``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, - headers={ - **_revalidation_headers(headers), - "ETag": etag, - "Cache-Control": cache_control, - **(age or {}), - }, - ) + response = Response(status_code=HTTP_304_NOT_MODIFIED) + _append_headers(response, _revalidation_headers(headers)) + response.headers["ETag"] = etag + response.headers["Cache-Control"] = cache_control + response.headers.update(age or {}) + return response # Response directives by which the handler says its response belongs to one @@ -1252,7 +1259,7 @@ async def serve(*args: Any, **kwargs: Any) -> Response: _cache_control_for( response, response_cache_control, private_cache_control ), - response.headers, + response.headers.items(), ) response.headers["ETag"] = etag logger.debug("Bypassed the backend; path=%s", req.url.path) @@ -1311,7 +1318,7 @@ async def serve(*args: Any, **kwargs: Any) -> Response: response_cache_control, private_cache_control, ), - current_response.headers, + current_response.headers.items(), ) # Compare with cached ETag - if match, return 304 @@ -1335,17 +1342,25 @@ async def serve(*args: Any, **kwargs: Any) -> Response: # valid cached copy directly (cache hit without running the handler) if cached_data and not no_cache: logger.debug("Cache HIT (TTL valid); key=%s", cache_key) - return Response( + hit = Response( content=cached_data.content, status_code=cached_data.status_code, media_type=cached_data.media_type, - headers={ - **(cached_data.headers or {}), - "ETag": cached_data.fingerprint, - "Cache-Control": response_cache_control, - **_age_headers(cached_data, ttl), - }, ) + # Framing and validator headers come from this response, not + # the entry: an entry built outside @cache may still carry them. + _append_headers( + hit, + ( + (name, value) + for name, value in cached_data.headers + if name.lower() not in _UNCACHEABLE_HEADERS + ), + ) + hit.headers["ETag"] = cached_data.fingerprint + hit.headers["Cache-Control"] = response_cache_control + hit.headers.update(_age_headers(cached_data, ttl)) + return hit if current_response is None or current_etag is None: # Retrieve the current response if not already done diff --git a/fastapi_cachex/headers.py b/fastapi_cachex/headers.py index 33fe63a..82814c1 100644 --- a/fastapi_cachex/headers.py +++ b/fastapi_cachex/headers.py @@ -6,9 +6,11 @@ def add_vary(headers: MutableHeaders, names: list[str]) -> None: """Add header names to ``Vary``, keeping the values already there. - Starlette's ``add_vary_header`` appends unconditionally, so names the - response already varies on (compared case-insensitively) are skipped, as - is everything when it already varies on ``*``. + The missing names go on one new ``Vary`` line, so every line already + there is kept (Starlette's ``add_vary_header`` rewrites the first line and + drops the others). Names the response already varies on (compared + case-insensitively) are skipped, as is everything when it already varies + on ``*``. Args: headers: Mutable response headers to write to @@ -21,7 +23,10 @@ def add_vary(headers: MutableHeaders, names: list[str]) -> None: } if "*" in present: return + missing: list[str] = [] for name in names: if name.lower() not in present: - headers.add_vary_header(name) + missing.append(name) present.add(name.lower()) + if missing: + headers.append("Vary", ", ".join(missing)) diff --git a/fastapi_cachex/types.py b/fastapi_cachex/types.py index b60c8ed..a20d0ed 100644 --- a/fastapi_cachex/types.py +++ b/fastapi_cachex/types.py @@ -3,6 +3,8 @@ import hashlib import re from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Mapping from dataclasses import dataclass from fastapi import Request @@ -57,6 +59,35 @@ def log_ref(value: str) -> str: # Status replayed for entries stored before ``CacheEntry`` carried a status code. DEFAULT_STATUS_CODE = 200 +# Response header lines as ``(name, value)`` pairs, in the order sent. +HeaderPairs = tuple[tuple[str, str], ...] + + +def _header_pairs( + headers: Mapping[str, str] | Iterable[tuple[str, str]] | None, +) -> HeaderPairs: + """``headers`` as a tuple of ``(name, value)`` pairs. + + A mapping (the ``dict`` of 0.3.x) gives its items, so a name appears once. + + Raises: + TypeError: If an item is not a ``tuple`` or ``list`` of two ``str``. + """ + if headers is None or headers == (): + return () + items = headers.items() if isinstance(headers, Mapping) else headers + pairs = tuple(items) + for pair in pairs: + if ( + not isinstance(pair, (tuple, list)) + or len(pair) != 2 # noqa: PLR2004 + or not isinstance(pair[0], str) + or not isinstance(pair[1], str) + ): + msg = "CacheEntry.headers must hold (str, str) pairs" + raise TypeError(msg) + return tuple((name, value) for name, value in pairs) + @dataclass class CacheEntry: @@ -66,6 +97,12 @@ class CacheEntry: headers, so entries built by older callers (and documents written by older releases) keep their previous behaviour. + ``headers`` holds every header line the response is replayed with, as + ``(name, value)`` pairs in the order sent, so a header sent more than once + (several ``Link`` lines, say) is kept line by line. A mapping or any + iterable of pairs is accepted and converted to a tuple; ``None`` means no + headers. + ``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 @@ -77,9 +114,13 @@ class CacheEntry: content: bytes media_type: str | None = None status_code: int = DEFAULT_STATUS_CODE - headers: dict[str, str] | None = None + headers: HeaderPairs = () stored_at: float | None = None + def __post_init__(self) -> None: + """Store ``headers`` as a tuple of pairs, whatever it was given as.""" + self.headers = _header_pairs(self.headers) + @dataclass class CacheItem: diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index d81c6b8..d064dbf 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -153,7 +153,10 @@ entry = CacheEntry( content=b'{"data": "response"}', # 原始回應位元組 media_type="application/json", status_code=200, # 以原本的狀態碼重播 - headers={"Vary": "Accept-Encoding"}, # 重播時送回的標頭 + headers=( + ("link", "; rel=preload"), + ("link", "; rel=preload"), + ), # 重播時依序送回的標頭行 stored_at=1702650540.5, # @cache 儲存它時的 epoch 秒數;用來計算 Age ) ``` @@ -166,45 +169,48 @@ TTL 不儲存在 `CacheEntry` 中:過期由後端負責(`MemoryBackend` 將 ```python if request.method != "GET": - return await handler() # 不快取,不帶 Cache-Control + return await handler() # 不快取,不帶 Cache-Control if no_store: - return await render() # 不讀取,不寫入 + return await render() # 不讀取,不寫入 bypass = private or not ttl # Authorization 標頭、中介軟體載入的 Session,或不是空的 request.session credential = None if private or public else request_credential(request) header = private_header if credential else decorator_header # 用於下方每個回應 if bypass or (credential and not cache_authorized): - response, etag = await render() # 既不讀取也不寫入後端 + response, etag = await render() # 既不讀取也不寫入後端 return not_modified(...) if etag_matches(client_etag, etag) else response cache_key = key_builder(request) + vary_components(request) # 只在這裡建立 -entry = await backend.get(cache_key) # 過期的項目已在此略過 +entry = await backend.get(cache_key) # 過期的項目已在此略過 if client_etag and no_cache: - fresh = await render() # no-cache:一律先重新產生 + fresh = await render() # no-cache:一律先重新產生 if etag_matches(client_etag, fresh.etag): - return not_modified(...) # 304 + return not_modified(...) # 304 elif client_etag and entry and etag_matches(client_etag, entry.fingerprint): return not_modified(..., age_headers(entry, ttl)) # 304,handler 不執行 if entry and not no_cache: - return Response( # 200,handler 不執行 + hit = Response( # 200,handler 不執行 content=entry.content, status_code=entry.status_code, media_type=entry.media_type, - headers={**(entry.headers or {}), "ETag": entry.fingerprint, ..., - **age_headers(entry, ttl)}, # Age:now - stored_at,限制在 0..ttl ) + for name, value in entry.headers: # 依序加上儲存的每一行 + hit.headers.append(name, value) + hit.headers["ETag"] = entry.fingerprint # 接著設定 ETag、Cache-Control 與 + ... # Age:now - stored_at,限制在 0..ttl + return hit -response, body, etag = await render() # 未命中(若 no-cache 已產生過則直接沿用) +response, body, etag = await render() # 未命中(若 no-cache 已產生過則直接沿用) if not is_cacheable_status(response.status_code): - return response # 非 2xx:原樣回傳,不寫入 + return response # 非 2xx:原樣回傳,不寫入 if etag is None: - return response # 串流/檔案:沒有 ETag,不寫入 + return response # 串流/檔案:沒有 ETag,不寫入 if marked_private_or_no_store(response) or "set-cookie" in response.headers: - return response # 屬於單一呼叫者的回應:不寫入 + return response # 屬於單一呼叫者的回應:不寫入 if not entry or entry.fingerprint != etag: await backend.set(cache_key, CacheEntry(..., stored_at=time.time()), ttl=ttl) return response @@ -252,7 +258,7 @@ If-None-Match: * → 只要資源存在就相符 → 304 content=b"...", media_type="application/json", status_code=200, - headers=None, + headers=(), stored_at=1702650540.5, ), expiry=1702650600.5, # epoch 秒數;None 表示永不過期 @@ -280,13 +286,14 @@ 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` 且沒有額外標頭。沒有 `stored_at` 的項目(0.3.9 以前)解碼後為 `stored_at=None`,送出時不帶 `Age` 標頭。 +- `headers` 是 `[name, value]` 行組成的清單,因此重複送出的標頭會保留每一行。0.3.x 寫入的物件(每個名稱一個值)仍可解碼。其他任何形式都會讓整個項目視為未命中。 - 任何解碼失敗(損壞的 JSON、缺少欄位、型別錯誤)都視為**快取未命中**,回傳 `None` 而不是拋出例外。 - `increment()` 會留下一個**單純的整數**(由 Redis/Memcached 的 INCR 系列指令寫入);它會解碼成 fingerprint 為 `counter` 的 `CacheEntry`。 @@ -394,7 +401,7 @@ class CacheEntry: content: bytes # 原始回應位元組 media_type: str | None = None status_code: int = 200 # 原樣重播 - headers: dict[str, str] | None = None # 重播時送回 + headers: tuple[tuple[str, str], ...] = () # 重播時送回的 (name, value) 標頭行 stored_at: float | None = None # @cache 儲存它時的 epoch 秒數;用來計算 Age diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 62b3c7c..280c975 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -65,7 +65,7 @@ async def items(): ... 當快取項目仍有效(在 TTL 內)時: -- **預設行為**:直接回傳快取的內容,連同 handler 當初產生的狀態碼與標頭,不會重新執行端點的 handler +- **預設行為**:直接回傳快取的內容,連同 handler 當初產生的狀態碼與標頭(重複送出的標頭會保留每一行),不會重新執行端點的 handler - **帶有 `If-None-Match` 標頭**:ETag 相符時回傳 HTTP 304 Not Modified - **使用 `no-cache` 指令**:先以新產生的內容強制重新驗證,再決定是否回 304 - **使用 `private=True`**:不從共用後端讀取,也不寫入;每次都執行 handler,只有 `If-None-Match` 重新驗證有效 diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md index 3e8cc73..4ec452f 100644 --- a/i18n/zh-TW/docs/MIGRATING_0_4.md +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -43,7 +43,7 @@ filterwarnings = [ | `UserSessionDep` 需要使用者 | [#127](https://github.com/allen0099/FastAPI-CacheX/issues/127) | 否 | [UserSessionDep](#user-session-dep) | | 移除 `memcache` extra | [#202](https://github.com/allen0099/FastAPI-CacheX/issues/202) | 否 | [memcache extra](#memcache-extra) | | `BaseCacheBackend.delete()` 回傳 `bool` | [#71](https://github.com/allen0099/FastAPI-CacheX/issues/71) | 否 | [delete() 的回傳值](#backend-delete) | -| `CacheEntry.headers` 改為成對值的清單 | [#105](https://github.com/allen0099/FastAPI-CacheX/issues/105) | 否 | [重複的標頭](#cache-entry-headers) | +| `CacheEntry.headers` 改為成對值的 tuple | [#105](https://github.com/allen0099/FastAPI-CacheX/issues/105) | 否 | [重複的標頭](#cache-entry-headers) | | HTTP 快取鍵格式 | [#271](https://github.com/allen0099/FastAPI-CacheX/issues/271)、[#270](https://github.com/allen0099/FastAPI-CacheX/issues/270)、[#269](https://github.com/allen0099/FastAPI-CacheX/issues/269)、[#266](https://github.com/allen0099/FastAPI-CacheX/issues/266)、[#265](https://github.com/allen0099/FastAPI-CacheX/issues/265)、[#72](https://github.com/allen0099/FastAPI-CacheX/issues/72) | 否 | [快取鍵](#cache-keys) | | 有條件的 Session 寫入 | [#128](https://github.com/allen0099/FastAPI-CacheX/issues/128) | 否 | [Session 寫入](#session-writes) | @@ -272,7 +272,7 @@ CacheManagerProxy.set(CacheManager(lock=True)) ### 重複的標頭 {#cache-entry-headers} -0.4.0 會儲存 handler 重複送出的標頭(例如多個 `Link` 標頭)的每一行,而不是只保留最後一行([#105](https://github.com/allen0099/FastAPI-CacheX/issues/105))。`CacheEntry.headers` 從 `dict[str, str]` 改為有順序的 `(name, value)` 成對值清單。這會影響自訂後端,以及建立或讀取 `CacheEntry` 的程式碼: +0.4.0 會儲存 handler 重複送出的標頭(例如多個 `Link` 標頭)的每一行,而不是只保留最後一行([#105](https://github.com/allen0099/FastAPI-CacheX/issues/105))。`CacheEntry.headers` 從 `dict[str, str] | None` 改為依送出順序排列的 `(name, value)` 成對值 tuple;沒有標頭時為 `()`。重播的值不再合併或拆分,因此含有逗號的值仍是一行。這會影響自訂後端,以及讀取 `CacheEntry` 的程式碼。建立 `CacheEntry` 的程式碼仍可傳入 `dict`(或任何成對值的 iterable,或 `None`):建構函式會轉換它,但型別檢查器會標出 `dict`。 ```python # 修改前 @@ -282,7 +282,7 @@ entry.headers["link"] [value for name, value in entry.headers if name == "link"] ``` -0.4.0 仍可讀取 0.3.x 寫入的項目,但 0.3.x 無法讀取 0.4.0 寫入的項目。在兩個版本共用同一個後端的滾動部署中,請在所有實例都執行 0.4.0 後清除一次 HTTP 快取(或在部署期間讓舊實例不要使用共用快取)。改由儲存格式帶版本標記是否可行,尚未決定。 +Redis 與 Memcached 以 `[name, value]` 行組成的 JSON 清單儲存標頭。0.4.0 仍能解碼 0.3.x 寫入的物件,但 0.3.x 遇到 0.4.0 寫入且帶有任何標頭的項目時會回應 `500`。使用預設 key builder 或呼叫 `build_cache_key()` 的 builder 時,`@cache` 的滾動部署不需要額外處理:[快取鍵格式](#cache-keys)在同一個版本中改變,因此兩個版本都不會查到對方寫入的 HTTP 項目。自行組出鍵的自訂 `key_builder` 在兩個版本中產生相同的鍵;對這樣的路由,請在部署期間讓 0.3.x 實例不要使用共用後端,或先把 builder 改為使用 `build_cache_key()`。以自己的鍵儲存 `CacheEntry` 並在不同版本間共用的應用程式也是如此。 ### 監控路由 {#add-routes} diff --git a/tests/test_cache_age.py b/tests/test_cache_age.py index 6b1c083..d89d371 100644 --- a/tests/test_cache_age.py +++ b/tests/test_cache_age.py @@ -233,7 +233,7 @@ async def item() -> Response: await client.get("/item") entry = await BackendProxy.get().get(KEY) assert entry is not None - assert entry.headers is None + assert entry.headers == () clock.now = START + 3 response = await client.get("/item") diff --git a/tests/test_cache_repeated_headers.py b/tests/test_cache_repeated_headers.py new file mode 100644 index 0000000..9ad2717 --- /dev/null +++ b/tests/test_cache_repeated_headers.py @@ -0,0 +1,220 @@ +"""A header sent more than once is stored and replayed line by line (#105).""" + +import json + +import pytest +from fastapi import FastAPI +from fastapi import Response +from fastapi.testclient import TestClient +from starlette.datastructures import MutableHeaders + +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.headers import add_vary +from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.types import CacheEntry + +LINES = [ + (b"link", b"; rel=preload"), + (b"x-tag", b"b"), + (b"link", b"; rel=preload"), + (b"x-tag", b"a, c"), +] + + +def _key(path: str) -> str: + return f"http:v2|GET|testserver|{path}|" + + +def _response() -> Response: + response = Response(content="ok", media_type="text/plain") + response.raw_headers.extend(LINES) + return response + + +def _app(**options: object) -> tuple[TestClient, list[int]]: + app = FastAPI() + calls: list[int] = [] + + @app.get("/r") + @cache(ttl=60, **options) # type: ignore[arg-type] + async def endpoint() -> Response: + calls.append(1) + return _response() + + return TestClient(app), calls + + +async def test_every_line_is_stored_in_order() -> None: + client, _ = _app() + + client.get("/r") + entry = await BackendProxy.get().get(_key("/r")) + + assert entry is not None + assert entry.headers == tuple((n.decode(), v.decode()) for n, v in LINES) + + +def test_a_hit_replays_every_line() -> None: + client, calls = _app() + + miss = client.get("/r") + hit = client.get("/r") + + assert calls == [1] + for response in (miss, hit): + assert response.headers.get_list("link") == [ + "; rel=preload", + "; rel=preload", + ] + # Not joined: a value holding a comma stays one line. + assert response.headers.get_list("x-tag") == ["b", "a, c"] + + +def _vary_app(**options: object) -> TestClient: + app = FastAPI() + + @app.get("/v") + @cache(ttl=60, **options) # type: ignore[arg-type] + async def endpoint() -> Response: + response = Response(content="ok", media_type="text/plain") + response.raw_headers.extend([(b"vary", b"Accept"), (b"vary", b"Origin")]) + return response + + return TestClient(app) + + +@pytest.mark.parametrize( + "options", + [{}, {"no_cache": True}, {"private": True}], + ids=["stored-entry", "no-cache", "private"], +) +def test_a_304_repeats_every_vary_line(options: dict[str, object]) -> None: + client = _vary_app(**options) + etag = client.get("/v").headers["etag"] + + not_modified = client.get("/v", headers={"If-None-Match": etag}) + + assert not_modified.status_code == 304 + assert not_modified.headers.get_list("vary") == ["Accept", "Origin"] + + +@pytest.mark.parametrize( + "options", + [{}, {"no_cache": True}, {"private": True}], + ids=["stored-entry", "no-cache", "private"], +) +def test_vary_names_are_added_after_every_line(options: dict[str, object]) -> None: + """``vary=`` adds its names on a new line instead of rewriting the first.""" + client = _vary_app(vary=["X-Tenant"], **options) + + miss = client.get("/v") + hit = client.get("/v") + not_modified = client.get("/v", headers={"If-None-Match": miss.headers["etag"]}) + + assert not_modified.status_code == 304 + for response in (miss, hit, not_modified): + assert response.headers.get_list("vary") == ["Accept", "Origin", "X-Tenant"] + + +async def test_a_hit_sets_its_own_framing_and_validators() -> None: + """An entry built outside ``@cache`` cannot duplicate or override them.""" + client, calls = _app() + await BackendProxy.get().set( + _key("/r"), + CacheEntry( + 'W/"stored"', + b"ok", + media_type="text/plain", + headers=( + ("etag", 'W/"other"'), + ("cache-control", "no-store"), + ("content-type", "text/html"), + ("content-length", "99"), + ("x-kept", "1"), + ), + stored_at=None, + ), + ) + + hit = client.get("/r") + + assert calls == [] + assert hit.headers.get_list("etag") == ['W/"stored"'] + assert hit.headers.get_list("cache-control") == ["max-age=60"] + assert hit.headers.get_list("content-type") == ["text/plain; charset=utf-8"] + assert hit.headers.get_list("content-length") == ["2"] + assert hit.headers["x-kept"] == "1" + + +def test_the_codec_round_trips_repeated_lines() -> None: + entry = CacheEntry("f", b"x", headers=(("link", ""), ("link", ""))) + + raw = encode_entry(entry) + + assert json.loads(raw)["headers"] == [["link", ""], ["link", ""]] + assert decode_entry(raw) == entry + + +def test_the_codec_reads_the_0_3_object() -> None: + raw = json.dumps({"fingerprint": "f", "content": "x", "headers": {"x-a": "1"}}) + + entry = decode_entry(raw) + + assert entry is not None + assert entry.headers == (("x-a", "1"),) + + +@pytest.mark.parametrize( + "headers", + [ + "x-a", + 5, + [["x-a"]], + [["x-a", 1]], + [[1, "v"]], + {"x-a": 1}, + ["ab"], + [{"x": 1, "y": 2}], + ], + ids=[ + "string", + "number", + "short-pair", + "int-value", + "int-name", + "int-in-object", + "two-char-string", + "object-pair", + ], +) +def test_the_codec_treats_malformed_headers_as_a_miss(headers: object) -> None: + raw = json.dumps({"fingerprint": "f", "content": "x", "headers": headers}) + + assert decode_entry(raw) is None + + +@pytest.mark.parametrize( + "headers", + [None, {"x-a": "1"}, [("x-a", "1")], [["x-a", "1"]], iter([("x-a", "1")])], + ids=["none", "mapping", "list-of-tuples", "list-of-lists", "iterator"], +) +def test_cache_entry_converts_headers_to_a_tuple(headers: object) -> None: + entry = CacheEntry("f", b"x", headers=headers) # type: ignore[arg-type] + + expected = () if headers is None else (("x-a", "1"),) + assert entry.headers == expected + + +def test_cache_entry_rejects_a_non_str_value() -> None: + with pytest.raises(TypeError, match=r"\(str, str\) pairs"): + CacheEntry("f", b"x", headers=[("x-a", 1)]) # type: ignore[arg-type] + + +def test_add_vary_adds_no_line_when_every_name_is_there() -> None: + headers = MutableHeaders(raw=[(b"vary", b"Accept"), (b"vary", b"Origin")]) + + add_vary(headers, ["origin", "ACCEPT"]) + + assert headers.getlist("vary") == ["Accept", "Origin"] diff --git a/tests/test_cache_status_headers.py b/tests/test_cache_status_headers.py index 10c8454..bdd600b 100644 --- a/tests/test_cache_status_headers.py +++ b/tests/test_cache_status_headers.py @@ -224,7 +224,6 @@ def test_decode_entry_defaults_pre_v2_documents(): content=b"hello", media_type="text/plain", status_code=200, - headers=None, ) @@ -243,7 +242,7 @@ async def test_redis_reads_pre_v2_documents(): assert entry is not None assert entry.status_code == 200 - assert entry.headers is None + assert entry.headers == () assert entry.content == b"legacy" await backend.clear() @@ -264,7 +263,7 @@ async def test_memcached_reads_pre_v2_documents(): assert entry is not None assert entry.status_code == 200 - assert entry.headers is None + assert entry.headers == () assert entry.content == b"legacy" await flush_memcached(backend) @@ -280,7 +279,7 @@ async def test_redis_round_trips_status_and_headers(): content=b"body", media_type="text/plain", status_code=203, - headers={"X-Total-Count": "42"}, + headers=(("link", ""), ("x-total-count", "42"), ("link", "")), ) await backend.set("v2-key", entry) @@ -301,7 +300,7 @@ async def test_memcached_round_trips_status_and_headers(): content=b"body", media_type="text/plain", status_code=203, - headers={"X-Total-Count": "42"}, + headers=(("link", ""), ("x-total-count", "42"), ("link", "")), ) await backend.set("v2-key", entry) diff --git a/tests/test_cache_vary.py b/tests/test_cache_vary.py index 7dafde5..e9abe7d 100644 --- a/tests/test_cache_vary.py +++ b/tests/test_cache_vary.py @@ -221,7 +221,10 @@ async def greet(response: Response) -> dict[str, str]: hit = client.get("/greet") for response in (miss, hit): - assert response.headers.get_list("vary") == ["Accept-Language, Origin, Accept"] + assert response.headers.get_list("vary") == [ + "Accept-Language, Origin", + "Accept", + ] def test_vary_star_is_left_alone() -> None: