From 80f21681b0cfdf1e169066cabc05e8bc50559279 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 07:40:15 +0000 Subject: [PATCH] feat: warn about the 0.4.0 breaking changes in 0.3.9 0.3.9 is the last 0.3.x release, so it announces every 0.4.0 change it can: - FastAPICacheXSessionMiddleware emits a FutureWarning while its config relies on the cookie_name / cookie_https_only defaults, which become __Host-session with Secure (#256); SessionConfig emits a UserWarning for __Host-/__Secure- settings browsers refuse. - CacheManager.get_or_set() emits a FutureWarning, once per manager, while neither the call nor the manager chose lock= (#280); lock=None now means "not chosen". - get_session_manager emits a FutureWarning, once per app, when SessionManagerProxy does not hold the middleware's manager (#131). - Passing the Redis encoding option emits a DeprecationWarning (#126), and the short JWT key warning says 0.4.0 rejects it (#129). Adds a Migrating to 0.4.0 page (English and zh-TW) covering every 0.4.0 milestone item, changelog fragments, and the release notice in CHANGELOG.md. Closes #352 --- CHANGELOG.md | 2 + README.md | 5 +- changelog.d/126.deprecated.md | 5 + changelog.d/129.deprecated.md | 3 + changelog.d/131.deprecated.md | 7 + changelog.d/256.deprecated.2.md | 5 + changelog.d/256.deprecated.md | 9 + changelog.d/280.deprecated.md | 8 + docs/APP_CACHE.md | 9 +- docs/BACKENDS.md | 12 +- docs/MIGRATING_0_4.md | 356 +++++++++++++++++++ docs/SESSION.md | 32 +- examples/app_cache.py | 5 +- examples/session_api.py | 8 + examples/session_jwt.py | 8 + examples/session_jwt_claims.py | 2 + examples/session_login.py | 6 +- examples/session_redis.py | 4 + fastapi_cachex/backends/config.py | 9 +- fastapi_cachex/backends/redis.py | 77 +++- fastapi_cachex/manager.py | 41 ++- fastapi_cachex/session/config.py | 29 ++ fastapi_cachex/session/dependencies.py | 36 +- fastapi_cachex/session/middleware.py | 38 ++ fastapi_cachex/session/token_serializers.py | 3 +- i18n/zh-TW/docs/APP_CACHE.md | 9 +- i18n/zh-TW/docs/BACKENDS.md | 3 +- i18n/zh-TW/docs/MIGRATING_0_4.md | 355 ++++++++++++++++++ i18n/zh-TW/docs/SESSION.md | 23 +- i18n/zh-TW/docs/index.md | 5 +- tests/backends/test_redis.py | 58 ++- tests/session/conftest.py | 10 +- tests/session/test_0_4_0_notices.py | 239 +++++++++++++ tests/session/test_client_ip.py | 8 +- tests/session/test_get_session_manager.py | 4 + tests/session/test_hardening.py | 13 +- tests/session/test_login.py | 6 +- tests/session/test_lookup_writes.py | 4 +- tests/session/test_middleware.py | 1 + tests/session/test_starlette_middleware.py | 15 +- tests/session/test_token_response_caching.py | 7 +- tests/session/test_token_serializers.py | 1 + tests/test_cache_manager.py | 78 +++- tests/test_changelog_release.py | 9 +- zensical.toml | 1 + zensical.zh-TW.toml | 1 + 46 files changed, 1478 insertions(+), 91 deletions(-) create mode 100644 changelog.d/126.deprecated.md create mode 100644 changelog.d/129.deprecated.md create mode 100644 changelog.d/131.deprecated.md create mode 100644 changelog.d/256.deprecated.2.md create mode 100644 changelog.d/256.deprecated.md create mode 100644 changelog.d/280.deprecated.md create mode 100644 docs/MIGRATING_0_4.md create mode 100644 i18n/zh-TW/docs/MIGRATING_0_4.md create mode 100644 tests/session/test_0_4_0_notices.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bae94..f5cb611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. ## [Unreleased] +0.3.9 is the last 0.3.x release. 0.4.0 contains breaking changes; see [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/). + ## [0.3.8] - 2026-09-27 ### Added diff --git a/README.md b/README.md index 1f4d429..e3c77c3 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,9 @@ def build_report() -> dict: @app.get("/report") async def report(cache: AppCache): - # Cache any JSON value in your own code. - return await cache.get_or_set("report", build_report, ttl=300) + # Cache any JSON value in your own code. lock=True runs build_report once + # for concurrent misses (the default from 0.4.0). + return await cache.get_or_set("report", build_report, ttl=300, lock=True) ``` > [!IMPORTANT] diff --git a/changelog.d/126.deprecated.md b/changelog.d/126.deprecated.md new file mode 100644 index 0000000..bf9f343 --- /dev/null +++ b/changelog.d/126.deprecated.md @@ -0,0 +1,5 @@ +**The `encoding` option of `AsyncRedisCacheBackend` and `RedisConfig`.** 0.4.0 +removes it and reads raw bytes; entries were always UTF-8. Passing `encoding` +to `AsyncRedisCacheBackend`, or setting it on a `RedisConfig` given to +`load_from_config()`, now emits a `DeprecationWarning`. Leave it out: UTF-8 is +what you get without it. A value other than UTF-8 keeps its `RuntimeWarning`. diff --git a/changelog.d/129.deprecated.md b/changelog.d/129.deprecated.md new file mode 100644 index 0000000..db3d0fe --- /dev/null +++ b/changelog.d/129.deprecated.md @@ -0,0 +1,3 @@ +**JWT HMAC secrets shorter than the hash output.** The `UserWarning` that +`JWTTokenSerializer` emits for an `HS384` key under 48 bytes or an `HS512` key +under 64 bytes now says that 0.4.0 will reject such a key at startup. diff --git a/changelog.d/131.deprecated.md b/changelog.d/131.deprecated.md new file mode 100644 index 0000000..6a58e68 --- /dev/null +++ b/changelog.d/131.deprecated.md @@ -0,0 +1,7 @@ +**`get_session_manager` finding the manager only on `app.state`.** 0.4.0 +resolves `get_session_manager` (and `SessionManagerDep`, `ClientIPDep` and +`rotate_session_id()`, which use it) through `SessionManagerProxy` only. It now +emits a `FutureWarning`, once per app, when the proxy holds no manager or a +different one than the session middleware. Call +`SessionManagerProxy.set(session_manager)` at startup; the middleware can then +pick the manager up from the proxy. diff --git a/changelog.d/256.deprecated.2.md b/changelog.d/256.deprecated.2.md new file mode 100644 index 0000000..ada5e38 --- /dev/null +++ b/changelog.d/256.deprecated.2.md @@ -0,0 +1,5 @@ +**`SessionConfig` with a `__Host-` or `__Secure-` cookie name that browsers +refuse.** A `__Host-` name without `cookie_https_only=True`, with a +`cookie_path` other than `"/"` or with a `cookie_domain`, and a `__Secure-` name +without `cookie_https_only=True`, now emit a `UserWarning`: browsers drop such a +cookie, so the session never sticks. 0.4.0 will reject these settings. diff --git a/changelog.d/256.deprecated.md b/changelog.d/256.deprecated.md new file mode 100644 index 0000000..8d3e329 --- /dev/null +++ b/changelog.d/256.deprecated.md @@ -0,0 +1,9 @@ +**Relying on the session cookie defaults of `FastAPICacheXSessionMiddleware`.** +0.4.0 names the session cookie `__Host-session` and sets the `Secure` flag by +default, so every cookie session is logged out once on upgrade and a plain-HTTP +setup stops receiving the cookie. The middleware now emits a `FutureWarning` +when its config leaves `cookie_name` or `cookie_https_only` at the default. Set +both: `cookie_name="session", cookie_https_only=False` keeps the current cookie, +`cookie_name="__Host-session", cookie_https_only=True` switches now. Header-only +setups never send the cookie and do not warn. See "Migrating to 0.4.0" in the +docs. diff --git a/changelog.d/280.deprecated.md b/changelog.d/280.deprecated.md new file mode 100644 index 0000000..27bb446 --- /dev/null +++ b/changelog.d/280.deprecated.md @@ -0,0 +1,8 @@ +**Relying on the `lock=False` default of `CacheManager.get_or_set()`.** 0.4.0 +turns stampede protection on by default. A `get_or_set()` call that passes no +`lock=`, on a manager created without `lock=` (including the one `AppCache` +creates), now emits a `FutureWarning` once per manager. Pass `lock=False` to +keep the current behaviour or `lock=True` to opt in now, per call or to +`CacheManager(...)`; for `AppCache`, register a manager with +`CacheManagerProxy.set()`. `CacheManager(lock=None)` is now accepted and means +"not chosen". diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index 4f0a1e4..9daae06 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -17,8 +17,9 @@ async def expensive_operation(cache: AppCache): return result -# Or instantiate directly, e.g. outside of a request: -manager = CacheManager(key_prefix="myapp:", default_ttl=60) +# Or instantiate directly, e.g. outside of a request. Pass `lock` explicitly: +# its default turns from False to True in 0.4.0 (see "Stampede protection"). +manager = CacheManager(key_prefix="myapp:", default_ttl=60, lock=False) await manager.set("user:42", {"name": "Alice"}) user = await manager.get("user:42") # {"name": "Alice"} await manager.delete("user:42") @@ -121,6 +122,10 @@ manager = CacheManager(lock=True, lock_ttl=60) Ensure `lock_ttl` exceeds the expected execution time of `factory`. If `factory` outlives `lock_ttl`, the lock expires mid-run and a waiting caller may start a second computation. +### The default changes in 0.4.0 + +Stampede protection is off by default in 0.3.x and **on by default from 0.4.0**. A `get_or_set()` call that passes no `lock=`, on a manager created without `lock=` (including the one `AppCache` creates for you), emits a `FutureWarning` once per manager. Pass `lock=False` to keep the current behaviour or `lock=True` to opt in now, either per call or to `CacheManager(...)`; for `AppCache`, register your own manager with `CacheManagerProxy.set(CacheManager(lock=...))`. See [Migrating to 0.4.0](MIGRATING_0_4.md#get-or-set-lock). + ## JSON round-trip Values are stored as JSON (`json.dumps` with its defaults) and read back with diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 6d7ed91..47824e2 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -92,7 +92,6 @@ config = RedisConfig( port=6379, password=None, # SecretStr | None db=0, - encoding="utf-8", # keep UTF-8; see below socket_timeout=1.0, # seconds; applies to reads/writes socket_connect_timeout=1.0, key_prefix="fastapi_cachex:", @@ -102,11 +101,12 @@ backend = AsyncRedisCacheBackend.load_from_config(config) BackendProxy.set(backend) ``` -Keep `encoding="utf-8"`. Entries are always written as UTF-8 JSON, and the client -decodes replies with `encoding`, so any other value corrupts non-ASCII content on the way -back (with `"latin-1"`, a stored `b"\xe9"` reads back as `b"\xc3\xa9"`). The backend -emits a `RuntimeWarning` for a non-UTF-8 encoding, and the parameter will be removed in -0.4.0. +Leave `encoding` out, of both `RedisConfig` and `AsyncRedisCacheBackend`: it is deprecated +and removed in 0.4.0, and setting it at all emits a `DeprecationWarning`. Entries are always +written as UTF-8 JSON, and the client decodes replies with `encoding`, so any value other than +UTF-8 also corrupts non-ASCII content on the way back (with `"latin-1"`, a stored `b"\xe9"` +reads back as `b"\xc3\xa9"`), and the backend emits a `RuntimeWarning` for it. See +[Migrating to 0.4.0](MIGRATING_0_4.md#redis-encoding). Keep `protocol=2` unless you need RESP3 features *and* your `hiredis` build supports it (RESP3 needs hiredis >= 3.0). Redis 8.0 speaks RESP3, but an older diff --git a/docs/MIGRATING_0_4.md b/docs/MIGRATING_0_4.md new file mode 100644 index 0000000..ec496d2 --- /dev/null +++ b/docs/MIGRATING_0_4.md @@ -0,0 +1,356 @@ +# Migrating to 0.4.0 {#migrating-to-040} + +0.3.9 is the last 0.3.x release. 0.4.0 contains breaking changes, collected in the [0.4.0 milestone](https://github.com/allen0099/FastAPI-CacheX/issues?q=milestone%3A0.4.0). This page lists every one of them, what to change, and whether 0.3.9 already warns about it. Some 0.4.0 designs are not final yet; where an issue leaves a detail open, the section says what is known and what is still undecided. + +## Before you upgrade {#before-you-upgrade} + +Upgrade to 0.3.9 first and run your test suite with the library's warnings turned into errors: + +```bash +python -W error::DeprecationWarning -W error::FutureWarning -m pytest +``` + +or, with pytest's own setting: + +```toml +[tool.pytest.ini_options] +filterwarnings = [ + "error::DeprecationWarning", + "error::FutureWarning", +] +``` + +Every warning below names the setting to change and links to its issue. `FutureWarning` announces a default that changes behaviour and is shown by default; `DeprecationWarning` announces a removal; `UserWarning` flags a configuration that is already wrong today and that 0.4.0 no longer accepts. Once 0.3.9 runs without these warnings, the changes in the "Warned in 0.3.9" column are done; the rest of this page covers what no warning can detect. + +## Summary {#summary} + +| Change | Issue | Warned in 0.3.9 | Section | +|--------|-------|-----------------|---------| +| Session cookie defaults to `__Host-session` with `Secure` | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `FutureWarning` | [Session cookie](#session-cookie) | +| Contradictory `__Host-` / `__Secure-` cookie settings are rejected | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `UserWarning` | [Session cookie](#session-cookie) | +| Explicit `login()` / `logout()`, read-only `Session.user` | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | No | [Login and logout](#login-logout) | +| `get_or_set()` locks by default | [#280](https://github.com/allen0099/FastAPI-CacheX/issues/280) | `FutureWarning` | [get_or_set lock](#get-or-set-lock) | +| `get_session_manager` resolves through `SessionManagerProxy` | [#131](https://github.com/allen0099/FastAPI-CacheX/issues/131) | `FutureWarning` | [get_session_manager](#get-session-manager) | +| `add_routes()` requires `dependencies`, no content preview by default | [#298](https://github.com/allen0099/FastAPI-CacheX/issues/298) | `UserWarning` | [Monitoring routes](#add-routes) | +| Redis `encoding` option removed | [#126](https://github.com/allen0099/FastAPI-CacheX/issues/126) | `DeprecationWarning` | [Redis encoding](#redis-encoding) | +| JWT HMAC secrets shorter than the hash output are rejected | [#129](https://github.com/allen0099/FastAPI-CacheX/issues/129) | `UserWarning` | [JWT secret length](#jwt-secret) | +| `SessionMiddleware` removed | [#69](https://github.com/allen0099/FastAPI-CacheX/issues/69) | `DeprecationWarning` | [SessionMiddleware](#session-middleware) | +| `BackendProxy.get_backend()` / `set_backend()` removed | [#70](https://github.com/allen0099/FastAPI-CacheX/issues/70) | `DeprecationWarning` | [BackendProxy](#backend-proxy) | +| `CacheError` removed | [#130](https://github.com/allen0099/FastAPI-CacheX/issues/130) | `DeprecationWarning` | [CacheError](#cache-error) | +| Redis `clear_pattern()` prefix-stripped retry removed | [#125](https://github.com/allen0099/FastAPI-CacheX/issues/125) | `DeprecationWarning` | [Redis clear_pattern](#redis-clear-pattern) | +| `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) | +| 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) | +| Cookies in `token_source_priority` | [#75](https://github.com/allen0099/FastAPI-CacheX/issues/75) | No | [Token sources](#token-source-priority) | + +## Sessions {#sessions} + +### Session cookie defaults {#session-cookie} + +0.4.0 changes two `SessionConfig` defaults ([#256](https://github.com/allen0099/FastAPI-CacheX/issues/256)): `cookie_name` becomes `"__Host-session"` (from `"session"`) and `cookie_https_only` becomes `True` (from `False`). Browsers accept a `__Host-` cookie only when it is `Secure`, has `Path=/` and no `Domain`, and never from a subdomain, which removes the usual way to plant a session cookie. Two consequences: + +- Every browser holding a `session` cookie is logged out once after the upgrade, because the middleware looks for `__Host-session` instead. +- A `Secure` cookie is not sent over plain HTTP, so local development without TLS needs an explicit opt-out. + +In 0.3.9, `FastAPICacheXSessionMiddleware` emits a `FutureWarning` when its config leaves `cookie_name` or `cookie_https_only` at the default. Header-only setups (the deprecated `SessionMiddleware`, or `SessionManager` used without middleware) never send the cookie and do not warn. + +Before: + +```python +config = SessionConfig(secret_key=SECRET) +app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=SessionManager(backend, config) +) +``` + +After, keeping today's cookie (the same code works on 0.3.9 and 0.4.0, and is what plain-HTTP development needs): + +```python +config = SessionConfig( + secret_key=SECRET, cookie_name="session", cookie_https_only=False +) +``` + +After, switching now (HTTPS only): + +```python +config = SessionConfig( + secret_key=SECRET, cookie_name="__Host-session", cookie_https_only=True +) +``` + +0.4.0 also rejects contradictory settings: a `__Host-` name without `cookie_https_only=True`, with a `cookie_path` other than `"/"` or with a `cookie_domain`, and a `__Secure-` name without `cookie_https_only=True`. Browsers already refuse such a cookie, so the session never sticks; 0.3.9 emits a `UserWarning` when `SessionConfig` is built with one of them. + +### Login and logout {#login-logout} + +0.4.0 makes becoming authenticated go through one explicit API that always issues a new session ID ([#256](https://github.com/allen0099/FastAPI-CacheX/issues/256)). Known so far: + +- `login(request, user)` (in 0.3.9 already, `from fastapi_cachex.session import login`) attaches the user and rotates the ID. Use it today instead of setting `session.user` yourself. +- A logout API is added; `request.session.clear()` keeps meaning logout. +- `Session.user` becomes read-only outside these calls. Code that assigns it directly breaks. +- A login starts a new session instead of promoting the anonymous one. Which data is carried over (all by default, or a `keep=` list) is not decided yet. +- For a few seconds after a rotation the old ID resolves to the new session, so in-flight requests carrying the old token do not fail. +- `rotate_session_id()` stays for privilege changes without a new user, possibly renamed. + +The exact signatures are not final, so 0.3.9 does not warn about them. An application that decides "logged in" from its own `request.session` keys (`request.session.get("user_id")`) is outside what the library can see; use the library's identity (`session.user`, `AuthenticatedSession`) or rotate the ID yourself. + +Before: + +```python +session, _ = await manager.get_session(token) +session.user = SessionUser(user_id=user_id) # read-only from 0.4.0 +await manager.update_session(session) +``` + +After (works in 0.3.9, rotates the session ID too): + +```python +from fastapi_cachex.session import login + +await login(request, SessionUser(user_id=user_id)) +``` + +### get_session_manager {#get-session-manager} + +`get_session_manager` (and `SessionManagerDep`, `ClientIPDep` and `rotate_session_id()`, which use it) currently returns the manager the middleware stored on `app.state`. 0.4.0 resolves it through `SessionManagerProxy` only, like `BackendProxy` and `CacheManagerProxy` ([#131](https://github.com/allen0099/FastAPI-CacheX/issues/131)). In 0.3.9 it emits a `FutureWarning`, once per app, when the proxy holds no manager or a different one. + +Before: + +```python +session_manager = SessionManager(backend, config) +app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=session_manager) +``` + +After: + +```python +session_manager = SessionManager(backend, config) +SessionManagerProxy.set(session_manager) +# The middleware picks the manager up from the proxy. +app.add_middleware(FastAPICacheXSessionMiddleware) +``` + +### UserSessionDep {#user-session-dep} + +`UserSessionDep` is an alias of `SessionDep` and admits anonymous sessions. In 0.4.0 it requires a session with a user, like `AuthenticatedSession` ([#127](https://github.com/allen0099/FastAPI-CacheX/issues/127)), so anonymous requests to routes that use it get `401`. 0.3.9 does not warn: a type alias has no hook that runs when it is used, and a warning on import would fire for everyone. Pick the dependency that says what you mean: + +```python +# Before +async def cart(session: UserSessionDep): ... + + +# After: anonymous sessions allowed (today's behaviour) +async def cart(session: SessionDep): ... + + +# After: a logged-in user required (0.4.0's UserSessionDep) +async def profile(session: AuthenticatedSession): ... +``` + +### SessionMiddleware {#session-middleware} + +The header-only `SessionMiddleware` is removed ([#69](https://github.com/allen0099/FastAPI-CacheX/issues/69)); 0.3.x already emits a `DeprecationWarning`. Use `FastAPICacheXSessionMiddleware`, which also reads the header and `Authorization: Bearer` token and adds `request.session`. It also sends a session cookie to clients that sent no token, so set the cookie options as in [Session cookie](#session-cookie). See [Session management](SESSION.md#migration-sessionmiddleware-fastapicachexsessionmiddleware). + +```python +# Before +app.add_middleware(SessionMiddleware, session_manager=manager, config=config) + +# After +app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config +) +``` + +### JWT secret length {#jwt-secret} + +With `token_format="jwt"`, 0.4.0 raises at startup when `jwt_algorithm` is `HS384` or `HS512` and `secret_key` is shorter than 48 or 64 bytes (RFC 7518 section 3.2) ([#129](https://github.com/allen0099/FastAPI-CacheX/issues/129)). 0.3.x emits a `UserWarning` when `JWTTokenSerializer` is built. Use a longer key, or `HS256`: + +```python +# Before: 32 characters, too short for HS512 +SessionConfig( + secret_key=secrets.token_urlsafe(24), token_format="jwt", jwt_algorithm="HS512" +) + +# After +SessionConfig( + secret_key=secrets.token_urlsafe(64), token_format="jwt", jwt_algorithm="HS512" +) +``` + +### Session writes {#session-writes} + +0.4.0 writes sessions conditionally ([#128](https://github.com/allen0099/FastAPI-CacheX/issues/128)): a request that loaded a session before another request deleted, invalidated or rotated it can no longer bring the record back when it saves. No code change is needed. A custom backend has to support the write-if-present primitive this adds; its shape is not decided yet. + +### Token sources {#token-source-priority} + +`SessionConfig.token_source_priority` accepts `"cookie"` next to `"header"` and `"bearer"` in 0.4.0 ([#75](https://github.com/allen0099/FastAPI-CacheX/issues/75)), and `FastAPICacheXSessionMiddleware` resolves the token by walking the list. Existing lists keep working; today the cookie is read after the header and bearer sources. Whether the default list gains `"cookie"`, and in which position, is not decided yet. + +## Application cache {#application-cache} + +### get_or_set lock {#get-or-set-lock} + +`CacheManager.get_or_set()` turns stampede protection on by default in 0.4.0 ([#280](https://github.com/allen0099/FastAPI-CacheX/issues/280)): on concurrent misses of one key, only one caller runs `factory`, and the others wait for its result. This adds backend round trips on a miss (see [Stampede protection](APP_CACHE.md#stampede-protection)). In 0.3.9, a `get_or_set()` call that passes no `lock=`, on a manager created without `lock=`, emits a `FutureWarning` once per manager. That includes the manager `AppCache` creates for you. + +Before: + +```python +manager = CacheManager(key_prefix="myapp:") +value = await manager.get_or_set("report", build_report) +``` + +After, either per manager or per call: + +```python +manager = CacheManager(key_prefix="myapp:", lock=False) # keep today's behaviour +manager = CacheManager(key_prefix="myapp:", lock=True) # opt in now + +value = await manager.get_or_set("report", build_report, lock=True) +``` + +With `AppCache`, register your own manager at startup: + +```python +CacheManagerProxy.set(CacheManager(lock=True)) +``` + +## HTTP caching {#http-caching} + +### Cache keys {#cache-keys} + +0.4.0 changes the format of every HTTP cache key, in one step so that the upgrade costs a single cache miss ([#271](https://github.com/allen0099/FastAPI-CacheX/issues/271), [#266](https://github.com/allen0099/FastAPI-CacheX/issues/266), [#265](https://github.com/allen0099/FastAPI-CacheX/issues/265), [#269](https://github.com/allen0099/FastAPI-CacheX/issues/269), [#270](https://github.com/allen0099/FastAPI-CacheX/issues/270), [#72](https://github.com/allen0099/FastAPI-CacheX/issues/72)): + +- The separator becomes a single `|` (`CACHE_KEY_SEPARATOR`). +- Keys start with a format tag, such as `http:v2|`, so the next format change can remove old keys by pattern. +- The host is normalised: lower-cased, and the scheme's default port (`:80`, `:443`) dropped. +- A long query string (over about 200 bytes) is stored as `sha256:` and its hex digest; the path stays readable. +- Query parameters are sorted, as `@cache(sort_query=True)` does since 0.3.9; whether that becomes the default or stays opt-in is not decided yet. +- One `CacheKey` type encodes and parses keys; the key-parsing internals of `routes.py` change. + +```text +Before: GET|||Example.com:80|||/users/1|||page=2 +After: http:v2|GET|example.com|/users/1|page=2 (exact tag not final) +``` + +What to change: + +- `clear_pattern()` patterns that spell out the separator (`"GET|||*|||/users/*"`) need rewriting. `clear_path()` and `invalidate()` build the key themselves and need nothing. +- A custom `key_builder` that calls `build_cache_key()` or joins with `CACHE_KEY_SEPARATOR` follows automatically; one that hard-codes `|||` does not. +- Entries written by 0.3.x are not read by 0.4.0. They expire on their TTL; on Redis and memory you can remove them right after the upgrade with `await backend.clear_pattern("*|||*")`. Memcached cannot enumerate keys, so there they just expire. + +0.3.9 does not warn: nothing in 0.3.x can tell whether a pattern or key builder will match the new format, and the only runtime cost is the one-off miss. + +### 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`: + +```python +# Before +entry.headers["link"] + +# After +[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. + +### Monitoring routes {#add-routes} + +`add_routes()` requires `dependencies` in 0.4.0, and `include_content_preview` defaults to `False` ([#298](https://github.com/allen0099/FastAPI-CacheX/issues/298)). 0.3.x emits a `UserWarning` when `dependencies` is left out. + +```python +# Before +add_routes(app) + +# After +add_routes(app, dependencies=[Depends(verify_admin)]) +# or, unguarded on purpose (local or test setups), keeping the previews: +add_routes(app, dependencies=[], include_content_preview=True) +``` + +## Backends {#backends} + +### Redis encoding {#redis-encoding} + +The Redis client reads raw bytes in 0.4.0, and the `encoding` option is removed from `AsyncRedisCacheBackend` and `RedisConfig` ([#126](https://github.com/allen0099/FastAPI-CacheX/issues/126)). Entries were always written as UTF-8, so leaving it out changes nothing. In 0.3.9, passing `encoding` at all emits a `DeprecationWarning` (and a value other than UTF-8 keeps its `RuntimeWarning`). + +```python +# Before +AsyncRedisCacheBackend(host="redis", encoding="utf-8") +RedisConfig(host="redis", encoding="utf-8") + +# After +AsyncRedisCacheBackend(host="redis") +RedisConfig(host="redis") +``` + +### Redis clear_pattern {#redis-clear-pattern} + +Before 0.3.8, a Redis `clear_pattern()` pattern that started with the backend's `key_prefix` was matched with the prefix stripped. 0.3.x still retries that way when the pattern clears nothing, with a `DeprecationWarning`; 0.4.0 removes the retry ([#125](https://github.com/allen0099/FastAPI-CacheX/issues/125)). Patterns match the logical key: + +```python +# Before +await backend.clear_pattern("fastapi_cachex:GET|||*") + +# After +await backend.clear_pattern("GET|||*") # "GET|*" with 0.4.0's key format +``` + +### delete() return value {#backend-delete} + +`BaseCacheBackend.delete()` returns whether a key was removed in 0.4.0, instead of `None` ([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)), and the base `delete_many()` fallback counts the keys that existed rather than the ones attempted. A third-party backend that returns `None` fails type checking and makes that count wrong. 0.3.9 does not warn: a subclass cannot declare `-> bool` today without a type error against the 0.3.x base class, and nobody uses the `None`. + +```python +# Before +class MyBackend(BaseCacheBackend): + async def delete(self, key: str) -> None: + await self._client.delete(key) + + +# After +class MyBackend(BaseCacheBackend): + async def delete(self, key: str) -> bool: + return await self._client.delete(key) > 0 +``` + +Callers that need the answer in 0.3.x can use `await backend.get_and_delete(key) is not None`. + +### BackendProxy {#backend-proxy} + +`BackendProxy.get_backend()` and `set_backend()` are removed ([#70](https://github.com/allen0099/FastAPI-CacheX/issues/70)); 0.3.x already emits a `DeprecationWarning`. + +```python +# Before +BackendProxy.set_backend(backend) +backend = BackendProxy.get_backend() + +# After +BackendProxy.set(backend) +backend = BackendProxy.get() +``` + +### CacheError {#cache-error} + +The `CacheError` alias is removed ([#130](https://github.com/allen0099/FastAPI-CacheX/issues/130)); 0.3.x already emits a `DeprecationWarning` when it is imported. + +```python +# Before +from fastapi_cachex.exceptions import CacheError + +# After +from fastapi_cachex.exceptions import CacheXError +``` + +### memcache extra {#memcache-extra} + +The `memcache` extra, an alias of `memcached`, is removed ([#202](https://github.com/allen0099/FastAPI-CacheX/issues/202)). The failure is quiet: pip and uv only warn about an unknown extra and install `fastapi-cachex` without `pymemcache`, so the error appears when `MemcachedBackend` is constructed. 0.3.9 cannot warn, because an extra is resolved by the installer and the library never sees which one was requested. + +```bash +# Before +pip install "fastapi-cachex[memcache]" + +# After +pip install "fastapi-cachex[memcached]" +``` diff --git a/docs/SESSION.md b/docs/SESSION.md index 050f1ac..3080d24 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -69,13 +69,13 @@ keeps the token it gets back and sends it on later requests. ``` -Instead of passing the manager to the middleware, you can register it on the -proxy. When `config` is omitted, the middleware uses `session_manager.config`: +The example also registers the manager on `SessionManagerProxy`, where +`get_session_manager` looks for it from 0.4.0 (see +[Migrating to 0.4.0](MIGRATING_0_4.md#get-session-manager)). With the manager +there, the middleware can pick it up instead of taking it as an argument. When +`config` is omitted, the middleware uses `session_manager.config`: ```python -from fastapi_cachex.session import SessionManagerProxy - -SessionManagerProxy.set(session_manager) app.add_middleware(FastAPICacheXSessionMiddleware) # picked up from the proxy ``` @@ -247,18 +247,29 @@ SessionConfig( # Backend backend_key_prefix="session:", # Cookies (read only by FastAPICacheXSessionMiddleware) - cookie_name="session", + cookie_name="session", # "__Host-session" from 0.4.0 cookie_max_age=14 * 24 * 60 * 60, # None = no Max-Age (cookie ends with the browser session) cookie_path="/", cookie_same_site="lax", # "lax" / "strict" / "none" ("none" needs cookie_https_only=True) - cookie_https_only=False, # True adds the Secure flag + cookie_https_only=False, # True adds the Secure flag; True from 0.4.0 cookie_domain=None, # None = no Domain attribute ) ``` +#### Cookie defaults change in 0.4.0 {#cookie-defaults-change-in-040} + +0.4.0 names the session cookie `__Host-session` and sets the `Secure` flag by default. Browsers accept a `__Host-` cookie only when it is `Secure`, has `Path=/` and no `Domain`, and never from a subdomain, which removes the usual way to plant a session cookie (session fixation). The new name also means every browser holding a `session` cookie is logged out once after the upgrade. + +Until then, `FastAPICacheXSessionMiddleware` emits a `FutureWarning` when its config leaves `cookie_name` or `cookie_https_only` at the default. Set both to silence it: + +- `cookie_name="session", cookie_https_only=False` keeps the current cookie (and keeps working after the upgrade, e.g. for local development over plain HTTP); +- `cookie_name="__Host-session", cookie_https_only=True` switches now, over HTTPS. + +A `__Host-` name with `cookie_https_only=False`, a `cookie_path` other than `/` or a `cookie_domain` (and a `__Secure-` name without `cookie_https_only=True`) emits a `UserWarning`, because browsers refuse such a cookie; 0.4.0 rejects the combination. See [Migrating to 0.4.0](MIGRATING_0_4.md#session-cookie). + Sessions expire after `session_ttl` seconds. With `sliding_expiration`, each request that finds less than `session_ttl * sliding_threshold` seconds remaining extends the expiry to a full `session_ttl` again and issues a renewed token, which the middleware sends back to the client @@ -357,6 +368,7 @@ Always transport tokens over HTTPS in production. For cookie clients, mark the c ```python config = SessionConfig( secret_key="...", + cookie_name="__Host-session", # browsers refuse it without Secure, Path=/, no Domain cookie_https_only=True, # adds the Secure flag to the session cookie ) ``` @@ -591,7 +603,11 @@ from fastapi_cachex.session.dependencies import ( `get_session_manager` returns the manager the middleware stored on `app.state` when it handled its first request; it responds with `500` if no session middleware has run yet. Using it avoids -importing the manager into your route modules: +importing the manager into your route modules. From 0.4.0 it resolves the manager through +`SessionManagerProxy` instead, so register it there with `SessionManagerProxy.set(manager)`: +until then, `get_session_manager` (and `SessionManagerDep`, `ClientIPDep` and +`rotate_session_id()`, which use it) emits a `FutureWarning` once per app when the proxy holds no +manager or a different one. See [Migrating to 0.4.0](MIGRATING_0_4.md#get-session-manager). ```python from fastapi_cachex.session import SessionUser diff --git a/examples/app_cache.py b/examples/app_cache.py index 1d5fbcf..d59998e 100644 --- a/examples/app_cache.py +++ b/examples/app_cache.py @@ -27,8 +27,9 @@ backend = MemoryBackend() BackendProxy.set(backend) # Optional: without this, AppCache creates a CacheManager with the defaults -# (key prefix "cache:", no TTL) on first use. -CacheManagerProxy.set(CacheManager(key_prefix="example:", default_ttl=300)) +# (key prefix "cache:", no TTL) on first use. lock=True runs the factory once for +# concurrent misses of the same key; it becomes the default in 0.4.0. +CacheManagerProxy.set(CacheManager(key_prefix="example:", default_ttl=300, lock=True)) @asynccontextmanager diff --git a/examples/session_api.py b/examples/session_api.py index 0a49977..49b7549 100644 --- a/examples/session_api.py +++ b/examples/session_api.py @@ -22,6 +22,7 @@ from pydantic import BaseModel from fastapi_cachex import BackendProxy +from fastapi_cachex import SessionManagerProxy from fastapi_cachex.backends import MemoryBackend from fastapi_cachex.session import FastAPICacheXSessionMiddleware from fastapi_cachex.session import SessionConfig @@ -41,8 +42,15 @@ "SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying" ), session_ttl=3600, # 1 hour + # Both cookie defaults change in 0.4.0, so set them explicitly. Over HTTPS + # use cookie_name="__Host-session", cookie_https_only=True. + cookie_name="session", + cookie_https_only=False, ) session_manager = SessionManager(backend, config) +# Register it on the proxy too: from 0.4.0, get_session_manager (and +# SessionManagerDep, ClientIPDep) find the manager there only. +SessionManagerProxy.set(session_manager) @asynccontextmanager diff --git a/examples/session_jwt.py b/examples/session_jwt.py index 6fd5de7..69c123c 100644 --- a/examples/session_jwt.py +++ b/examples/session_jwt.py @@ -24,6 +24,7 @@ from fastapi_cachex import FastAPICacheXSessionMiddleware from fastapi_cachex import SessionConfig from fastapi_cachex import SessionManager +from fastapi_cachex import SessionManagerProxy from fastapi_cachex import SessionUser from fastapi_cachex.backends import MemoryBackend from fastapi_cachex.session.dependencies import AuthenticatedSession @@ -44,8 +45,15 @@ # Optional: issued as `iss`/`aud` and checked on every request. jwt_issuer="https://api.example.com", jwt_audience="example-clients", + # The middleware also accepts a cookie. 0.4.0 changes both cookie defaults + # (to "__Host-session" with the Secure flag), so set them explicitly; over + # HTTPS use cookie_name="__Host-session" and cookie_https_only=True. + cookie_name="session", + cookie_https_only=False, ) session_manager = SessionManager(backend, config) +# ClientIPDep finds the manager through the proxy (only through it from 0.4.0). +SessionManagerProxy.set(session_manager) @asynccontextmanager diff --git a/examples/session_jwt_claims.py b/examples/session_jwt_claims.py index a7297a9..2ce2244 100644 --- a/examples/session_jwt_claims.py +++ b/examples/session_jwt_claims.py @@ -143,6 +143,8 @@ def check_claims(self, payload: dict[str, Any]) -> None: jwt_algorithm="HS256", jwt_issuer="acme-corp", jwt_audience="acme-api", + cookie_name="__Host-session", # the middleware also reads a cookie + cookie_https_only=True, ) # token_serializer replaces the serializer chosen from token_format. diff --git a/examples/session_login.py b/examples/session_login.py index 258f008..5f23a88 100644 --- a/examples/session_login.py +++ b/examples/session_login.py @@ -43,7 +43,11 @@ "SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying" ), session_ttl=3600, - # Keep False only for local HTTP development. + # 0.4.0 changes both cookie defaults (to "__Host-session" with the Secure + # flag), so set them explicitly. In production over HTTPS use + # cookie_name="__Host-session" and cookie_https_only=True; keep False only + # for local HTTP development. + cookie_name="session", cookie_https_only=False, ) session_manager = SessionManager(backend, config) diff --git a/examples/session_redis.py b/examples/session_redis.py index 3157a14..d8844f3 100644 --- a/examples/session_redis.py +++ b/examples/session_redis.py @@ -26,6 +26,7 @@ from fastapi import Request from pydantic import BaseModel +from fastapi_cachex import SessionManagerProxy from fastapi_cachex.backends import AsyncRedisCacheBackend from fastapi_cachex.session import FastAPICacheXSessionMiddleware from fastapi_cachex.session import SessionConfig @@ -56,8 +57,11 @@ sliding_threshold=0.5, ip_binding=True, # reject the token from another IP address user_agent_binding=False, # optional: reject it from another User-Agent + cookie_name="__Host-session", # the 0.4.0 default; needs HTTPS + cookie_https_only=True, ) session_manager = SessionManager(backend, config) +SessionManagerProxy.set(session_manager) # ClientIPDep resolves it through the proxy @asynccontextmanager diff --git a/fastapi_cachex/backends/config.py b/fastapi_cachex/backends/config.py index 7180e3f..8f5f97f 100644 --- a/fastapi_cachex/backends/config.py +++ b/fastapi_cachex/backends/config.py @@ -16,7 +16,14 @@ class RedisConfig(BaseModel): default=None, description="Redis server password" ) db: int = Field(default=0, ge=0, description="Redis database number") - encoding: str = Field(default="utf-8", description="Character encoding to use") + encoding: str = Field( + default="utf-8", + description=( + "Deprecated, removed in 0.4.0: leave it unset. Character encoding " + "the client decodes replies with; setting it emits a " + "DeprecationWarning in load_from_config()." + ), + ) socket_timeout: float = Field( default=1.0, description="Timeout for socket operations in seconds" ) diff --git a/fastapi_cachex/backends/redis.py b/fastapi_cachex/backends/redis.py index 50d2e53..6d87203 100644 --- a/fastapi_cachex/backends/redis.py +++ b/fastapi_cachex/backends/redis.py @@ -79,23 +79,46 @@ def _escape_glob(text: str) -> str: """ -def _warn_if_not_utf8(encoding: str) -> None: - r"""Warn when replies would be decoded with anything but UTF-8. +_ENCODING_REMOVED = ( + "Version 0.4.0 removes it: the client will read raw bytes, and entries " + "are always UTF-8. Remove the argument; UTF-8 is what you get without it " + "(https://github.com/allen0099/FastAPI-CacheX/issues/126)." +) - The shared codec writes UTF-8 JSON, and the client decodes each reply with - ``encoding``, so under e.g. latin-1 a stored ``b"\xe9"`` reads back as - ``b"\xc3\xa9"``. Aliases such as ``"UTF8"`` and ``"utf_8"`` are accepted. + +def _is_utf8(encoding: str) -> bool: + """Whether ``encoding`` names UTF-8 (unknown names count as UTF-8 here). + + An unknown name is left for the client to reject itself. """ try: - name = codecs.lookup(encoding).name + return codecs.lookup(encoding).name == "utf-8" except LookupError: - return # the client rejects an unknown encoding itself - if name != "utf-8": + return True + + +def _warn_encoding(encoding: str) -> None: + r"""Warn about an explicitly passed ``encoding``. + + UTF-8 (under any alias such as ``"UTF8"`` or ``"utf_8"``) only gets the + ``DeprecationWarning`` for the parameter's removal in 0.4.0. Anything else + gets a ``RuntimeWarning``: the shared codec writes UTF-8 JSON, and the + client decodes each reply with ``encoding``, so under e.g. latin-1 a stored + ``b"\xe9"`` reads back as ``b"\xc3\xa9"``. + """ + if _is_utf8(encoding): + warnings.warn( + f"AsyncRedisCacheBackend(encoding={encoding!r}) is deprecated. " + f"{_ENCODING_REMOVED}", + DeprecationWarning, + stacklevel=3, + ) + else: warnings.warn( f"AsyncRedisCacheBackend(encoding={encoding!r}) will corrupt non-ASCII " "cached content: entries are always written as UTF-8, and replies " - "are decoded with this encoding. Use encoding='utf-8'. The encoding " - "parameter will be removed in version 0.4.0.", + "are decoded with this encoding. Remove the argument (UTF-8 is the " + "default); the encoding parameter will be removed in version 0.4.0.", RuntimeWarning, stacklevel=3, ) @@ -117,7 +140,7 @@ def __init__( port: int = 6379, password: str | None = None, db: int = 0, - encoding: str = "utf-8", + encoding: str | None = None, decode_responses: Literal[True] = True, socket_timeout: float = 1.0, socket_connect_timeout: float = 1.0, @@ -132,11 +155,12 @@ def __init__( port: Redis port password: Redis password db: Redis database number - encoding: Character encoding the client decodes replies with. - Leave it as UTF-8: entries are always written as UTF-8 JSON, so - any other encoding corrupts non-ASCII content on the way back, - and a ``RuntimeWarning`` says so. The parameter will be removed - in 0.4.0. + encoding: Deprecated; leave it out. Character encoding the client + decodes replies with, UTF-8 when omitted. Entries are always + written as UTF-8 JSON, so any other encoding corrupts non-ASCII + content on the way back, and a ``RuntimeWarning`` says so. + Passing it at all emits a ``DeprecationWarning``: the parameter + is removed in 0.4.0. decode_responses: Whether to decode response automatically socket_timeout: Timeout for socket operations (in seconds) socket_connect_timeout: Timeout for socket connection (in seconds) @@ -158,7 +182,10 @@ def __init__( ) raise CacheXError(msg) from exc - _warn_if_not_utf8(encoding) + if encoding is None: + encoding = "utf-8" + else: + _warn_encoding(encoding) # `protocol` is not in the types-redis stubs (added in redis-py 5.x). # Pass it via **kwargs so mypy doesn't complain about an unknown keyword. @@ -193,7 +220,21 @@ def load_from_config(config: RedisConfig) -> "AsyncRedisCacheBackend": config: RedisConfig instance Returns: An instance of AsyncRedisCacheBackend + + Warns: + DeprecationWarning: ``config`` sets ``encoding`` explicitly; the + field is removed in 0.4.0. """ + encoding: str | None = None + if "encoding" in config.model_fields_set: + warnings.warn( + f"RedisConfig(encoding={config.encoding!r}) is deprecated. " + f"{_ENCODING_REMOVED}", + DeprecationWarning, + stacklevel=2, + ) + if not _is_utf8(config.encoding): + encoding = config.encoding # keeps the RuntimeWarning return AsyncRedisCacheBackend( host=config.host, port=config.port, @@ -201,11 +242,11 @@ def load_from_config(config: RedisConfig) -> "AsyncRedisCacheBackend": if config.password is not None else None, db=config.db, - encoding=config.encoding, socket_timeout=config.socket_timeout, socket_connect_timeout=config.socket_connect_timeout, key_prefix=config.key_prefix, protocol=config.protocol, + encoding=encoding, ) async def aclose(self) -> None: diff --git a/fastapi_cachex/manager.py b/fastapi_cachex/manager.py index 9e22047..0137f4e 100644 --- a/fastapi_cachex/manager.py +++ b/fastapi_cachex/manager.py @@ -103,7 +103,7 @@ def __init__( key_prefix: str = "cache:", default_ttl: int | None = None, *, - lock: bool = False, + lock: bool | None = None, lock_ttl: int = 60, ) -> None: r"""Initialize CacheManager. @@ -114,13 +114,18 @@ def __init__( default_ttl: Default TTL (seconds) applied when set() is called without an explicit ttl. None means no expiry by default. lock: Whether get_or_set() uses distributed locking by default to - prevent cache stampedes (default: False). + prevent cache stampedes. ``None`` (the default) means ``False`` + in 0.3.x, and ``get_or_set()`` emits a ``FutureWarning`` the + first time it relies on it: the default becomes ``True`` in + 0.4.0. Pass ``False`` or ``True`` explicitly to keep the + current behaviour or opt in now. lock_ttl: Default TTL in seconds for stampede protection locks (default: 60). Raises: BackendNotFoundError: If ``backend`` is None and no backend has been set with ``BackendProxy.set()``. - TypeError: If ``lock`` is not a bool or ``lock_ttl`` is not an int. + TypeError: If ``lock`` is not a bool or None, or ``lock_ttl`` is + not an int. ValueError: If ``default_ttl`` or ``lock_ttl`` is zero or negative. Warns: @@ -133,13 +138,16 @@ def __init__( self.key_prefix = key_prefix self.default_ttl = validate_ttl(default_ttl) - _validate_lock(lock, allow_none=False) + _validate_lock(lock, allow_none=True) effective_lock_ttl = validate_ttl(lock_ttl) if effective_lock_ttl is None: msg = "lock_ttl must be a positive int, got None" raise ValueError(msg) - self.lock = lock + self.lock: bool = bool(lock) + # Whether get_or_set() still has to warn that the lock default flips + # in 0.4.0 (#280): only while neither the manager nor the call chose. + self._warn_lock_default = lock is None self.lock_ttl: int = effective_lock_ttl if self._prefix_has_glob: @@ -411,7 +419,10 @@ async def get_or_set( # noqa: PLR0913 ttl: Time-to-live in seconds for a newly created value. If None, uses ``self.default_ttl``. lock: Whether to use distributed locking for stampede protection. - If None, inherits the manager's ``lock`` setting. + If None, inherits the manager's ``lock`` setting. When the + manager was created without ``lock=`` either, the first such + call emits a ``FutureWarning``: the default becomes ``True`` + in 0.4.0. lock_ttl: Upper bound in seconds for the lock lease. If None, inherits the manager's ``lock_ttl``. wait_timeout: Maximum seconds waiting callers poll the cache before @@ -432,10 +443,28 @@ async def get_or_set( # noqa: PLR0913 have invalid types. ValueError: If ``ttl``, ``lock_ttl``, or ``wait_timeout`` is zero or negative. LockTimeoutError: If ``raise_on_timeout=True`` and waiting exceeds ``wait_timeout``. + + Warns: + FutureWarning: Once per manager, when neither this call nor the + manager's constructor passed ``lock``: the default becomes + ``True`` in 0.4.0. """ validated_wait_timeout = _validate_get_or_set_args( lock, ttl, lock_ttl, wait_timeout ) + if lock is None and self._warn_lock_default: + self._warn_lock_default = False + warnings.warn( + "CacheManager.get_or_set() is relying on the default lock=False: " + "neither the call nor CacheManager(...) passed lock=. Version " + "0.4.0 turns stampede protection on by default (lock=True). " + "Pass lock=False to keep the current behaviour, or lock=True to " + "opt in now, to get_or_set() or to CacheManager() (for AppCache, " + "register one with CacheManagerProxy.set()) " + "(https://github.com/allen0099/FastAPI-CacheX/issues/280).", + FutureWarning, + stacklevel=2, + ) cached = await self.get(key, default=_SENTINEL) if cached is not _SENTINEL: diff --git a/fastapi_cachex/session/config.py b/fastapi_cachex/session/config.py index 5605a15..a46c0c6 100644 --- a/fastapi_cachex/session/config.py +++ b/fastapi_cachex/session/config.py @@ -247,6 +247,35 @@ def _warn_insecure_same_site_none(self) -> "SessionConfig": ) return self + @model_validator(mode="after") + def _warn_invalid_cookie_prefix(self) -> "SessionConfig": + """Warn about a ``__Host-`` / ``__Secure-`` cookie browsers will refuse. + + Browsers store a ``__Secure-`` cookie only with the Secure flag, and a + ``__Host-`` cookie only with Secure, ``Path=/`` and no ``Domain``, so + the session would silently never stick. 0.4.0 rejects these + combinations (#256). + """ + problems: list[str] = [] + if self.cookie_name.startswith(("__Host-", "__Secure-")): + if not self.cookie_https_only: + problems.append("cookie_https_only=True") + if self.cookie_name.startswith("__Host-"): + if self.cookie_path != "/": + problems.append('cookie_path="/"') + if self.cookie_domain is not None: + problems.append("cookie_domain=None") + if problems: + warnings.warn( + f"cookie_name={self.cookie_name!r} requires {', '.join(problems)}: " + "browsers refuse a cookie with this prefix otherwise, so the " + "session cookie would never be stored. Version 0.4.0 will reject " + "this configuration.", + UserWarning, + stacklevel=3, + ) + return self + @field_validator("jwt_algorithm") @classmethod def _check_jwt_algorithm(cls, value: str) -> str: diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py index 7a9e82d..ecbaaac 100644 --- a/fastapi_cachex/session/dependencies.py +++ b/fastapi_cachex/session/dependencies.py @@ -1,5 +1,6 @@ """FastAPI dependency injection utilities for session management.""" +import warnings from typing import TYPE_CHECKING from typing import Annotated @@ -10,16 +11,22 @@ from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPBearer +from fastapi_cachex.exceptions import ProxyNotSetError + from .middleware import _log_in from .middleware import _RequestSession from .middleware import get_client_ip from .models import Session +from .proxy import SessionManagerProxy if TYPE_CHECKING: from .manager import SessionManager from .models import SessionUser +# ``app.state`` flag: get_session_manager() already warned about #131 there. +_PROXY_WARNED = "__fastapi_cachex_session_manager_proxy_warned" + # HTTPBearer security scheme for OpenAPI UI _http_bearer = HTTPBearer( scheme_name="SessionBearer", @@ -138,9 +145,15 @@ async def login( Raises: HTTPException: 500 if no session middleware has registered a SessionManager yet + + Warns: + FutureWarning: Once per app, if the manager the middleware registered + is not the one set with ``SessionManagerProxy.set()``. 0.4.0 + resolves this dependency through ``SessionManagerProxy`` only. """ + state = request.app.state manager: SessionManager | None = getattr( - request.app.state, "__fastapi_cachex_session_manager", None + state, "__fastapi_cachex_session_manager", None ) if manager is None: raise HTTPException( @@ -150,9 +163,30 @@ async def login( "FastAPICacheXSessionMiddleware is added to the app." ), ) + if not getattr(state, _PROXY_WARNED, False) and manager is not _proxy_manager(): + setattr(state, _PROXY_WARNED, True) + warnings.warn( + "get_session_manager() returned the SessionManager the session " + "middleware registered, which is not the one set in " + "SessionManagerProxy. Version 0.4.0 resolves get_session_manager() " + "(and SessionManagerDep, ClientIPDep and rotate_session_id(), which use " + "it) through SessionManagerProxy " + "only. Call SessionManagerProxy.set(session_manager) at startup " + "(https://github.com/allen0099/FastAPI-CacheX/issues/131).", + FutureWarning, + stacklevel=2, + ) return manager +def _proxy_manager() -> "SessionManager | None": + """Return the manager set in ``SessionManagerProxy``, or None.""" + try: + return SessionManagerProxy.get() + except ProxyNotSetError: + return None + + async def rotate_session_id(request: Request) -> bool: """Give the request's session a new ID, as a defence against session fixation. diff --git a/fastapi_cachex/session/middleware.py b/fastapi_cachex/session/middleware.py index 930f397..31979cf 100644 --- a/fastapi_cachex/session/middleware.py +++ b/fastapi_cachex/session/middleware.py @@ -176,6 +176,38 @@ def _stash_session_manager(app: Any, manager: SessionManager) -> None: setattr(app.state, "__fastapi_cachex_session_manager", manager) +def _warn_if_default_cookie(config: SessionConfig) -> None: + """Warn that 0.4.0 changes the session cookie defaults this config relies on. + + 0.4.0 names the cookie ``__Host-session`` and sets the Secure flag by + default (#256). The new name logs every cookie session out once, and the + Secure flag stops the cookie working over plain HTTP, so both have to be + chosen explicitly to be unaffected. + + Args: + config: The configuration the middleware uses + """ + defaults = [ + name + for name in ("cookie_name", "cookie_https_only") + if name not in config.model_fields_set + ] + if not defaults: + return + warnings.warn( + f"FastAPICacheXSessionMiddleware is using the default {' and '.join(defaults)} " + "of SessionConfig. Version 0.4.0 changes the session cookie defaults to " + "cookie_name='__Host-session' with the Secure flag (cookie_https_only=True): " + "the new name logs every cookie session out once on upgrade, and a Secure " + "cookie is not sent over plain HTTP. Set both explicitly: " + "cookie_name='session', cookie_https_only=False keeps the current cookie; " + "cookie_name='__Host-session', cookie_https_only=True switches now " + "(https://github.com/allen0099/FastAPI-CacheX/issues/256).", + FutureWarning, + stacklevel=3, + ) + + class SessionMiddleware(BaseHTTPMiddleware): """Middleware to handle session loading and token extraction. @@ -348,10 +380,16 @@ def __init__( app: ASGI application session_manager: Session manager instance config: Session configuration + + Warns: + FutureWarning: If the configuration leaves ``cookie_name`` or + ``cookie_https_only`` at its default, both of which change in + 0.4.0. """ self.app = app self.session_manager = session_manager or SessionManagerProxy.get() self.config = config or self.session_manager.config + _warn_if_default_cookie(self.config) security_flags = f"httponly; samesite={self.config.cookie_same_site}" if self.config.cookie_https_only: diff --git a/fastapi_cachex/session/token_serializers.py b/fastapi_cachex/session/token_serializers.py index 9479ff5..6d58e73 100644 --- a/fastapi_cachex/session/token_serializers.py +++ b/fastapi_cachex/session/token_serializers.py @@ -110,7 +110,8 @@ def _warn_if_key_too_short(secret: str, algorithm: str) -> None: f"secret_key is {key_bytes} bytes, shorter than the {min_bytes} " f"bytes RFC 7518 section 3.2 requires for {algorithm}. Use a longer " f"secret_key (e.g. secrets.token_urlsafe({min_bytes})) or " - f'jwt_algorithm="HS256".', + f'jwt_algorithm="HS256". Version 0.4.0 will reject a shorter key ' + f"(https://github.com/allen0099/FastAPI-CacheX/issues/129).", UserWarning, stacklevel=3, ) diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index f1f972a..9f46afe 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -15,8 +15,9 @@ async def expensive_operation(cache: AppCache): return result -# 也可以直接建立實例,例如在請求之外使用: -manager = CacheManager(key_prefix="myapp:", default_ttl=60) +# 也可以直接建立實例,例如在請求之外使用。請明確傳入 `lock`: +# 它的預設值會在 0.4.0 從 False 改為 True(見「Cache stampede 保護」)。 +manager = CacheManager(key_prefix="myapp:", default_ttl=60, lock=False) await manager.set("user:42", {"name": "Alice"}) user = await manager.get("user:42") # {"name": "Alice"} await manager.delete("user:42") @@ -82,6 +83,10 @@ manager = CacheManager(lock=True, lock_ttl=60) 請確保 `lock_ttl` 超過 `factory` 的預期執行時間。若 `factory` 執行時間超過 `lock_ttl`,鎖會在執行途中過期,導致等待中的呼叫者發起第二次計算。 +### 0.4.0 的預設值變更 {#the-default-changes-in-040} + +Cache stampede 保護在 0.3.x 預設關閉,**0.4.0 起預設開啟**。若 `get_or_set()` 呼叫沒有傳入 `lock=`,而 manager 建立時也沒有傳入 `lock=`(包括 `AppCache` 自動建立的 manager),每個 manager 會發出一次 `FutureWarning`。傳入 `lock=False` 可保留目前的行為,傳入 `lock=True` 則現在就啟用,可以針對單次呼叫或傳給 `CacheManager(...)`;使用 `AppCache` 時,請以 `CacheManagerProxy.set(CacheManager(lock=...))` 註冊自己的 manager。請參閱[遷移至 0.4.0](MIGRATING_0_4.md#get-or-set-lock)。 + ## JSON 往返 {#json-round-trip} 值以 JSON 儲存(`json.dumps` 的預設設定),讀取時以 `json.loads` 解碼,因此取回的是 JSON 解碼後的形式,而不是你存入的物件: diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index b5d4ecb..2507657 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -64,7 +64,6 @@ config = RedisConfig( port=6379, password=None, # SecretStr | None db=0, - encoding="utf-8", # 保持 UTF-8;見下方說明 socket_timeout=1.0, # 秒;適用於讀取/寫入 socket_connect_timeout=1.0, key_prefix="fastapi_cachex:", @@ -74,7 +73,7 @@ backend = AsyncRedisCacheBackend.load_from_config(config) BackendProxy.set(backend) ``` -請保持 `encoding="utf-8"`。項目一律以 UTF-8 JSON 寫入,而用戶端會以 `encoding` 解碼回應,因此任何其他值都會在讀回時破壞非 ASCII 內容(使用 `"latin-1"` 時,儲存的 `b"\xe9"` 會讀回成 `b"\xc3\xa9"`)。編碼不是 UTF-8 時,後端會發出 `RuntimeWarning`,而這個參數將於 0.4.0 移除。 +請不要設定 `encoding`,`RedisConfig` 與 `AsyncRedisCacheBackend` 皆然:它已棄用並將於 0.4.0 移除,只要設定就會發出 `DeprecationWarning`。項目一律以 UTF-8 JSON 寫入,而用戶端會以 `encoding` 解碼回應,因此 UTF-8 以外的值還會在讀回時破壞非 ASCII 內容(使用 `"latin-1"` 時,儲存的 `b"\xe9"` 會讀回成 `b"\xc3\xa9"`),後端也會因此發出 `RuntimeWarning`。請參閱[遷移至 0.4.0](MIGRATING_0_4.md#redis-encoding)。 除非你需要 RESP3 的功能,*而且*你的 `hiredis` 建置支援它(RESP3 需要 hiredis >= 3.0),否則請保留 `protocol=2`。Redis 8.0 支援 RESP3,但較舊的 hiredis 會無法協商使用它。 diff --git a/i18n/zh-TW/docs/MIGRATING_0_4.md b/i18n/zh-TW/docs/MIGRATING_0_4.md new file mode 100644 index 0000000..1120b84 --- /dev/null +++ b/i18n/zh-TW/docs/MIGRATING_0_4.md @@ -0,0 +1,355 @@ +# 遷移至 0.4.0 {#migrating-to-040} + +0.3.9 是最後一個 0.3.x 版本。0.4.0 包含破壞性變更,收錄於 [0.4.0 milestone](https://github.com/allen0099/FastAPI-CacheX/issues?q=milestone%3A0.4.0)。本頁逐一列出這些變更、需要修改的地方,以及 0.3.9 是否已經發出警告。部分 0.4.0 的設計尚未定案;若 issue 對某個細節仍未決定,該節會說明目前已知與尚未決定的部分。 + +## 升級前 {#before-you-upgrade} + +請先升級到 0.3.9,並在執行測試時將函式庫的警告轉為錯誤: + +```bash +python -W error::DeprecationWarning -W error::FutureWarning -m pytest +``` + +或使用 pytest 本身的設定: + +```toml +[tool.pytest.ini_options] +filterwarnings = [ + "error::DeprecationWarning", + "error::FutureWarning", +] +``` + +下方每個警告都會指出要修改的設定,並附上對應 issue 的連結。`FutureWarning` 預告會改變行為的預設值,預設就會顯示;`DeprecationWarning` 預告移除;`UserWarning` 標示目前就已經有問題、0.4.0 不再接受的設定。0.3.9 不再發出這些警告後,「0.3.9 是否警告」欄位中有警告的變更就已處理完畢;本頁其餘部分說明警告無法偵測的變更。 + +## 總覽 {#summary} + +| 變更 | Issue | 0.3.9 是否警告 | 章節 | +|------|-------|----------------|------| +| Session Cookie 預設為 `__Host-session` 並帶 `Secure` | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `FutureWarning` | [Session Cookie](#session-cookie) | +| 拒絕互相矛盾的 `__Host-`/`__Secure-` Cookie 設定 | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | `UserWarning` | [Session Cookie](#session-cookie) | +| 明確的 `login()`/`logout()`、唯讀的 `Session.user` | [#256](https://github.com/allen0099/FastAPI-CacheX/issues/256) | 否 | [登入與登出](#login-logout) | +| `get_or_set()` 預設使用鎖 | [#280](https://github.com/allen0099/FastAPI-CacheX/issues/280) | `FutureWarning` | [get_or_set 的鎖](#get-or-set-lock) | +| `get_session_manager` 透過 `SessionManagerProxy` 取得 | [#131](https://github.com/allen0099/FastAPI-CacheX/issues/131) | `FutureWarning` | [get_session_manager](#get-session-manager) | +| `add_routes()` 必須傳入 `dependencies`,預設不含內容預覽 | [#298](https://github.com/allen0099/FastAPI-CacheX/issues/298) | `UserWarning` | [監控路由](#add-routes) | +| 移除 Redis 的 `encoding` 選項 | [#126](https://github.com/allen0099/FastAPI-CacheX/issues/126) | `DeprecationWarning` | [Redis encoding](#redis-encoding) | +| 拒絕短於雜湊輸出的 JWT HMAC 密鑰 | [#129](https://github.com/allen0099/FastAPI-CacheX/issues/129) | `UserWarning` | [JWT 密鑰長度](#jwt-secret) | +| 移除 `SessionMiddleware` | [#69](https://github.com/allen0099/FastAPI-CacheX/issues/69) | `DeprecationWarning` | [SessionMiddleware](#session-middleware) | +| 移除 `BackendProxy.get_backend()`/`set_backend()` | [#70](https://github.com/allen0099/FastAPI-CacheX/issues/70) | `DeprecationWarning` | [BackendProxy](#backend-proxy) | +| 移除 `CacheError` | [#130](https://github.com/allen0099/FastAPI-CacheX/issues/130) | `DeprecationWarning` | [CacheError](#cache-error) | +| 移除 Redis `clear_pattern()` 去除前綴後的重試 | [#125](https://github.com/allen0099/FastAPI-CacheX/issues/125) | `DeprecationWarning` | [Redis clear_pattern](#redis-clear-pattern) | +| `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) | +| 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) | +| `token_source_priority` 可包含 Cookie | [#75](https://github.com/allen0099/FastAPI-CacheX/issues/75) | 否 | [權杖來源](#token-source-priority) | + +## Session {#sessions} + +### Session Cookie 預設值 {#session-cookie} + +0.4.0 會改變兩個 `SessionConfig` 預設值([#256](https://github.com/allen0099/FastAPI-CacheX/issues/256)):`cookie_name` 從 `"session"` 改為 `"__Host-session"`,`cookie_https_only` 從 `False` 改為 `True`。瀏覽器只接受帶 `Secure`、`Path=/` 且沒有 `Domain` 的 `__Host-` Cookie,也不接受子網域設定的這種 Cookie,因此移除了植入 Session Cookie 最常見的途徑。這帶來兩個後果: + +- 升級後,所有持有 `session` Cookie 的瀏覽器都會被登出一次,因為中介軟體改為尋找 `__Host-session`。 +- `Secure` Cookie 不會透過純 HTTP 傳送,因此沒有 TLS 的本機開發環境需要明確關閉它。 + +在 0.3.9 中,若 `FastAPICacheXSessionMiddleware` 的設定讓 `cookie_name` 或 `cookie_https_only` 維持預設值,會發出 `FutureWarning`。只使用標頭的設定(已棄用的 `SessionMiddleware`,或不搭配中介軟體使用的 `SessionManager`)永遠不會送出 Cookie,因此不會警告。 + +修改前: + +```python +config = SessionConfig(secret_key=SECRET) +app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=SessionManager(backend, config) +) +``` + +修改後,保留目前的 Cookie(同一段程式碼在 0.3.9 與 0.4.0 都能運作,也是純 HTTP 開發環境需要的設定): + +```python +config = SessionConfig( + secret_key=SECRET, cookie_name="session", cookie_https_only=False +) +``` + +修改後,現在就切換(僅限 HTTPS): + +```python +config = SessionConfig( + secret_key=SECRET, cookie_name="__Host-session", cookie_https_only=True +) +``` + +0.4.0 也會拒絕互相矛盾的設定:`__Host-` 名稱搭配 `cookie_https_only=False`、`"/"` 以外的 `cookie_path` 或 `cookie_domain`,以及 `__Secure-` 名稱搭配 `cookie_https_only=False`。瀏覽器本來就會拒絕這樣的 Cookie,所以 Session 永遠無法保存;0.3.9 在以這些設定建立 `SessionConfig` 時會發出 `UserWarning`。 + +### 登入與登出 {#login-logout} + +0.4.0 讓使用者只能透過一個明確的 API 成為已驗證狀態,而這個 API 一律會發出新的 Session ID([#256](https://github.com/allen0099/FastAPI-CacheX/issues/256))。目前已知: + +- `login(request, user)`(0.3.9 已提供,`from fastapi_cachex.session import login`)會附加使用者並輪替 ID。現在就請改用它,而不是自行設定 `session.user`。 +- 新增登出 API;`request.session.clear()` 仍代表登出。 +- `Session.user` 在這些呼叫之外變成唯讀。直接指定它的程式碼會失效。 +- 登入會開始一個新的 Session,而不是將匿名 Session 升級。要保留哪些資料(預設全部,或使用 `keep=` 清單)尚未決定。 +- 輪替後的幾秒內,舊 ID 會解析到新的 Session,讓仍帶著舊權杖的進行中請求不會失敗。 +- `rotate_session_id()` 會保留,用於不更換使用者的權限變更,名稱可能會改變。 + +確切的函式簽章尚未定案,因此 0.3.9 不會針對這些變更發出警告。若應用程式依據自己寫入 `request.session` 的鍵(`request.session.get("user_id")`)判斷是否已登入,這不在函式庫能察覺的範圍內;請使用函式庫的身分(`session.user`、`AuthenticatedSession`),或自行輪替 ID。 + +修改前: + +```python +session, _ = await manager.get_session(token) +session.user = SessionUser(user_id=user_id) # 0.4.0 起唯讀 +await manager.update_session(session) +``` + +修改後(在 0.3.9 可用,也會輪替 Session ID): + +```python +from fastapi_cachex.session import login + +await login(request, SessionUser(user_id=user_id)) +``` + +### get_session_manager {#get-session-manager} + +`get_session_manager`(以及使用它的 `SessionManagerDep`、`ClientIPDep` 與 `rotate_session_id()`)目前回傳中介軟體存放在 `app.state` 的管理器。0.4.0 改為只透過 `SessionManagerProxy` 取得,與 `BackendProxy` 和 `CacheManagerProxy` 一致([#131](https://github.com/allen0099/FastAPI-CacheX/issues/131))。在 0.3.9 中,當 proxy 沒有管理器或持有不同的管理器時,每個應用程式會發出一次 `FutureWarning`。 + +修改前: + +```python +session_manager = SessionManager(backend, config) +app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=session_manager) +``` + +修改後: + +```python +session_manager = SessionManager(backend, config) +SessionManagerProxy.set(session_manager) +app.add_middleware(FastAPICacheXSessionMiddleware) # 從 proxy 取得管理器 +``` + +### UserSessionDep {#user-session-dep} + +`UserSessionDep` 是 `SessionDep` 的別名,也接受匿名 Session。0.4.0 起它與 `AuthenticatedSession` 一樣需要帶有使用者的 Session([#127](https://github.com/allen0099/FastAPI-CacheX/issues/127)),因此匿名請求存取使用它的路由會得到 `401`。0.3.9 不會警告:型別別名在被使用時沒有可以執行程式碼的時機,而在匯入時警告會對每個人觸發。請選擇符合你本意的依賴項: + +```python +# 修改前 +async def cart(session: UserSessionDep): ... + + +# 修改後:允許匿名 Session(目前的行為) +async def cart(session: SessionDep): ... + + +# 修改後:需要已登入的使用者(0.4.0 的 UserSessionDep) +async def profile(session: AuthenticatedSession): ... +``` + +### SessionMiddleware {#session-middleware} + +只使用標頭的 `SessionMiddleware` 會被移除([#69](https://github.com/allen0099/FastAPI-CacheX/issues/69));0.3.x 已經會發出 `DeprecationWarning`。請改用 `FastAPICacheXSessionMiddleware`,它同樣讀取標頭與 `Authorization: Bearer` 權杖,並提供 `request.session`。它也會對沒有送出權杖的用戶端送出 Session Cookie,因此請依照 [Session Cookie](#session-cookie) 設定 Cookie 選項。另請參閱 [Session 管理](SESSION.md#migration-sessionmiddleware-fastapicachexsessionmiddleware)。 + +```python +# 修改前 +app.add_middleware(SessionMiddleware, session_manager=manager, config=config) + +# 修改後 +app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config +) +``` + +### JWT 密鑰長度 {#jwt-secret} + +使用 `token_format="jwt"` 時,若 `jwt_algorithm` 為 `HS384` 或 `HS512`,而 `secret_key` 短於 48 或 64 位元組(RFC 7518 第 3.2 節),0.4.0 會在啟動時拋出例外([#129](https://github.com/allen0099/FastAPI-CacheX/issues/129))。0.3.x 在建立 `JWTTokenSerializer` 時會發出 `UserWarning`。請使用較長的密鑰,或改用 `HS256`: + +```python +# 修改前:32 個字元,對 HS512 來說太短 +SessionConfig( + secret_key=secrets.token_urlsafe(24), token_format="jwt", jwt_algorithm="HS512" +) + +# 修改後 +SessionConfig( + secret_key=secrets.token_urlsafe(64), token_format="jwt", jwt_algorithm="HS512" +) +``` + +### Session 寫入 {#session-writes} + +0.4.0 會以有條件的方式寫入 Session([#128](https://github.com/allen0099/FastAPI-CacheX/issues/128)):若某個請求載入 Session 之後,另一個請求刪除、使其失效或輪替了它,前者儲存時不會再讓紀錄復活。不需要修改程式碼。自訂後端必須支援這項變更新增的「存在才寫入」原語,其形式尚未決定。 + +### 權杖來源 {#token-source-priority} + +0.4.0 起,`SessionConfig.token_source_priority` 除了 `"header"` 與 `"bearer"` 之外也接受 `"cookie"`([#75](https://github.com/allen0099/FastAPI-CacheX/issues/75)),`FastAPICacheXSessionMiddleware` 會依照清單順序尋找權杖。現有的清單仍可運作;目前 Cookie 會在標頭與 bearer 來源之後讀取。預設清單是否加入 `"cookie"`、放在什麼位置,尚未決定。 + +## 應用層快取 {#application-cache} + +### get_or_set 的鎖 {#get-or-set-lock} + +0.4.0 起,`CacheManager.get_or_set()` 預設開啟 cache stampede 保護([#280](https://github.com/allen0099/FastAPI-CacheX/issues/280)):同一個鍵同時未命中時,只有一個呼叫者執行 `factory`,其他呼叫者等待它的結果。這會讓未命中時多出幾次後端往返(見 [Cache stampede 保護](APP_CACHE.md#stampede-protection))。在 0.3.9 中,若 `get_or_set()` 呼叫沒有傳入 `lock=`,而 manager 建立時也沒有傳入 `lock=`,每個 manager 會發出一次 `FutureWarning`。`AppCache` 自動建立的 manager 也包含在內。 + +修改前: + +```python +manager = CacheManager(key_prefix="myapp:") +value = await manager.get_or_set("report", build_report) +``` + +修改後,可以針對 manager 或單次呼叫設定: + +```python +manager = CacheManager(key_prefix="myapp:", lock=False) # 保留目前的行為 +manager = CacheManager(key_prefix="myapp:", lock=True) # 現在就啟用 + +value = await manager.get_or_set("report", build_report, lock=True) +``` + +使用 `AppCache` 時,請在啟動時註冊自己的 manager: + +```python +CacheManagerProxy.set(CacheManager(lock=True)) +``` + +## HTTP 快取 {#http-caching} + +### 快取鍵 {#cache-keys} + +0.4.0 會一次改變所有 HTTP 快取鍵的格式,讓升級只造成一次快取未命中([#271](https://github.com/allen0099/FastAPI-CacheX/issues/271)、[#266](https://github.com/allen0099/FastAPI-CacheX/issues/266)、[#265](https://github.com/allen0099/FastAPI-CacheX/issues/265)、[#269](https://github.com/allen0099/FastAPI-CacheX/issues/269)、[#270](https://github.com/allen0099/FastAPI-CacheX/issues/270)、[#72](https://github.com/allen0099/FastAPI-CacheX/issues/72)): + +- 分隔符號改為單一的 `|`(`CACHE_KEY_SEPARATOR`)。 +- 鍵以格式標籤開頭,例如 `http:v2|`,讓下一次格式變更可以用模式移除舊鍵。 +- 主機名稱會正規化:轉為小寫,並去除該 scheme 的預設連接埠(`:80`、`:443`)。 +- 過長的查詢字串(約超過 200 位元組)會以 `sha256:` 加上十六進位摘要儲存;路徑仍保持可讀。 +- 查詢參數會排序,如同 0.3.9 起的 `@cache(sort_query=True)`;這會成為預設還是維持選用,尚未決定。 +- 由單一的 `CacheKey` 型別負責編碼與解析鍵;`routes.py` 中解析鍵的內部實作會改變。 + +```text +修改前:GET|||Example.com:80|||/users/1|||page=2 +修改後:http:v2|GET|example.com|/users/1|page=2 (確切的標籤尚未定案) +``` + +需要修改的地方: + +- 直接寫出分隔符號的 `clear_pattern()` 模式(`"GET|||*|||/users/*"`)需要改寫。`clear_path()` 與 `invalidate()` 會自行組出鍵,不需要修改。 +- 呼叫 `build_cache_key()` 或以 `CACHE_KEY_SEPARATOR` 串接的自訂 `key_builder` 會自動跟上;直接寫死 `|||` 的則不會。 +- 0.4.0 不會讀取 0.3.x 寫入的項目。這些項目會在 TTL 到期後過期;在 Redis 與記憶體後端上,可以在升級後立即以 `await backend.clear_pattern("*|||*")` 移除。Memcached 無法列舉鍵,只能等它們過期。 + +0.3.9 不會警告:0.3.x 無從判斷某個模式或 key builder 是否符合新格式,而執行期唯一的代價只是一次未命中。 + +### 重複的標頭 {#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` 的程式碼: + +```python +# 修改前 +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 快取(或在部署期間讓舊實例不要使用共用快取)。改由儲存格式帶版本標記是否可行,尚未決定。 + +### 監控路由 {#add-routes} + +0.4.0 起 `add_routes()` 必須傳入 `dependencies`,`include_content_preview` 預設為 `False`([#298](https://github.com/allen0099/FastAPI-CacheX/issues/298))。0.3.x 在省略 `dependencies` 時會發出 `UserWarning`。 + +```python +# 修改前 +add_routes(app) + +# 修改後 +add_routes(app, dependencies=[Depends(verify_admin)]) +# 或刻意不加保護(本機或測試環境),並保留預覽: +add_routes(app, dependencies=[], include_content_preview=True) +``` + +## 後端 {#backends} + +### Redis encoding {#redis-encoding} + +0.4.0 的 Redis 用戶端會直接讀取原始位元組,並從 `AsyncRedisCacheBackend` 與 `RedisConfig` 移除 `encoding` 選項([#126](https://github.com/allen0099/FastAPI-CacheX/issues/126))。項目一律以 UTF-8 寫入,因此省略它不會改變任何行為。在 0.3.9 中,只要傳入 `encoding` 就會發出 `DeprecationWarning`(UTF-8 以外的值仍會另外發出 `RuntimeWarning`)。 + +```python +# 修改前 +AsyncRedisCacheBackend(host="redis", encoding="utf-8") +RedisConfig(host="redis", encoding="utf-8") + +# 修改後 +AsyncRedisCacheBackend(host="redis") +RedisConfig(host="redis") +``` + +### Redis clear_pattern {#redis-clear-pattern} + +0.3.8 之前,以後端 `key_prefix` 開頭的 Redis `clear_pattern()` 模式會在去除前綴後比對。0.3.x 在模式沒有清除任何項目時仍會以這種方式重試,並發出 `DeprecationWarning`;0.4.0 移除這個重試([#125](https://github.com/allen0099/FastAPI-CacheX/issues/125))。模式比對的是邏輯上的鍵: + +```python +# 修改前 +await backend.clear_pattern("fastapi_cachex:GET|||*") + +# 修改後 +await backend.clear_pattern("GET|||*") # 在 0.4.0 的鍵格式下為 "GET|*" +``` + +### delete() 的回傳值 {#backend-delete} + +0.4.0 起 `BaseCacheBackend.delete()` 回傳是否移除了鍵,而不是 `None`([#71](https://github.com/allen0099/FastAPI-CacheX/issues/71)),基底類別的 `delete_many()` 後備實作也改為計算實際存在的鍵,而不是嘗試刪除的鍵。回傳 `None` 的第三方後端無法通過型別檢查,也會讓這個計數出錯。0.3.9 不會警告:子類別目前無法宣告 `-> bool` 而不與 0.3.x 的基底類別產生型別錯誤,而且沒有人使用這個 `None`。 + +```python +# 修改前 +class MyBackend(BaseCacheBackend): + async def delete(self, key: str) -> None: + await self._client.delete(key) + + +# 修改後 +class MyBackend(BaseCacheBackend): + async def delete(self, key: str) -> bool: + return await self._client.delete(key) > 0 +``` + +在 0.3.x 需要知道結果的呼叫者,可以使用 `await backend.get_and_delete(key) is not None`。 + +### BackendProxy {#backend-proxy} + +`BackendProxy.get_backend()` 與 `set_backend()` 會被移除([#70](https://github.com/allen0099/FastAPI-CacheX/issues/70));0.3.x 已經會發出 `DeprecationWarning`。 + +```python +# 修改前 +BackendProxy.set_backend(backend) +backend = BackendProxy.get_backend() + +# 修改後 +BackendProxy.set(backend) +backend = BackendProxy.get() +``` + +### CacheError {#cache-error} + +`CacheError` 別名會被移除([#130](https://github.com/allen0099/FastAPI-CacheX/issues/130));0.3.x 在匯入它時已經會發出 `DeprecationWarning`。 + +```python +# 修改前 +from fastapi_cachex.exceptions import CacheError + +# 修改後 +from fastapi_cachex.exceptions import CacheXError +``` + +### memcache extra {#memcache-extra} + +`memcache` extra(`memcached` 的別名)會被移除([#202](https://github.com/allen0099/FastAPI-CacheX/issues/202))。這個失敗很安靜:pip 與 uv 對未知的 extra 只會警告,並在沒有 `pymemcache` 的情況下安裝 `fastapi-cachex`,因此錯誤要到建立 `MemcachedBackend` 時才會出現。0.3.9 無法警告,因為 extra 由安裝工具解析,函式庫看不到安裝時指定了哪一個。 + +```bash +# 修改前 +pip install "fastapi-cachex[memcache]" + +# 修改後 +pip install "fastapi-cachex[memcached]" +``` diff --git a/i18n/zh-TW/docs/SESSION.md b/i18n/zh-TW/docs/SESSION.md index ccf4fdb..b6ed113 100644 --- a/i18n/zh-TW/docs/SESSION.md +++ b/i18n/zh-TW/docs/SESSION.md @@ -56,12 +56,9 @@ uv add "fastapi-cachex[jwt]" ``` -也可以不把管理器傳給中介軟體,而是將它註冊到 proxy。省略 `config` 時,中介軟體會使用 `session_manager.config`: +範例也把管理器註冊到 `SessionManagerProxy`,0.4.0 起 `get_session_manager` 只會從那裡取得它(見[遷移至 0.4.0](MIGRATING_0_4.md#get-session-manager))。管理器註冊在那裡之後,中介軟體也可以從 proxy 取得,而不必以參數傳入。省略 `config` 時,中介軟體會使用 `session_manager.config`: ```python -from fastapi_cachex.session import SessionManagerProxy - -SessionManagerProxy.set(session_manager) app.add_middleware(FastAPICacheXSessionMiddleware) # 從 proxy 取得 ``` @@ -160,18 +157,29 @@ SessionConfig( # 後端 backend_key_prefix="session:", # Cookie(只有 FastAPICacheXSessionMiddleware 會讀取) - cookie_name="session", + cookie_name="session", # 0.4.0 起為 "__Host-session" cookie_max_age=14 * 24 * 60 * 60, # None = 不設 Max-Age(Cookie 隨瀏覽器工作階段結束) cookie_path="/", cookie_same_site="lax", # "lax" / "strict" / "none"("none" 需要 cookie_https_only=True) - cookie_https_only=False, # True 會加上 Secure 旗標 + cookie_https_only=False, # True 會加上 Secure 旗標;0.4.0 起為 True cookie_domain=None, # None = 不設 Domain 屬性 ) ``` +#### 0.4.0 的 Cookie 預設值變更 {#cookie-defaults-change-in-040} + +0.4.0 會將 Session Cookie 命名為 `__Host-session`,並預設加上 `Secure` 旗標。瀏覽器只接受帶 `Secure`、`Path=/` 且沒有 `Domain` 的 `__Host-` Cookie,也不接受子網域設定的這種 Cookie,因此移除了植入 Session Cookie 最常見的途徑(Session 固定攻擊(session fixation))。新名稱也代表升級後,所有持有 `session` Cookie 的瀏覽器都會被登出一次。 + +在那之前,若 `FastAPICacheXSessionMiddleware` 的設定讓 `cookie_name` 或 `cookie_https_only` 維持預設值,會發出 `FutureWarning`。明確設定兩者即可消除警告: + +- `cookie_name="session", cookie_https_only=False` 保留目前的 Cookie(升級後也能繼續運作,例如透過純 HTTP 進行本機開發); +- `cookie_name="__Host-session", cookie_https_only=True` 現在就切換,需透過 HTTPS。 + +`__Host-` 名稱搭配 `cookie_https_only=False`、`/` 以外的 `cookie_path` 或 `cookie_domain`(以及 `__Secure-` 名稱未搭配 `cookie_https_only=True`)會發出 `UserWarning`,因為瀏覽器會拒絕這樣的 Cookie;0.4.0 會拒絕這種組合。請參閱[遷移至 0.4.0](MIGRATING_0_4.md#session-cookie)。 + Session 會在 `session_ttl` 秒後過期。啟用 `sliding_expiration` 時,每個發現剩餘時間少於 `session_ttl * sliding_threshold` 秒的請求,都會將過期時間重新延長為完整的 `session_ttl`,並發行一個更新後的權杖,由中介軟體傳回給用戶端(回應標頭或 `Set-Cookie`,見上表)。標頭/Bearer 用戶端在回應帶有 `header_name` 標頭時,應以它取代已保存的權杖。`absolute_timeout` 會在 Session 建立後經過該秒數時結束 Session,不論是否有滑動更新:過期時間、後端 TTL 與 JWT 的 `exp` 都不會超過 `created_at + absolute_timeout`,過期時間到達這個上限後也不再發行更新後的權杖。 #### `token_source_priority` 只接受 `"header"` 與 `"bearer"` {#token_source_priority-accepts-only-header-and-bearer} @@ -235,6 +243,7 @@ config = SessionConfig(secret_key=secret_key) ```python config = SessionConfig( secret_key="...", + cookie_name="__Host-session", # 瀏覽器只接受帶 Secure、Path=/ 且沒有 Domain 的這種 Cookie cookie_https_only=True, # 為 Session Cookie 加上 Secure 旗標 ) ``` @@ -397,7 +406,7 @@ from fastapi_cachex.session.dependencies import ( ) ``` -`get_session_manager` 回傳中介軟體在處理第一個請求時存放在 `app.state` 上的管理器;若尚未有任何 Session 中介軟體執行過,它會回應 `500`。使用它可以避免在路由模組中匯入管理器: +`get_session_manager` 回傳中介軟體在處理第一個請求時存放在 `app.state` 上的管理器;若尚未有任何 Session 中介軟體執行過,它會回應 `500`。使用它可以避免在路由模組中匯入管理器。0.4.0 起它改為透過 `SessionManagerProxy` 取得管理器,因此請以 `SessionManagerProxy.set(manager)` 註冊:在那之前,當 proxy 沒有管理器或持有不同的管理器時,`get_session_manager`(以及使用它的 `SessionManagerDep`、`ClientIPDep` 與 `rotate_session_id()`)每個應用程式會發出一次 `FutureWarning`。請參閱[遷移至 0.4.0](MIGRATING_0_4.md#get-session-manager)。 ```python from fastapi_cachex.session import SessionUser diff --git a/i18n/zh-TW/docs/index.md b/i18n/zh-TW/docs/index.md index d9eba6b..d1c30ee 100644 --- a/i18n/zh-TW/docs/index.md +++ b/i18n/zh-TW/docs/index.md @@ -66,8 +66,9 @@ def build_report() -> dict: @app.get("/report") async def report(cache: AppCache): - # 在自己的程式碼中快取任意 JSON 值。 - return await cache.get_or_set("report", build_report, ttl=300) + # 在自己的程式碼中快取任意 JSON 值。lock=True 讓同時未命中時只執行一次 + # build_report(0.4.0 起的預設值)。 + return await cache.get_or_set("report", build_report, ttl=300, lock=True) ``` > [!IMPORTANT] diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index 56e0eb2..cae5aeb 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -53,13 +53,29 @@ def test_redis_load_from_config_initializes_client_and_prefix() -> None: @requires_redis_package -@pytest.mark.parametrize("encoding", ["utf-8", "UTF8", "utf_8"]) -def test_redis_utf8_encoding_does_not_warn(encoding: str) -> None: - """UTF-8 under any of its aliases is accepted silently (#122).""" +def test_redis_without_encoding_does_not_warn() -> None: + """Leaving ``encoding`` out is the forward-compatible form (#126).""" with warnings.catch_warnings(): warnings.simplefilter("error") + backend = AsyncRedisCacheBackend(port=UNCONNECTED_PORT) + + kwargs = getattr(backend.client.connection_pool, "connection_kwargs", {}) + assert kwargs.get("encoding", "utf-8") == "utf-8" + + +@requires_redis_package +@pytest.mark.parametrize("encoding", ["utf-8", "UTF8", "utf_8"]) +def test_redis_explicit_utf8_encoding_is_deprecated(encoding: str) -> None: + """UTF-8 under any alias is only deprecated, not a corruption risk (#122, #126).""" + with pytest.warns( + DeprecationWarning, match=r"Version 0\.4\.0 removes it" + ) as record: AsyncRedisCacheBackend(port=UNCONNECTED_PORT, encoding=encoding) + assert len(record) == 1 + assert record[0].filename == __file__ + assert "issues/126" in str(record[0].message) + @requires_redis_package @pytest.mark.parametrize("encoding", ["latin-1", "utf-16", "ascii"]) @@ -73,9 +89,10 @@ def test_redis_non_utf8_encoding_warns(encoding: str) -> None: @requires_redis_package def test_redis_unknown_encoding_is_left_to_the_client() -> None: - """An unknown codec is rejected by redis-py, not warned about (#122).""" + """An unknown codec is rejected by redis-py, not reported as corrupting (#122).""" with warnings.catch_warnings(): warnings.simplefilter("error") + warnings.filterwarnings("ignore", category=DeprecationWarning) with pytest.raises(LookupError): AsyncRedisCacheBackend(port=UNCONNECTED_PORT, encoding="no-such-codec") @@ -85,8 +102,36 @@ def test_redis_load_from_config_warns_on_non_utf8_encoding() -> None: """RedisConfig goes through the same check (#122).""" from fastapi_cachex.backends.config import RedisConfig - with pytest.warns(RuntimeWarning, match="encoding='latin-1'"): - AsyncRedisCacheBackend.load_from_config(RedisConfig(encoding="latin-1")) + config = RedisConfig(encoding="latin-1") + with ( + pytest.warns(DeprecationWarning, match="RedisConfig"), + pytest.warns(RuntimeWarning, match="encoding='latin-1'"), + ): + AsyncRedisCacheBackend.load_from_config(config) + + +@requires_redis_package +def test_redis_load_from_config_deprecates_an_explicit_encoding() -> None: + """Setting RedisConfig.encoding at all is deprecated (#126).""" + from fastapi_cachex.backends.config import RedisConfig + + config = RedisConfig(encoding="utf-8", port=UNCONNECTED_PORT) + with pytest.warns( + DeprecationWarning, match=r"RedisConfig\(encoding='utf-8'\)" + ) as record: + AsyncRedisCacheBackend.load_from_config(config) + + assert len(record) == 1 + assert record[0].filename == __file__ + + +@requires_redis_package +def test_redis_load_from_config_without_encoding_does_not_warn() -> None: + from fastapi_cachex.backends.config import RedisConfig + + with warnings.catch_warnings(): + warnings.simplefilter("error") + AsyncRedisCacheBackend.load_from_config(RedisConfig(port=UNCONNECTED_PORT)) async def test_redis_latin1_encoding_corrupts_non_ascii_content() -> None: @@ -120,7 +165,6 @@ def test_redis_load_from_config_passes_all_fields() -> None: port=6380, password=SecretStr("s3cr3t"), db=3, - encoding="utf-8", socket_timeout=2.5, socket_connect_timeout=1.5, key_prefix="myapp:", diff --git a/tests/session/conftest.py b/tests/session/conftest.py index 40a3f47..5401a9d 100644 --- a/tests/session/conftest.py +++ b/tests/session/conftest.py @@ -15,8 +15,14 @@ def backend() -> MemoryBackend: @pytest.fixture def config() -> SessionConfig: - """Create session config for testing.""" - return SessionConfig(secret_key="a" * 32) + """Create session config for testing. + + The cookie settings are explicit so FastAPICacheXSessionMiddleware does not + warn about the 0.4.0 cookie defaults (#256). + """ + return SessionConfig( + secret_key="a" * 32, cookie_name="session", cookie_https_only=False + ) @pytest.fixture diff --git a/tests/session/test_0_4_0_notices.py b/tests/session/test_0_4_0_notices.py new file mode 100644 index 0000000..183db71 --- /dev/null +++ b/tests/session/test_0_4_0_notices.py @@ -0,0 +1,239 @@ +"""Advance notices for the session changes 0.4.0 makes (#352). + +- #256: the default session cookie becomes ``__Host-session`` with Secure. +- #131: ``get_session_manager`` resolves through ``SessionManagerProxy`` only. +""" + +import warnings + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient + +from fastapi_cachex.backends.memory import MemoryBackend +from fastapi_cachex.session import FastAPICacheXSessionMiddleware +from fastapi_cachex.session import SessionConfig +from fastapi_cachex.session import SessionManager +from fastapi_cachex.session.dependencies import ClientIPDep +from fastapi_cachex.session.dependencies import SessionManagerDep +from fastapi_cachex.session.dependencies import rotate_session_id +from fastapi_cachex.session.proxy import SessionManagerProxy + +SECRET = "a" * 32 + +# --- #256: default cookie name and Secure flag --------------------------------- + + +def _middleware(config: SessionConfig) -> FastAPICacheXSessionMiddleware: + manager = SessionManager(MemoryBackend(), config) + return FastAPICacheXSessionMiddleware(FastAPI(), session_manager=manager) + + +@pytest.mark.parametrize( + ("settings", "named"), + [ + ({}, "default cookie_name and cookie_https_only"), + ({"cookie_https_only": True}, "default cookie_name of"), + ({"cookie_name": "session"}, "default cookie_https_only of"), + ], +) +def test_middleware_warns_while_a_cookie_default_is_relied_upon( + settings: dict[str, object], named: str +) -> None: + config = SessionConfig(secret_key=SECRET, **settings) + + with pytest.warns(FutureWarning, match=named) as record: + _middleware(config) + + message = str(record[0].message) + assert "__Host-session" in message + assert "cookie_name='session', cookie_https_only=False" in message + + +@pytest.mark.parametrize( + "settings", + [ + {"cookie_name": "session", "cookie_https_only": False}, + {"cookie_name": "__Host-session", "cookie_https_only": True}, + {"cookie_name": "sid", "cookie_https_only": True}, + ], +) +def test_middleware_is_silent_with_explicit_cookie_settings( + settings: dict[str, object], +) -> None: + config = SessionConfig(secret_key=SECRET, **settings) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + _middleware(config) + + +def test_middleware_checks_the_config_it_falls_back_to() -> None: + """With no ``config=``, the manager's config is the one that counts.""" + manager = SessionManager(MemoryBackend(), SessionConfig(secret_key=SECRET)) + + with pytest.warns(FutureWarning, match="default cookie_name and"): + FastAPICacheXSessionMiddleware(FastAPI(), session_manager=manager) + + +def test_middleware_warns_when_the_app_builds_its_stack() -> None: + """``add_middleware`` defers construction; the first request builds it.""" + manager = SessionManager(MemoryBackend(), SessionConfig(secret_key=SECRET)) + app = FastAPI() + app.add_middleware(FastAPICacheXSessionMiddleware, session_manager=manager) + + @app.get("/") + async def index() -> dict[str, bool]: + return {"ok": True} + + with pytest.warns(FutureWarning, match="__Host-session"): + response = TestClient(app).get("/") + + assert response.status_code == 200 + + +@pytest.mark.parametrize( + ("settings", "requirement"), + [ + ({"cookie_name": "__Host-session"}, "cookie_https_only=True"), + ({"cookie_name": "__Secure-session"}, "cookie_https_only=True"), + ( + { + "cookie_name": "__Host-session", + "cookie_https_only": True, + "cookie_path": "/app", + }, + 'cookie_path="/"', + ), + ( + { + "cookie_name": "__Host-session", + "cookie_https_only": True, + "cookie_domain": "example.com", + }, + "cookie_domain=None", + ), + ], +) +def test_prefixed_cookie_names_browsers_refuse_warn( + settings: dict[str, object], requirement: str +) -> None: + with pytest.warns(UserWarning, match="Version 0.4.0 will reject") as record: + SessionConfig(secret_key=SECRET, **settings) + + assert requirement in str(record[0].message) + + +@pytest.mark.parametrize( + "settings", + [ + {"cookie_name": "__Host-session", "cookie_https_only": True}, + { + "cookie_name": "__Secure-session", + "cookie_https_only": True, + "cookie_path": "/app", + "cookie_domain": "example.com", + }, + {"cookie_name": "session", "cookie_domain": "example.com"}, + ], +) +def test_valid_cookie_prefixes_do_not_warn(settings: dict[str, object]) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + SessionConfig(secret_key=SECRET, **settings) + + +# --- #131: get_session_manager through SessionManagerProxy --------------------- + + +def _manager_app(manager: SessionManager, config: SessionConfig) -> FastAPI: + app = FastAPI() + app.add_middleware( + FastAPICacheXSessionMiddleware, session_manager=manager, config=config + ) + + @app.get("/manager") + async def read_manager(mgr: SessionManagerDep) -> dict[str, bool]: + return {"is_same": mgr is manager} + + @app.get("/ip") + async def read_ip(client_ip: ClientIPDep) -> dict[str, str | None]: + return {"ip": client_ip} + + @app.post("/rotate") + async def rotate(request: Request) -> dict[str, bool]: + return {"rotated": await rotate_session_id(request)} + + return app + + +@pytest.mark.parametrize("path", ["/manager", "/ip"]) +def test_get_session_manager_warns_without_the_proxy( + manager: SessionManager, config: SessionConfig, path: str +) -> None: + client = TestClient(_manager_app(manager, config)) + + with pytest.warns(FutureWarning, match=r"SessionManagerProxy\.set\(") as record: + first = client.get(path) + # Once per app: later requests stay quiet. + second = client.get(path) + + assert first.status_code == second.status_code == 200 + assert "issues/131" in str(record[0].message) + + +def test_rotate_session_id_warns_without_the_proxy( + manager: SessionManager, config: SessionConfig +) -> None: + client = TestClient(_manager_app(manager, config)) + + with pytest.warns(FutureWarning, match="rotate_session_id"): + response = client.post("/rotate") + + assert response.json() == {"rotated": False} + + +def test_get_session_manager_warns_when_the_proxy_holds_another_manager( + manager: SessionManager, config: SessionConfig +) -> None: + SessionManagerProxy.set(SessionManager(MemoryBackend(), config)) + client = TestClient(_manager_app(manager, config)) + + with pytest.warns(FutureWarning, match="not the one set in SessionManagerProxy"): + response = client.get("/manager") + + # 0.3.x still answers with the middleware's manager. + assert response.json() == {"is_same": True} + + +def test_get_session_manager_is_silent_when_the_proxy_agrees( + manager: SessionManager, config: SessionConfig +) -> None: + SessionManagerProxy.set(manager) + client = TestClient(_manager_app(manager, config)) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + response = client.get("/manager") + client.post("/rotate") + + assert response.json() == {"is_same": True} + + +def test_middleware_from_the_proxy_is_silent(config: SessionConfig) -> None: + """The recommended wiring: the middleware picks the manager up from the proxy.""" + manager = SessionManager(MemoryBackend(), config) + SessionManagerProxy.set(manager) + app = FastAPI() + app.add_middleware(FastAPICacheXSessionMiddleware) + + @app.get("/manager") + async def read_manager(mgr: SessionManagerDep) -> dict[str, bool]: + return {"is_same": mgr is manager} + + with warnings.catch_warnings(): + warnings.simplefilter("error") + response = TestClient(app).get("/manager") + + assert response.json() == {"is_same": True} diff --git a/tests/session/test_client_ip.py b/tests/session/test_client_ip.py index 57eb30f..1e052bc 100644 --- a/tests/session/test_client_ip.py +++ b/tests/session/test_client_ip.py @@ -16,6 +16,7 @@ from fastapi_cachex.session.dependencies import ClientIPDep from fastapi_cachex.session.dependencies import OptionalSession from fastapi_cachex.session.dependencies import SessionManagerDep +from fastapi_cachex.session.proxy import SessionManagerProxy def _connection(peer: str | None, headers: dict[str, str]) -> HTTPConnection: @@ -52,9 +53,14 @@ def test_get_client_ip_without_peer(): def proxied_app() -> FastAPI: """An app with IP binding whose only peer (TestClient) is a trusted proxy.""" config = SessionConfig( - secret_key="a" * 32, ip_binding=True, trusted_proxies=["testclient"] + secret_key="a" * 32, + ip_binding=True, + trusted_proxies=["testclient"], + cookie_name="session", + cookie_https_only=False, ) manager = SessionManager(MemoryBackend(), config) + SessionManagerProxy.set(manager) app = FastAPI() app.add_middleware( diff --git a/tests/session/test_get_session_manager.py b/tests/session/test_get_session_manager.py index b03d4a4..43c3432 100644 --- a/tests/session/test_get_session_manager.py +++ b/tests/session/test_get_session_manager.py @@ -22,6 +22,7 @@ def test_get_session_manager_dependency( ) -> None: """Test get_session_manager dependency retrieves manager from app state.""" app = FastAPI() + SessionManagerProxy.set(manager) # Add middleware which stores manager in app.state app.add_middleware(SessionMiddleware, session_manager=manager, config=config) @@ -46,6 +47,7 @@ async def test_get_session_manager_allows_create_session( ) -> None: """Test using get_session_manager to create sessions.""" app = FastAPI() + SessionManagerProxy.set(manager) # Add middleware app.add_middleware(SessionMiddleware, session_manager=manager, config=config) @@ -96,6 +98,7 @@ async def test_get_session_manager_full_workflow( ) -> None: """Test complete workflow: create, get, delete session using dependency.""" app = FastAPI() + SessionManagerProxy.set(manager) app.add_middleware(SessionMiddleware, session_manager=manager, config=config) @app.post("/login") @@ -139,6 +142,7 @@ def test_session_manager_type_annotation( from fastapi_cachex.session.dependencies import SessionManagerDep app = FastAPI() + SessionManagerProxy.set(manager) app.add_middleware(SessionMiddleware, session_manager=manager, config=config) @app.get("/test") diff --git a/tests/session/test_hardening.py b/tests/session/test_hardening.py index 59d7c53..4e0723e 100644 --- a/tests/session/test_hardening.py +++ b/tests/session/test_hardening.py @@ -17,7 +17,12 @@ @pytest.fixture def config() -> SessionConfig: """Session config with IP binding on, trusting nothing by default.""" - return SessionConfig(secret_key="a" * 32, ip_binding=True) + return SessionConfig( + secret_key="a" * 32, + ip_binding=True, + cookie_name="session", + cookie_https_only=False, + ) def test_non_ascii_signature_is_rejected_not_raised(): @@ -128,7 +133,11 @@ async def test_prepended_forwarded_entry_cannot_satisfy_ip_binding( # The proxy in this test is TestClient itself, which presents as # "testclient"; trusting it puts us in the deployment the setting exists for. config = SessionConfig( - secret_key="a" * 32, ip_binding=True, trusted_proxies=["testclient"] + secret_key="a" * 32, + ip_binding=True, + trusted_proxies=["testclient"], + cookie_name="session", + cookie_https_only=False, ) manager = SessionManager(MemoryBackend(), config) diff --git a/tests/session/test_login.py b/tests/session/test_login.py index 1fd9959..bb4e899 100644 --- a/tests/session/test_login.py +++ b/tests/session/test_login.py @@ -303,7 +303,11 @@ async def test_new_session_is_bound_like_the_middlewares( ) -> None: """A session login() creates gets the IP and User-Agent bindings.""" config = SessionConfig( - secret_key="a" * 32, ip_binding=True, user_agent_binding=True + secret_key="a" * 32, + ip_binding=True, + user_agent_binding=True, + cookie_name="session", + cookie_https_only=False, ) manager = SessionManager(backend, config) client = TestClient(_login_app(manager, config)) diff --git a/tests/session/test_lookup_writes.py b/tests/session/test_lookup_writes.py index 29efb40..2c402ef 100644 --- a/tests/session/test_lookup_writes.py +++ b/tests/session/test_lookup_writes.py @@ -41,7 +41,9 @@ async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None def _manager(**overrides: object) -> tuple[SessionManager, SpyBackend]: backend = SpyBackend() - config = SessionConfig(secret_key="a" * 32, **overrides) + config = SessionConfig( + secret_key="a" * 32, cookie_name="session", cookie_https_only=False, **overrides + ) return SessionManager(backend, config), backend diff --git a/tests/session/test_middleware.py b/tests/session/test_middleware.py index 7fda759..13a59f2 100644 --- a/tests/session/test_middleware.py +++ b/tests/session/test_middleware.py @@ -239,6 +239,7 @@ async def test_user_agent_binding_checks_the_request_user_agent( async def test_a_rotated_session_id_is_sent_back_as_a_new_token( manager: SessionManager, config: SessionConfig ) -> None: + SessionManagerProxy.set(manager) session, token = await manager.create_session(user=SessionUser(user_id="u1")) client = _client(manager, config) diff --git a/tests/session/test_starlette_middleware.py b/tests/session/test_starlette_middleware.py index 3d2f973..09d07e9 100644 --- a/tests/session/test_starlette_middleware.py +++ b/tests/session/test_starlette_middleware.py @@ -28,6 +28,7 @@ from fastapi_cachex.session.middleware import FastAPICacheXSessionMiddleware from fastapi_cachex.session.middleware import SessionMiddleware from fastapi_cachex.session.models import SessionUser +from fastapi_cachex.session.proxy import SessionManagerProxy def _extract_cookie_token(set_cookie_header: str, cookie_name: str) -> str: @@ -83,6 +84,7 @@ def test_set_cookie_header_includes_secure_and_domain_flags( """cookie_https_only and cookie_domain must be reflected in Set-Cookie.""" config = SessionConfig( secret_key="a" * 32, + cookie_name="session", cookie_https_only=True, cookie_domain="example.com", ) @@ -106,7 +108,9 @@ async def set_route(request: Request) -> dict[str, bool]: async def test_call_passes_through_non_http_scope() -> None: """Non-http/websocket scopes (e.g. lifespan) must bypass session handling entirely.""" - config = SessionConfig(secret_key="a" * 32) + config = SessionConfig( + secret_key="a" * 32, cookie_name="session", cookie_https_only=False + ) manager = SessionManager(MemoryBackend(), config) calls: list[str] = [] @@ -127,6 +131,7 @@ def test_get_session_manager_di_stashed_once_across_requests( """Session manager is only stashed on app.state on the first request, not re-stashed.""" from fastapi_cachex.session.dependencies import SessionManagerDep + SessionManagerProxy.set(manager) app = FastAPI() app.add_middleware( FastAPICacheXSessionMiddleware, session_manager=manager, config=config @@ -814,6 +819,7 @@ async def test_deprecated_middleware_sends_regenerated_token( def _rotating_login_app(manager: SessionManager, config: SessionConfig) -> FastAPI: """An app with a Starlette-style login that rotates the ID first (#225).""" + SessionManagerProxy.set(manager) app = FastAPI() app.add_middleware( FastAPICacheXSessionMiddleware, session_manager=manager, config=config @@ -957,7 +963,12 @@ async def test_cookie_token_varies_on_cookie_and_the_headers_checked_first( def test_disabled_bearer_transport_is_left_out_of_vary(manager: SessionManager) -> None: - config = SessionConfig(secret_key="a" * 32, use_bearer_token=False) + config = SessionConfig( + secret_key="a" * 32, + use_bearer_token=False, + cookie_name="session", + cookie_https_only=False, + ) client = TestClient(_session_reading_app(manager, config)) response = client.get("/read") diff --git a/tests/session/test_token_response_caching.py b/tests/session/test_token_response_caching.py index d007c33..136bd8b 100644 --- a/tests/session/test_token_response_caching.py +++ b/tests/session/test_token_response_caching.py @@ -32,7 +32,12 @@ @pytest.fixture def sliding_config() -> SessionConfig: """A config that renews the token on every load.""" - return SessionConfig(secret_key="a" * 32, sliding_threshold=1.0) + return SessionConfig( + secret_key="a" * 32, + sliding_threshold=1.0, + cookie_name="session", + cookie_https_only=False, + ) @pytest.fixture diff --git a/tests/session/test_token_serializers.py b/tests/session/test_token_serializers.py index 51549b7..2ea338f 100644 --- a/tests/session/test_token_serializers.py +++ b/tests/session/test_token_serializers.py @@ -339,6 +339,7 @@ def test_jwt_serializer_warns_once_about_a_short_hmac_key( if warns: assert len(messages) == 1 assert f"requires for {algorithm}" in messages[0] + assert "Version 0.4.0 will reject" in messages[0] assert caught[0].filename == __file__ else: assert messages == [] diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 014b890..092c01a 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -66,7 +66,7 @@ async def cache_manager(request: Any) -> AsyncGenerator[CacheManager, Any]: ) BackendProxy.set(backend) - manager = CacheManager() + manager = CacheManager(lock=False) yield manager @@ -225,7 +225,7 @@ async def test_get_or_set_treats_corrupted_content_as_miss( memory_backend: MemoryBackend, ) -> None: """get_or_set() calls factory when stored content can't be decoded.""" - manager = CacheManager(backend=memory_backend) + manager = CacheManager(backend=memory_backend, lock=False) cache_key = f"{manager.key_prefix}bad" entry = CacheEntry(fingerprint="x", content=b"not valid json") await memory_backend.set(cache_key, entry, ttl=60) @@ -264,7 +264,7 @@ async def test_get_or_set_miss_encodes_the_value_once( memory_backend: MemoryBackend, monkeypatch: pytest.MonkeyPatch ) -> None: """get_or_set() serialises the factory's value once and stores those bytes.""" - manager = CacheManager(backend=memory_backend) + manager = CacheManager(backend=memory_backend, lock=False) encoded: list[Any] = [] original = CacheManager._encode @@ -734,10 +734,11 @@ async def factory() -> str: await asyncio.sleep(0.02) return "data" - results = await asyncio.gather( - manager.get_or_set("key", factory), - manager.get_or_set("key", factory), - ) + with pytest.warns(FutureWarning, match="default lock=False"): + results = await asyncio.gather( + manager.get_or_set("key", factory), + manager.get_or_set("key", factory), + ) assert list(results) == ["data", "data"] assert calls == 2 @@ -1047,9 +1048,6 @@ async def test_stampede_protection_validation(memory_backend: MemoryBackend) -> with pytest.raises(TypeError, match="lock must be a bool"): CacheManager(backend=memory_backend, lock=1) # type: ignore[arg-type] - with pytest.raises(TypeError, match="lock must be a bool"): - CacheManager(backend=memory_backend, lock=None) # type: ignore[arg-type] - with pytest.raises(ValueError, match="ttl must be a positive number"): CacheManager(backend=memory_backend, lock_ttl=0) @@ -1062,7 +1060,7 @@ async def test_stampede_protection_validation(memory_backend: MemoryBackend) -> with pytest.raises(TypeError, match="ttl must be an int"): CacheManager(backend=memory_backend, lock_ttl=1.5) # type: ignore[arg-type] - manager = CacheManager(backend=memory_backend) + manager = CacheManager(backend=memory_backend, lock=False) with pytest.raises(TypeError, match="lock must be a bool or None"): await manager.get_or_set("key", lambda: 1, lock="yes") # type: ignore[arg-type] @@ -1441,3 +1439,61 @@ async def sneaky_get(_key: str, default: Any = None) -> Any: with unittest.mock.patch.object(manager, "get", side_effect=sneaky_get): result = await manager.get_or_set("sneaky", lambda: "from_factory", lock=True) assert result == "sneaky_cached" + + +# --- 0.4.0 lock default advance notice (#280) --------------------------------- + + +async def test_get_or_set_warns_when_relying_on_the_lock_default( + memory_backend: MemoryBackend, +) -> None: + """Neither the manager nor the call chose lock=: the 0.4.0 flip is announced once.""" + manager = CacheManager(backend=memory_backend) + + assert manager.lock is False + with pytest.warns(FutureWarning, match=r"0\.4\.0 turns stampede protection on"): + assert await manager.get_or_set("a", lambda: 1) == 1 + # Once per manager: later calls relying on the default stay quiet. + assert await manager.get_or_set("b", lambda: 2) == 2 + + +async def test_get_or_set_warning_points_at_the_caller( + memory_backend: MemoryBackend, +) -> None: + manager = CacheManager(backend=memory_backend) + + with pytest.warns(FutureWarning) as record: + await manager.get_or_set("a", lambda: 1) + + assert record[0].filename == __file__ + + +@pytest.mark.parametrize("lock", [False, True]) +async def test_get_or_set_is_silent_with_an_explicit_manager_lock( + memory_backend: MemoryBackend, lock: bool +) -> None: + manager = CacheManager(backend=memory_backend, lock=lock) + + assert manager.lock is lock + assert await manager.get_or_set("a", lambda: 1) == 1 + + +@pytest.mark.parametrize("lock", [False, True]) +async def test_get_or_set_is_silent_with_an_explicit_call_lock( + memory_backend: MemoryBackend, lock: bool +) -> None: + manager = CacheManager(backend=memory_backend) + + assert await manager.get_or_set("a", lambda: 1, lock=lock) == 1 + # An explicit call does not use up the warning for a later default call. + with pytest.warns(FutureWarning, match="default lock=False"): + await manager.get_or_set("b", lambda: 2) + + +async def test_lock_none_is_the_unset_default(memory_backend: MemoryBackend) -> None: + """lock=None means "not chosen": False today, and get_or_set() still warns.""" + manager = CacheManager(backend=memory_backend, lock=None) + + assert manager.lock is False + with pytest.warns(FutureWarning, match="default lock=False"): + await manager.get_or_set("a", lambda: 1) diff --git a/tests/test_changelog_release.py b/tests/test_changelog_release.py index fa1c5eb..d489bdc 100644 --- a/tests/test_changelog_release.py +++ b/tests/test_changelog_release.py @@ -202,10 +202,15 @@ def test_the_repository_changelog_can_be_released(): assert rewritten.startswith("# Changelog\n") assert text.count("removed in 0.3.5") == rewritten.count("removed in 0.3.5") assert f"[0.9.9]: {BASE}/compare/v{latest[1]}...v0.9.9" in rewritten - assert body.startswith("### ") + # A release notice written under `Unreleased` (text above the first + # heading) may open the section; the entries follow under their headings. + notice, heading, _ = body.partition("### ") + assert heading + assert "\n- " not in f"\n{notice}" # Every entry waiting in `Unreleased` has the summary the release page needs. notes = release_notes(body, "0.9.9", "2026-09-14") - assert notes.startswith("### ") + assert notes.startswith(notice) + assert notes.removeprefix(notice).startswith("### ") # Every pending fragment reaches the release page with its issue link. for fragment in fragments: assert f"[#{fragment.issue}]({BASE}/issues/{fragment.issue})" in notes diff --git a/zensical.toml b/zensical.toml index 95c0b9c..08a2dc2 100644 --- a/zensical.toml +++ b/zensical.toml @@ -28,6 +28,7 @@ nav = [ { "State management" = "STATE.md" }, { "Distributed lock" = "LOCK.md" }, ] }, + { "Migrating to 0.4.0" = "MIGRATING_0_4.md" }, { "API reference" = [ { "HTTP caching" = "api/http-caching.md" }, { "Backends" = "api/backends.md" }, diff --git a/zensical.zh-TW.toml b/zensical.zh-TW.toml index b56e4a8..edd8e9b 100644 --- a/zensical.zh-TW.toml +++ b/zensical.zh-TW.toml @@ -32,6 +32,7 @@ nav = [ { "OAuth state" = "STATE.md" }, { "分散式鎖" = "LOCK.md" }, ] }, + { "遷移至 0.4.0" = "MIGRATING_0_4.md" }, { "API 參考(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/" }, { "開發" = [ { "開發指南(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/" },