Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/105.changed.md
Original file line number Diff line number Diff line change
@@ -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).
43 changes: 26 additions & 17 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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", "</a.css>; rel=preload"),
("link", "</b.js>; rel=preload"),
), # header lines sent back on replay, in order
stored_at=1702650540.5, # epoch seconds when @cache stored it; drives Age
)
```
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -375,7 +381,7 @@ and the standard library `json` otherwise:
"content": "<response bytes decoded as latin-1>",
"media_type": "application/json",
"status_code": 200,
"headers": {"Vary": "Accept-Encoding"},
"headers": [["vary", "Accept-Encoding"]],
"stored_at": 1702650540.5
}
```
Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/MIGRATING_0_4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down Expand Up @@ -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
Expand All @@ -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}

Expand Down
6 changes: 4 additions & 2 deletions fastapi_cachex/backends/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
)
Expand All @@ -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
Expand Down
89 changes: 52 additions & 37 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
13 changes: 9 additions & 4 deletions fastapi_cachex/headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Loading
Loading