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
54 changes: 33 additions & 21 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,26 @@ 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)
↓
Build the cache key: method|||host|||path|||query_params
↓
no-store? ── yes → run the handler, neither read nor write the cache,
│ respond with Cache-Control: no-store
↓ no
private, or Authorization/session without public/cache_authorized?
private, no positive ttl, or Authorization/session without public/cache_authorized?
── yes → run the handler; compare If-None-Match to decide 304 or 200
│ (the shared backend is neither read nor written; for
│ Authorization or a session, Cache-Control says private
│ instead of public)
│ (the shared backend is neither read nor written and the key
│ builder does not run; for Authorization or a session,
│ Cache-Control says private instead of public)
↓ no
Read the backend entry
Build the cache key: key_builder (default method|||host|||path|||query_params),
plus one name=value component per vary header
↓
Read the backend entry (with fail_open, a backend error counts as a miss)
↓
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 with Age
└─ no match / no header → continue
↓
Cached entry exists, ttl is set, and no-cache is off?
Cached entry exists and no-cache is off?
├─ yes → respond with the cached content (including the stored status code
│ and headers, plus Age; the handler does **not** run)
└─ no → run the handler
Expand All @@ -43,10 +44,13 @@ Cached entry exists, ttl is set, and no-cache is off?
│ entry is left alone; a private/no-store header is kept)
└─ regular response → set the ETag; write to the backend only if it
differs from the existing entry's ETag
(with fail_open, a failed write is logged
and the response served unstored)
↓
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)
Set-Cookie response gets private instead of public); with vary, add the
names to Vary on every GET response
```

## Detailed steps
Expand Down Expand Up @@ -96,9 +100,10 @@ and `X-Session-Token` a non-empty value is written as `sha256:<hex digest>`,
so no token appears in the key.

Query parameters are joined in the order the request sent them
(`str(request.query_params)`) and are **not sorted**, so `?page=1&limit=10` and
`?limit=10&page=1` are two separate cache entries. If you want them treated as
one, pass a custom `key_builder` that normalises the query string.
(`str(request.query_params)`) and are **not sorted** by default, so
`?page=1&limit=10` and `?limit=10&page=1` are two separate cache entries. To
treat them as one, set `@cache(sort_query=True)`, which orders the parameters
by name first (see [Cache keys](HTTP_CACHING.md#cache-keys)).

The key format keeps each dimension cached independently:

Expand All @@ -116,7 +121,7 @@ The decorator arguments control both the server-side behaviour and the

```python
# Change how the server-side cache is used
@cache(no_cache=True) # Always re-run the handler (revalidate); entries are still written
@cache(no_cache=True) # Always re-run the handler (revalidate); entries are still written with a positive ttl
@cache(no_store=True) # Never read or write the cache

# Normal caching behaviour
Expand All @@ -133,8 +138,11 @@ The decorator arguments control both the server-side behaviour and the
```

Arguments are validated when the decorator is applied, and a `CacheXError` is
raised if `public` and `private` are both set, or if only one of `stale` /
`stale_ttl` is given.
raised if `public` and `private` are both set, if only one of `stale` /
`stale_ttl` is given, if `ttl` is not an `int`, is negative or is larger than
`MAX_TTL`, if `vary` is not a list of header field names, if `sort_query` is
not a `bool` or is combined with a custom `key_builder`, or if `key_builder` is
an `async` callable.

The header value is built once per decorated route:

Expand Down Expand Up @@ -208,11 +216,14 @@ if request.method != "GET":
if no_store:
return await render() # no read, no write

authorized = "authorization" in request.headers and not (public or cache_authorized)
if private or not ttl or authorized:
bypass = private or not ttl
# Authorization header, a session the middleware loaded, or non-empty request.session
credential = None if bypass or public or cache_authorized else request_credential(request)
if bypass or credential:
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

if client_etag and no_cache:
Expand Down Expand Up @@ -475,6 +486,7 @@ class CacheEntry:
media_type: str | None = None
status_code: int = 200 # replayed as-is
headers: dict[str, str] | None = None # sent back on replay
stored_at: float | None = None # epoch seconds when @cache stored it; drives Age


@dataclass
Expand All @@ -486,7 +498,7 @@ class CacheItem:
`headers` stores the headers the handler set itself, excluding fields that must
be recomputed for every response or must not be replayed: `Set-Cookie`,
`Content-Length`, `Transfer-Encoding`, `Connection`, `Date`, `ETag`,
`Cache-Control` and `Content-Type` (`Content-Type` is restored from
`Cache-Control`, `Content-Type` and `Age` (`Content-Type` is restored from
`media_type`; storing both would emit the header twice).

Counters (`backend.increment()`) are also represented as a `CacheEntry`: the
Expand Down Expand Up @@ -525,9 +537,9 @@ 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, and they are **not
sorted**. `/users?page=1` and `/users?page=2` are different entries, and so are
`?a=1&b=2` and `?b=2&a=1`.
A: Because the cache key includes the query parameters, and by default they are
**not sorted**. `/users?page=1` and `/users?page=2` are different entries, and
so are `?a=1&b=2` and `?b=2&a=1` unless the route sets `sort_query=True`.

**Q: How does MemoryBackend work across multiple processes?**
A: It doesn't. Each process has its own cache; use Redis in production.
Expand Down
11 changes: 8 additions & 3 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ A response that belongs to one caller is never stored either (#296):
caller's identity (see [Authenticated endpoints](#authenticated-endpoints)).
`must_revalidate=True` does not lift the bypass: RFC 9111 would let a shared
cache reuse such a response under `must-revalidate`, but the library
requires an explicit opt-in.
requires an explicit opt-in. Routes that skip the backend anyway
(`private=True`, or no positive `ttl`) do not check for credentials and
send their own `Cache-Control` unchanged.
A request has a session when `FastAPICacheXSessionMiddleware` (or the
deprecated `SessionMiddleware`) loaded one for it, from the token header, a
bearer token or the session cookie, with or without a user, or when
Expand Down Expand Up @@ -398,7 +400,7 @@ lower-cased, the value trimmed (repeated header lines joined with `,`), and a
missing header treated as an empty one. The components are escaped like the
rest of the key and come after whatever the `key_builder` returns, so `vary`
and a custom key builder compose:
`GET|||example.com|||/greeting|||||||tenant-1|||accept-language=de` for
`GET|||example.com|||/greeting||||||tenant-1|||accept-language=de` for
`key_builder` returning `build_cache_key(request, "tenant-1")`. Routes without
`vary` keep their keys.

Expand All @@ -424,7 +426,7 @@ holds the full hex SHA-256 of the value (trimmed and joined as above) instead
of the value:

```
GET|||example.com|||/me|||||||authorization=sha256:3f0a…(64 hex digits)
GET|||example.com|||/me||||||authorization=sha256:3f0a…(64 hex digits)
```

The same token always gives the same digest, so it hits its own entry, and two
Expand Down Expand Up @@ -710,6 +712,9 @@ add_routes(
`content_type` is always `"bytes"` and is kept for compatibility; read
`media_type` instead.

Both routes list only route entries (keys in the `method|||host|||path|||query`
format); `CacheManager`, session, state and lock keys are skipped.

> [!WARNING]
> **These routes have no authentication of their own.** `include_in_schema=False`
> only hides them from the OpenAPI document; anyone who guesses the path can read
Expand Down
7 changes: 5 additions & 2 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,9 @@ def cache(
backend as ``private=True`` does (RFC 9111 §3.5), unless
``public`` is set, and its response is sent with ``private``.
A token that does not resolve to a session does not count.
Routes that skip the backend anyway (``private=True``, or no
positive ``ttl``) do not check for credentials, so their
``Cache-Control`` is sent unchanged.
The first such bypass is logged at ``WARNING`` once per route
and credential kind (the route template and the kind, never the
value), since it otherwise leaves the route with no cache hits.
Expand Down Expand Up @@ -1340,8 +1343,8 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
_age_headers(cached_data, ttl),
)

# If we don't have If-None-Match header, check if we have a valid cached copy
# and can serve it directly (cache hit without ETag comparison)
# No 304 was sent (no If-None-Match, or it did not match): serve a
# 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(
Expand Down
8 changes: 5 additions & 3 deletions fastapi_cachex/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,8 @@ def add_routes(
async def get_cached_hits() -> CacheHitsResponse:
"""List the cached route entries.

Splits every cached key into method, host, path, query and any
Splits every cached route key (other keys are skipped) into
method, host, path, query and any
extra components a key builder appended, with its
ETag and expiry, plus counts of valid and expired entries and the
distinct cached paths. Cache hits are not counted.
Expand All @@ -359,8 +360,9 @@ async def get_cached_hits() -> CacheHitsResponse:
async def get_cached_records() -> CachedRecordsResponse:
"""Display currently cached records.

Returns all currently cached records in the cache backend with their
content information and expiry details.
Returns every route entry in the cache backend (keys in the
``method|||host|||path|||query`` format; other keys are skipped) with
its content information and expiry details.

Returns:
CachedRecordsResponse containing cached records and statistics
Expand Down
38 changes: 23 additions & 15 deletions i18n/zh-TW/docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,25 @@ HTTP 請求抵達
@cache 裝飾器攔截請求(只有 GET 會經過快取;其他所有
方法直接執行 handler,也不會帶上 Cache-Control 標頭)
↓
建立快取鍵:method|||host|||path|||query_params
↓
no-store? ── 是 → 執行 handler,既不讀取也不寫入快取,
│ 回應帶上 Cache-Control: no-store
↓ 否
private,或帶有 Authorization/Session 且未設定 public/cache_authorized?
private、沒有正數的 ttl,或帶有 Authorization/Session 且未設定 public/cache_authorized?
── 是 → 執行 handler;比對 If-None-Match 決定回傳 304 或 200
│ (共用後端既不讀取也不寫入;Authorization 或 Session 的情況下
│ Cache-Control 以 private 取代 public)
│ (共用後端既不讀取也不寫入,key builder 也不會執行;
│ Authorization 或 Session 的情況下 Cache-Control 以 private 取代 public)
↓ 否
讀取後端項目
建立快取鍵:key_builder(預設為 method|||host|||path|||query_params),
再為每個 vary 標頭附加一個 name=value 段
↓
讀取後端項目(fail_open 時,後端錯誤視為未命中)
↓
請求帶有 If-None-Match?
├─ 且為 no-cache → 先執行 handler 計算目前的 ETag;相符 → 304
├─ 其他情況 → 與快取項目的 ETag 比對;相符 → 帶 Age 的 304
└─ 不相符/沒有此標頭 → 繼續
↓
快取項目存在、已設定 ttl,且未啟用 no-cache?
快取項目存在,且未啟用 no-cache?
├─ 是 → 以快取內容回應(包含儲存的狀態碼
│ 與標頭,並加上 Age;handler **不會**執行)
└─ 否 → 執行 handler
Expand All @@ -39,10 +40,13 @@ private,或帶有 Authorization/Session 且未設定 public/cache_authoriz
│ private/no-store 標頭保留原樣)
└─ 一般回應 → 設定 ETag;只有與既有項目的 ETag
不同時才寫入後端
(fail_open 時,寫入失敗會記錄警告,
回應照常送出但不儲存)
↓
在回應中附加 Cache-Control(非 2xx 回應回傳時不帶此標頭,
handler 自己送出的 private/no-store Cache-Control 永遠不會被取代,
設定 Set-Cookie 的回應則以 private 取代 public)
設定 Set-Cookie 的回應則以 private 取代 public);設定 vary 時,
會把這些名稱加入每個 GET 回應的 Vary
```

## 詳細步驟 {#detailed-steps}
Expand Down Expand Up @@ -76,7 +80,7 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25`

自訂的 `key_builder` 可以用 `build_cache_key(request, *components)` 在查詢字串之後加入其他段;這些段以同樣方式編碼,`clear_path()` 也仍會比對路徑(見 [HTTP 快取](HTTP_CACHING.md#adding-components-to-the-key)中的「在鍵中加入其他段」)。`@cache(vary=[...])` 會在 key builder 回傳的鍵之後,為每個列出的請求標頭附加一個 `name=value` 段,並把這些名稱加入回應的 `Vary` 標頭(見 [HTTP 快取](HTTP_CACHING.md#varying-on-request-headers)中的「依請求標頭區分」)。對於憑證標頭 `Authorization`、`Proxy-Authorization`、`Cookie` 與 `X-Session-Token`,非空的值會寫成 `sha256:<十六進位摘要>`,因此鍵中不會出現任何權杖。

查詢參數依請求送出的順序串接(`str(request.query_params)`),**不會排序**,因此 `?page=1&limit=10` 與 `?limit=10&page=1` 是兩個不同的快取項目。若希望兩者視為同一個,請傳入自訂的 `key_builder` 將查詢字串正規化。
查詢參數依請求送出的順序串接(`str(request.query_params)`),預設**不會排序**,因此 `?page=1&limit=10` 與 `?limit=10&page=1` 是兩個不同的快取項目。若希望兩者視為同一個,請設定 `@cache(sort_query=True)`,先依名稱排序參數(見 [HTTP 快取](HTTP_CACHING.md#cache-keys)中的「快取鍵」)。

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

Expand All @@ -91,7 +95,7 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25`

```python
# 改變伺服器端快取的使用方式
@cache(no_cache=True) # 每次都重新執行 handler(重新驗證);項目仍會寫入
@cache(no_cache=True) # 每次都重新執行 handler(重新驗證);ttl 為正數時項目仍會寫入
@cache(no_store=True) # 永不讀取或寫入快取

# 一般快取行為
Expand All @@ -107,7 +111,7 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25`
@cache(ttl=60, stale="error", stale_ttl=300) # stale-if-error=300
```

參數會在套用裝飾器時驗證;若同時設定 `public` 與 `private`,或只提供 `stale`/`stale_ttl` 其中之一,會拋出 `CacheXError`。
參數會在套用裝飾器時驗證;若同時設定 `public` 與 `private`、只提供 `stale`/`stale_ttl` 其中之一、`ttl` 不是 `int`、為負數或大於 `MAX_TTL`、`vary` 不是由標頭欄位名稱組成的 list、`sort_query` 不是 `bool` 或與自訂的 `key_builder` 一起使用,或 `key_builder` 是 `async` 可呼叫物件,會拋出 `CacheXError`。

標頭值在每個被裝飾的路由上只建立一次:

Expand Down Expand Up @@ -160,11 +164,14 @@ if request.method != "GET":
if no_store:
return await render() # 不讀取,不寫入

authorized = "authorization" in request.headers and not (public or cache_authorized)
if private or not ttl or authorized:
bypass = private or not ttl
# Authorization 標頭、中介軟體載入的 Session,或不是空的 request.session
credential = None if bypass or public or cache_authorized else request_credential(request)
if bypass or credential:
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) # 過期的項目已在此略過

if client_etag and no_cache:
Expand Down Expand Up @@ -380,6 +387,7 @@ class CacheEntry:
media_type: str | None = None
status_code: int = 200 # 原樣重播
headers: dict[str, str] | None = None # 重播時送回
stored_at: float | None = None # @cache 儲存它時的 epoch 秒數;用來計算 Age


@dataclass
Expand All @@ -388,7 +396,7 @@ class CacheItem:
expiry: float | None = None # epoch 秒數;僅 MemoryBackend 使用
```

`headers` 儲存 handler 自行設定的標頭,但排除每次回應都必須重新計算或不得重播的欄位:`Set-Cookie`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Date`、`ETag`、`Cache-Control` 與 `Content-Type`(`Content-Type` 會由 `media_type` 還原;兩者都儲存會使該標頭送出兩次)。
`headers` 儲存 handler 自行設定的標頭,但排除每次回應都必須重新計算或不得重播的欄位:`Set-Cookie`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Date`、`ETag`、`Cache-Control`、`Content-Type` 與 `Age`(`Content-Type` 會由 `media_type` 還原;兩者都儲存會使該標頭送出兩次)。

計數器(`backend.increment()`)同樣以 `CacheEntry` 表示:fingerprint 一律為 `counter`,`content` 為十進位數值的位元組,因此刪除、清除與監控都以相同方式處理它們。

Expand All @@ -412,7 +420,7 @@ handler 不必自行宣告 `Request`:`@cache` 會在函式簽名中注入一

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

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

**Q:MemoryBackend 在多個行程下如何運作?** A:無法運作。每個行程都有自己的快取;正式環境請使用 Redis。

Expand Down
Loading
Loading