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
11 changes: 11 additions & 0 deletions changelog.d/326.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
**`@cache` warns once when a credential makes a route bypass the backend.**
The first request that bypasses the shared backend on a route because it
carried an `Authorization` header, a session token or non-empty
`request.session` data is logged at `WARNING` on the `fastapi_cachex.cache`
logger, once per route and credential kind, naming the route template and the
credential (never its value). The message points to `public=True` for a
response that is the same for every user (which also sends `Cache-Control:
public` downstream) and to `cache_authorized=True` with a per-user
`key_builder` for a per-user one. Each bypass is still logged at `DEBUG`.
`docs/HTTP_CACHING.md` gains a "Requests with credentials" section on choosing
between the two.
51 changes: 50 additions & 1 deletion docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ A response that belongs to one caller is never stored either (#296):
included. A token that resolves to no session (forged, expired) does not
count, so it cannot be used to skip the cache. Before 0.3.9 only
`Authorization` did, and a plain `@cache` on a route that read the session
served one visitor's response to the next (#319).
served one visitor's response to the next (#319). The first bypass on each
route is logged at `WARNING` (see
[Requests with credentials](#requests-with-credentials)).
- **The handler's own `Cache-Control` contains `private` or `no-store`**
(as whole directives, in any case). The response is served but not stored,
and the handler's header is sent unchanged instead of the decorator's.
Expand All @@ -157,6 +159,53 @@ route's response model (declared or inferred from the return annotation, with
the `response_model_*` options), the route's `status_code` applies, and the
status and headers set on an injected `response: Response` parameter are kept.

### Requests with credentials

A single-page app that sends `Authorization` on every request, or a site where
every visitor has a session, gets no cache hits at all on a plain `@cache`
route: each request bypasses the backend (see above). Pick the option that
matches what the handler returns:

- **The response is the same for every user** (a product list, a public
article): set `public=True`. Requests with `Authorization` or a session then
read and write the backend like any other. Note that `public=True` also
changes the header sent downstream to `Cache-Control: public, ...`, which
tells a CDN or reverse proxy that it may store the response even though the
request carried credentials. Only use it when that is true.
- **The response is per user** (a profile, a cart, a dashboard): set
`cache_authorized=True` together with a `key_builder` that puts the verified
caller's identity into the key, so each user gets their own entry (see
[Authenticated endpoints](#authenticated-endpoints)). Without the identity in
the key, one user's response is served to the next. If you do not need a
server-side cache for it, leave both options unset (or use `private=True`)
and let only the browser cache it.

So that a 0% hit rate does not go unnoticed, the first request that bypasses
a route because of a credential is logged once at `WARNING` on the
`fastapi_cachex.cache` logger, naming the route template (such as
`'/items/{item_id}'`), the credential that caused it (an `Authorization`
header, a session token, or non-empty `request.session` data) and the two
options above:

```text
@cache bypassed the shared backend for route '/products': the request carried an Authorization header, so the response is not cached and is sent with Cache-Control: private. If the response is the same for every user, set @cache(public=True) (this also sends Cache-Control: public, so shared caches downstream may store it). If it is per user, set cache_authorized=True with a key_builder that puts the verified caller's identity into the key. Logged once per route and credential; each bypass is logged at DEBUG.
```

The warning is logged once per route and credential kind for the life of the
process (so once per worker), never with a header value or token. Every
bypass is still logged at `DEBUG`. When the bypass is what you want, set
`private=True` on the route, which bypasses the backend without a warning, or
raise the logger's level:

```python
import logging

logging.getLogger("fastapi_cachex.cache").setLevel(logging.ERROR)
```

That also hides the backend-failure warnings described below, so prefer
`private=True` where it fits.

### When the backend fails

`@cache` fails open. If the backend raises while reading, for example because
Expand Down
70 changes: 69 additions & 1 deletion fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import hashlib
import inspect
import logging
import threading
import warnings
from collections.abc import Awaitable
from collections.abc import Callable
Expand Down Expand Up @@ -254,6 +255,66 @@ def _request_credential(request: Request) -> str | None:
)


# How the one-time bypass warning names each `_request_credential` result.
_CREDENTIAL_DESCRIPTIONS = {
"Authorization header": "an Authorization header",
"Session": "a session token (header, bearer token or cookie)",
"Session data": "non-empty session data (request.session)",
}

_BYPASS_WARNING = (
"@cache bypassed the shared backend for route %r: the request carried %s, "
"so the response is not cached and is sent with Cache-Control: private. "
"If the response is the same for every user, set @cache(public=True) "
"(this also sends Cache-Control: public, so shared caches downstream may "
"store it). If it is per user, set cache_authorized=True with a key_builder "
"that puts the verified caller's identity into the key. Logged once per "
"route and credential; each bypass is logged at DEBUG."
)


class _BypassWarner:
"""Log the credential bypass at ``WARNING`` once per route and credential.

One instance lives in each ``@cache``-decorated function, so the state
goes away with the function (and a test's fresh app starts with none).
The route is keyed by its template (``/items/{item_id}``), never by the
requested path, so the set stays bounded by the routes the function is
registered on and client-chosen paths cannot grow it.
"""

def __init__(self, fallback_name: str) -> None:
self._fallback_name = fallback_name
self._warned: set[tuple[str, str]] = set()
# The wrapper runs on an event loop, where check-then-add cannot be
# interleaved, but one decorated function can serve apps running on
# several loops in different threads (several servers in one process,
# each `TestClient`), and a set's check-then-add is not atomic across
# threads. The lock keeps "once" exact; it costs nothing on the hot
# path, which returns before taking it once the pair is recorded.
self._lock = threading.Lock()

def __call__(self, request: Request, credential: str) -> None:
route_path = getattr(request.scope.get("route"), "path", None)
# Without a matched route (not reachable through FastAPI's router),
# name the handler: the requested path is client input and unbounded.
route = route_path if isinstance(route_path, str) else self._fallback_name
pair = (route, credential)
if pair in self._warned:
return
with self._lock:
if pair in self._warned:
return
self._warned.add(pair)
# Only the route template and the credential kind: never a header
# value or token. `%r` escapes anything unusual in the template.
logger.warning(
_BYPASS_WARNING,
route,
_CREDENTIAL_DESCRIPTIONS.get(credential, credential),
)


def default_key_builder(request: Request) -> str:
"""Default cache key builder function: ``build_cache_key(request)``.

Expand Down Expand Up @@ -859,6 +920,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.
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.
RFC 9111 would also allow reuse under ``must-revalidate``, but
``must_revalidate=True`` does not lift the bypass: only this
explicit opt-in or ``public`` does. Set this only when
Expand Down Expand Up @@ -1002,6 +1066,9 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable:
# routes skip the backend like private ones. `ttl=0` is included, since
# `max-age=0` allows no reuse either.
bypass_backend = private or not ttl
warn_bypass = _BypassWarner(
f"{getattr(func, '__module__', '?')}.{getattr(func, '__qualname__', '?')}"
)

@wraps(func)
async def serve(*args: Any, **kwargs: Any) -> Response:
Expand Down Expand Up @@ -1050,12 +1117,13 @@ async def serve(*args: Any, **kwargs: Any) -> Response:
else _request_credential(req)
)
authorized_bypass = credential is not None
if authorized_bypass:
if credential is not None:
logger.debug(
"%s present; bypassing the backend for path=%s",
credential,
req.url.path,
)
warn_bypass(req, credential)

# A private response belongs to exactly one user, so it must never
# be read from or written to the shared backend — the default cache
Expand Down
25 changes: 24 additions & 1 deletion i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,37 @@ async def items(): ...

屬於單一呼叫者的回應同樣不會被儲存(#296):

- **請求帶有 `Authorization` 或 Session。** 依照 RFC 9111 §3.5 對共用快取的要求,這類請求會像 `private=True` 一樣繞過後端:不讀取也不寫入,handler 照常執行,`If-None-Match` 與新產生的回應比對。回應(以及 304)會以 `private` 取代 `public` 送出,並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓 CDN 或代理也不會儲存它。`public=True` 的路由不受此限,設定 `cache_authorized=True` 的路由也一樣;後者是給包含呼叫者身分的 key builder 使用的明確選項(見[需驗證身分的端點](#authenticated-endpoints))。`must_revalidate=True` 不會解除繞過:RFC 9111 允許共用快取在 `must-revalidate` 下重複使用這類回應,但本函式庫要求明確選擇啟用。請求「帶有 Session」是指 `FastAPICacheXSessionMiddleware`(或已棄用的 `SessionMiddleware`)為它載入了 Session(權杖來自標頭、Bearer 權杖或 Session Cookie 皆可,有沒有使用者都算),或在任何 Session 中介軟體(包括 Starlette 的)下 `request.session` 不是空的。解析不出 Session 的權杖(偽造、過期)不算,因此無法用來略過快取。0.3.9 以前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。
- **請求帶有 `Authorization` 或 Session。** 依照 RFC 9111 §3.5 對共用快取的要求,這類請求會像 `private=True` 一樣繞過後端:不讀取也不寫入,handler 照常執行,`If-None-Match` 與新產生的回應比對。回應(以及 304)會以 `private` 取代 `public` 送出,並保留裝飾器的其他指令(`no_cache` 路由則為 `private, no-cache`),讓 CDN 或代理也不會儲存它。`public=True` 的路由不受此限,設定 `cache_authorized=True` 的路由也一樣;後者是給包含呼叫者身分的 key builder 使用的明確選項(見[需驗證身分的端點](#authenticated-endpoints))。`must_revalidate=True` 不會解除繞過:RFC 9111 允許共用快取在 `must-revalidate` 下重複使用這類回應,但本函式庫要求明確選擇啟用。請求「帶有 Session」是指 `FastAPICacheXSessionMiddleware`(或已棄用的 `SessionMiddleware`)為它載入了 Session(權杖來自標頭、Bearer 權杖或 Session Cookie 皆可,有沒有使用者都算),或在任何 Session 中介軟體(包括 Starlette 的)下 `request.session` 不是空的。解析不出 Session 的權杖(偽造、過期)不算,因此無法用來略過快取。0.3.9 以前只有 `Authorization` 會觸發繞過,讀取 Session 的路由只加上 `@cache` 時,會把一位訪客的回應提供給下一位(#319)。每個路由第一次繞過時會以 `WARNING` 等級記錄(見[帶有憑證的請求](#requests-with-credentials))。
- **handler 自己的 `Cache-Control` 含有 `private` 或 `no-store`**(完整指令,不分大小寫)。回應照常送出但不儲存,而且 handler 的標頭會原樣送出,不會被裝飾器的標頭取代。
- **回應設定了 cookie。** 回應照常送出(包含 `Set-Cookie`),但不儲存;它(以及 304)會以 `private` 取代 `public` 送出並保留其他指令,讓下游的共用快取也不會儲存它。

後兩種情況下,該鍵下已儲存的項目保持不變,而找到有效項目的請求仍會在 handler 執行前由該項目回應。handler 自己的 `private`/`no-store` 標頭一律優先,`no_store=True` 仍只送出 `no-store`。每次略過都會以 `DEBUG` 等級記錄。

handler 回傳一般資料而非 `Response` 時,得到的處理與沒有 `@cache` 時相同:回傳值會經過路由的 response model 驗證與過濾(明確宣告的,或由回傳型別註記推斷,並套用 `response_model_*` 選項),套用路由的 `status_code`,而在注入的 `response: Response` 參數上設定的狀態碼與標頭也會保留。

### 帶有憑證的請求 {#requests-with-credentials}

每個請求都送出 `Authorization` 的單頁應用程式,或每位訪客都有 Session 的網站,在只加上 `@cache` 的路由上完全不會命中快取:每個請求都會繞過後端(見上文)。請依 handler 回傳的內容選擇:

- **每位使用者得到相同的回應**(商品列表、公開文章):設定 `public=True`。帶有 `Authorization` 或 Session 的請求就會像其他請求一樣讀寫後端。注意 `public=True` 也會把送往下游的標頭改為 `Cache-Control: public, ...`,告訴 CDN 或反向 proxy 即使請求帶有憑證也可以儲存這個回應。只有在這確實成立時才使用它。
- **回應依使用者而不同**(個人資料、購物車、儀表板):設定 `cache_authorized=True`,並搭配把已驗證的呼叫者身分放進鍵的 `key_builder`,讓每位使用者擁有自己的項目(見[需驗證身分的端點](#authenticated-endpoints))。鍵中沒有身分時,一位使用者的回應會提供給下一位。若不需要伺服器端快取,兩個選項都不要設定(或使用 `private=True`),只讓瀏覽器快取它。

為了不讓 0% 的命中率無人察覺,路由第一次因憑證而繞過後端時,會在 `fastapi_cachex.cache` logger 上以 `WARNING` 等級記錄一次,內容包含路由樣板(例如 `'/items/{item_id}'`)、造成繞過的憑證(`Authorization` 標頭、Session 權杖,或不是空的 `request.session` 資料),以及上述兩個選項:

```text
@cache bypassed the shared backend for route '/products': the request carried an Authorization header, so the response is not cached and is sent with Cache-Control: private. If the response is the same for every user, set @cache(public=True) (this also sends Cache-Control: public, so shared caches downstream may store it). If it is per user, set cache_authorized=True with a key_builder that puts the verified caller's identity into the key. Logged once per route and credential; each bypass is logged at DEBUG.
```

這個警告在行程的生命週期內,每個路由與憑證種類只記錄一次(因此每個 worker 一次),而且絕不包含標頭值或權杖。每次繞過仍會以 `DEBUG` 等級記錄。若繞過正是你要的,請在路由上設定 `private=True`,它會繞過後端而不發出警告;或是提高 logger 的等級:

```python
import logging

logging.getLogger("fastapi_cachex.cache").setLevel(logging.ERROR)
```

這也會隱藏下文所述的後端錯誤警告,因此適用時請優先使用 `private=True`。

### 後端發生錯誤時 {#when-the-backend-fails}

`@cache` 採取 fail open。讀取時後端拋出錯誤(例如 Redis 或 Memcached 無法連線),該請求會被當成快取未命中,照常執行 handler。儲存回應時拋出錯誤(例如回應超過 Memcached 的項目大小上限,預設為 1 MB),回應會照常送出,只是不會被儲存。兩種情況都會在 `fastapi_cachex.cache` logger 記錄一則警告,因此後端中斷不會讓有快取的路由變成 500;負載會轉到你的 handler 上,請留意這些警告。
Expand Down
Loading
Loading