diff --git a/CLAUDE.md b/CLAUDE.md index aa06b06..6e49003 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ tox -e lowest # every direct dependency at its declared floor, Python 3.10 ### Module Structure -The library has four independent subsystems: +The library has five independent subsystems: **1. HTTP Caching (`fastapi_cachex/cache.py`, `proxy.py`, `backends/`)** - `@cache(...)` decorator wraps FastAPI route handlers. It injects a `Request` parameter into the handler signature if not already present, so the handler does not need to declare it. @@ -67,11 +67,11 @@ The library has four independent subsystems: - `clear()`/`clear_prefix()` are built on `backend.get_all_keys()` + `backend.delete_many()`, so they are no-ops on the Memcached backend (see below). **3. Session Management (`fastapi_cachex/session/`)** -- Optional subsystem, activated via `SessionMiddleware` and `SessionManagerProxy`. +- Optional subsystem, activated via `FastAPICacheXSessionMiddleware` (the header-only `SessionMiddleware` is deprecated until 0.4.0) and `SessionManagerProxy`. - `SessionManager` handles create/get/update/delete/invalidate/regenerate operations. It stores `Session` Pydantic models serialized as JSON, wrapped in `CacheEntry` for backend compatibility. - Token signing: `simple` format uses HMAC-SHA256 (`SecurityManager`); `jwt` format uses PyJWT (optional dependency `fastapi-cachex[jwt]`). - `get_session()` saves only when sliding expiration renewed the session (or with `touch=True`), so the stored `last_accessed` is the last write, not the last lookup. Session entries use the constant fingerprint `"session"`; nothing compares it. -- Session token is passed via custom header (`X-Session-Token` by default) or `Authorization: Bearer` token. +- Session token is passed via custom header (`X-Session-Token` by default), `Authorization: Bearer` token, or (`FastAPICacheXSessionMiddleware` only) the session cookie. - `SessionManagerProxy` mirrors the `BackendProxy` pattern for managing the `SessionManager` singleton. - Key FastAPI dependencies: `get_session`, `require_session`, `get_optional_session` (in `session/dependencies.py`). These accept anonymous sessions (`user=None`); `require_user_session` / `AuthenticatedSession` also require a user. `UserSessionDep` is still an alias of `SessionDep` until 0.4.0. - `JWTTokenSerializer` emits one `UserWarning` at construction when `secret_key` is shorter (in UTF-8 bytes) than the HMAC hash output (48 for HS384, 64 for HS512). @@ -83,6 +83,10 @@ The library has four independent subsystems: - Uses the same cache backends, with key prefix `oauth_state:` by default. - `create_state(binding=...)` / `consume_state(state, binding=...)` bind a state to the client that started the flow (SHA-256 stored, `hmac.compare_digest`); a mismatch in either direction raises `InvalidStateError`, and the state is consumed either way. +**5. Distributed Lock (`fastapi_cachex/lock.py`)** +- `CacheLock(name, ttl=60, ...)` is a lease on `set_if_absent` / `delete_if_equals` / `expire_if_equals`, with a per-instance random token; keys use the `lock:` prefix by default. +- It uses `BackendProxy.get()` (no `MemoryBackend` fallback). One instance per acquisition: re-acquiring a held instance raises `RuntimeError`; `async with` raises `LockTimeoutError`; `release()`/`extend()` return `False` once the lease is lost. + ### Backends (`fastapi_cachex/backends/`) All backends implement `BaseCacheBackend` (abstract base in `backends/base.py`): @@ -90,17 +94,18 @@ All backends implement `BaseCacheBackend` (abstract base in `backends/base.py`): - `AsyncRedisCacheBackend` (`backends/redis.py`): Fully async; uses `SCAN` (not `KEYS`) for pattern operations. Requires `redis[hiredis]` and `orjson` extras. - `MemcachedBackend` (`backends/memcached.py`): `clear_pattern`/`get_all_keys` are no-ops (return `0`/`[]` with a `RuntimeWarning`) since the Memcached protocol has no key enumeration. Runs the sync pymemcache client in worker threads with connection pooling (`use_pooling=True`, `default_noreply=False`), so concurrent calls never share a socket and every write is acknowledged before the next call on another socket can observe it. Requires the `memcached` extra (`memcache` is a deprecated alias until 0.4.0). -Backend keys are namespaced automatically (default prefix: `fastapi_cachex:`). +The Redis and Memcached backends namespace keys automatically (`key_prefix`, default `fastapi_cachex:`); `MemoryBackend` has no prefix. -Four non-abstract atomic primitives live on the base class with non-atomic fallbacks, and every built-in backend overrides them (see `docs/BACKENDS.md` "Atomic backend primitives"): +Five non-abstract atomic primitives live on the base class with non-atomic fallbacks, and every built-in backend overrides them (see `docs/BACKENDS.md` "Atomic backend primitives"): - `increment(key, delta=1, ttl=None) -> int`: fixed-window counter; `ttl` applies only when the counter is created. Redis runs a registered Lua script, Memcached uses `ADD` + `INCR`/`DECR`, memory works under its lock. A counter reads back through `get()` as a `CacheEntry` with `COUNTER_FINGERPRINT` (`types.py`). - `get_and_delete(key) -> CacheEntry | None`: one-shot retrieval (Redis `GETDEL`, Memcached `gets` + `cas(..., exptime=-1)`, retried up to 16 times, then `CacheXError`). `StateManager.consume_state`, `delete_state`, `CacheManager.delete` and `invalidate()` use it. `delete()` keeps returning `None` for 0.3.x compatibility. - `set_if_absent(key, value, ttl=None) -> bool`: claim-if-free for locks/slots. Redis `SET NX EX`, Memcached `ADD`, memory under its lock. - `delete_if_equals(key, expected) -> bool`: release only while the key still holds `expected` (compared as decoded `CacheEntry`). Redis compares in Python then deletes via a Lua script that re-checks the raw bytes; Memcached uses `GETS` + `CAS` with exptime `-1` (immediate expiry), since classic `DELETE` has no CAS. +- `expire_if_equals(key, expected, ttl) -> bool`: renew the TTL only while the key still holds `expected` (used by `CacheLock.extend`). Redis compares in Python then runs a Lua `GET` compare + `EXPIRE`; Memcached `GETS` + `CAS` writing the same bytes with the new exptime. `validate_ttl` (in `backends/base.py`) accepts `None` or an `int` from 1 to `MAX_TTL` (2**31 - 1) and raises `TypeError` for floats/bools; `validate_delta` requires an `int` in signed 64-bit range. Both run before any I/O. Memcached's `_expiry` also rejects expiries after 2038-01-19. -`delete_many(keys) -> int` is the fifth non-abstract base method: a per-key loop by default, one batched operation on Redis (`DEL`) and Memory (single lock). Memcached sends one acknowledged `DELETE` per key inside a single worker call and counts the ones that existed (pymemcache's `delete_many` returns `True` regardless). Every Memcached multi-step op (`increment`, `get_and_delete`, `*_if_equals`) also runs as one sync helper in one `asyncio.to_thread` call. +`delete_many(keys) -> int` is the sixth non-abstract base method: a per-key loop by default, one batched operation on Redis (`DEL`) and Memory (single lock). Memcached sends one acknowledged `DELETE` per key inside a single worker call and counts the ones that existed (pymemcache's `delete_many` returns `True` regardless). Every Memcached multi-step op (`increment`, `get_and_delete`, `*_if_equals`) also runs as one sync helper in one `asyncio.to_thread` call. `backends/codec.py` holds the JSON `CacheEntry` codec shared by Redis and Memcached; `decode_entry` maps a bare integer to a counter entry and every malformed value to `None`. diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index 47a0736..eda3028 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -52,6 +52,11 @@ await manager.clear_pattern("user:*") # matches "myapp:user:*" - Keys live under their own `cache:`-prefixed namespace by default, separate from the HTTP route cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache entries. +- The prefix is matched as a plain string prefix. A manager with + `key_prefix="cache:"` therefore also clears the entries of one with + `key_prefix="cache:users:"`, and an empty `key_prefix` makes `clear()` remove + everything in the backend, including HTTP responses, locks, OAuth states and + sessions. Give each manager a prefix that does not start with another's. - The `AppCache` dependency creates and registers a default `CacheManager` the first time it is used; `CacheManagerProxy.set()` registers your own instead. diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 67dc45c..45786da 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -12,8 +12,10 @@ lives in one backend, registered once at startup with `BackendProxy.set()`. | Simple caching | Memcached | Stable, mature (but no key enumeration, so no pattern/path clearing or monitoring) | | Multi-process deployments | Redis | Shared cache, consistency | -All backends namespace their keys with a prefix (`fastapi_cachex:` by default, -`key_prefix=` to change it) to avoid conflicts with other applications. +The Redis and Memcached backends namespace their keys with a prefix +(`fastapi_cachex:` by default, `key_prefix=` to change it) to avoid conflicts with +other applications on the same server. `MemoryBackend` lives inside one process +and has no prefix. ## In-memory (default) @@ -205,7 +207,9 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): and the monitoring routes treat it like any other entry. Incrementing a key that holds anything else raises `CacheXError` on every backend, even a cached response whose body is a number. A counter written with - `set(key, counter_entry(n))` can be incremented on every backend. `delta` + `set(key, counter_entry(n))` can be incremented on every backend, except that + Memcached counters are unsigned: there `n` must be from 0 to 2**64 - 1, and + incrementing a negative one raises `CacheXError`. `delta` must be an `int` within the signed 64-bit range; anything else raises `TypeError` or `ValueError` before the backend is touched. - `get_and_delete(key) -> CacheEntry | None` — Memory pops under its lock, Redis diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f8eb5e0..83aaa52 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -242,7 +242,10 @@ uv run zensical build --strict -f zensical.zh-TW.toml # also run by the Docs wo The English pages are the source of truth. The translation may lag behind them, and every translated page carries a banner saying so, with a link to the English original. Pages that are not translated yet are linked from the -translation's navigation to the English site. When translating, follow the +translation's navigation to the English site. The API reference (`docs/api/`), +this development guide and the changelog stay English-only (the API pages are +generated from the docstrings, which are in English); the translation links to +their English versions. When translating, follow the terms in [`i18n/zh-TW/GLOSSARY.md`](https://github.com/allen0099/FastAPI-CacheX/blob/master/i18n/zh-TW/GLOSSARY.md). ### Adding API reference pages diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 9f98061..989d685 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -159,8 +159,9 @@ app.add_middleware( ) ``` -All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) -to avoid conflicts with other applications. `CacheManager` (see +The Redis and Memcached backends also put their own prefix (`fastapi_cachex:` by +default) in front of every key, so other applications can share the server; +`MemoryBackend` has no prefix. `CacheManager` (see [Application cache](APP_CACHE.md)) uses a separate, simpler `cache:`-prefixed key namespace instead of this `|||`-separated format, since its keys aren't tied to HTTP requests. @@ -241,6 +242,10 @@ something a shared cache cannot see, use option 1 instead. > A key built from a raw request header is a horizontal privilege escalation: > sending `X-User-Id: ` returns that user's cached response. +The key builder runs only when `@cache` reads or writes the backend, so it is not +called for `no_store=True`, `private=True` or routes without a `ttl`. Before 0.3.8 +it was, only to feed a debug log. Keep it free of side effects. + ## Clearing the cache ### By path or pattern @@ -312,8 +317,9 @@ async def update_item(item_id: int, request: Request): ``` `invalidate(request, key_builder=None)` returns `True` when an entry existed and -was removed, `False` otherwise (including when no backend is configured — it -never raises). The request you hand it must produce the cached route's key: +was removed, `False` otherwise, including when no backend is configured. An +error from the backend itself is raised to the caller (see +[When the backend fails](#when-the-backend-fails)). The request you hand it must produce the cached route's key: same method, host, path and query string. If the cached route uses a custom `key_builder`, pass the same one here, or the key will not match. diff --git a/docs/JWT_CLAIMS.md b/docs/JWT_CLAIMS.md index 2574d56..0160aa3 100644 --- a/docs/JWT_CLAIMS.md +++ b/docs/JWT_CLAIMS.md @@ -87,7 +87,7 @@ FastAPI-CacheX uses a **stateful session** model, which is fundamentally differe └─────────────────────────────────────────────────────────┘ ``` -A valid JWT signature is necessary but not sufficient: after decoding, `SessionManager.get_session()` still loads the session from the backend and rejects it if it is missing, not active, expired, past `absolute_timeout`, or fails IP/User-Agent binding checks. Any decoding failure is raised as `SessionTokenError`, which `SessionMiddleware` treats as "no session". +A valid JWT signature is necessary but not sufficient: after decoding, `SessionManager.get_session()` still loads the session from the backend and rejects it if it is missing, not active, expired, past `absolute_timeout`, or fails IP/User-Agent binding checks. Any decoding failure is raised as `SessionTokenError`, which the session middleware (`FastAPICacheXSessionMiddleware`) treats as "no session". ### Why a Stateful Session @@ -173,187 +173,136 @@ await session_manager.delete_session("session-abc123") ## Extension Guide: Adding Custom Claims -If your application needs additional JWT claims, subclass `JWTTokenSerializer` and pass an instance to `SessionManager` through its `token_serializer` argument. Any object with `to_string(token) -> str` and `from_string(token_str) -> SessionToken` methods (the `TokenSerializer` protocol) will do; `from_string()` should raise `ValueError` for invalid tokens, which `SessionManager` converts into `SessionTokenError`. +If your application needs additional JWT claims, write your own serializer and pass an instance to `SessionManager` through its `token_serializer` argument. Any object with `to_string(token) -> str` and `from_string(token_str) -> SessionToken` methods (the `TokenSerializer` protocol) will do; `from_string()` should raise `ValueError` for invalid tokens, which `SessionManager` converts into `SessionTokenError`. -The examples below honour `token.expires_at` in `to_string()` the same way the built-in serializer does, so that `exp` keeps following sliding expiration. - -### Example 1: Adding `jti` and `nbf` +The base class below does what the built-in `JWTTokenSerializer` does and leaves two hooks for the extra claims. It keeps its own copy of the settings, read from the public `SessionConfig` fields, instead of reaching into `JWTTokenSerializer`'s private attributes, which may change in any release. Like the built-in serializer, it follows `token.expires_at` in `to_string()`, so `exp` keeps up with sliding expiration. ```python from __future__ import annotations -import uuid from datetime import datetime, timezone +from typing import Any + +import jwt +from fastapi_cachex.session import SessionConfig from fastapi_cachex.session.models import SessionToken -from fastapi_cachex.session.token_serializers import JWTTokenSerializer -class ExtendedJWTSerializer(JWTTokenSerializer): - """Extended JWT serializer that adds the jti and nbf claims.""" +class CustomClaimsJWTSerializer: + """JWT serializer with the built-in claims plus extra ones from subclasses.""" + + # Claims that from_string() requires besides sid, iat and exp. + required_claims: tuple[str, ...] = () + + def __init__(self, config: SessionConfig) -> None: + self.secret = config.secret_key.get_secret_value() + self.algorithm = config.jwt_algorithm # must be HS256, HS384 or HS512 + self.issuer = config.jwt_issuer + self.audience = config.jwt_audience + self.leeway = config.jwt_leeway + self.session_ttl = config.session_ttl + + def extra_claims(self, token: SessionToken) -> dict[str, Any]: + """Return the claims to add to a new token.""" + return {} + + def check_claims(self, payload: dict[str, Any]) -> None: + """Raise ValueError if the extra claims of a verified token are wrong.""" def to_string(self, token: SessionToken) -> str: - """Encode a SessionToken as a JWT, including jti and nbf.""" + """Encode a SessionToken as a signed JWT.""" iat = int(token.issued_at.timestamp()) if token.expires_at is not None: exp = int(token.expires_at.timestamp()) else: - exp = iat + int(self._session_ttl) + exp = iat + self.session_ttl - payload: dict[str, object] = { - "sid": token.session_id, - "iat": iat, - "exp": exp, - "jti": str(uuid.uuid4()), # Unique token ID - "nbf": iat, # Not before = issued at - } + payload: dict[str, Any] = {"sid": token.session_id, "iat": iat, "exp": exp} + if self.issuer: + payload["iss"] = self.issuer + if self.audience: + payload["aud"] = self.audience + payload.update(self.extra_claims(token)) + return jwt.encode(payload, self.secret, algorithm=self.algorithm) - if self._issuer: - payload["iss"] = self._issuer - if self._audience: - payload["aud"] = self._audience + def from_string(self, token_str: str) -> SessionToken: + """Verify a JWT and turn it back into a SessionToken.""" + try: + payload = jwt.decode( + token_str, + self.secret, + algorithms=[self.algorithm], + issuer=self.issuer, + audience=self.audience, + leeway=self.leeway, + options={"require": ["sid", "iat", "exp", *self.required_claims]}, + ) + except jwt.InvalidTokenError as e: + msg = "Invalid JWT token" + raise ValueError(msg) from e - encoded = self.jwt_encoder.encode( - payload, self._secret, algorithm=self._algorithm + self.check_claims(payload) + issued_at = datetime.fromtimestamp(int(payload["iat"]), tz=timezone.utc) + return SessionToken( + session_id=str(payload["sid"]), signature="", issued_at=issued_at ) - return str(encoded) +``` - def from_string(self, token_str: str) -> SessionToken: - """Decode and verify a JWT, including jti and nbf validation.""" - options = { - "require": ["sid", "iat", "exp", "jti"], # Require jti - "verify_signature": True, - "verify_exp": True, - "verify_iat": True, - "verify_nbf": True, # Verify nbf - } +PyJWT verifies the signature, `exp`, `iat` and (when present) `nbf` by default, and `iss`/`aud` when `issuer`/`audience` are given. Two checks of the built-in serializer are not repeated here: it rejects an asymmetric `jwt_algorithm` and warns about a `secret_key` shorter than the HMAC output. This class signs with `secret_key` too, so keep an `HS*` algorithm; for an asymmetric one, hold the private and public keys in the class and use them in `jwt.encode()` and `jwt.decode()`. - kwargs: dict[str, object] = { - "algorithms": [self._algorithm], - "options": options, - "leeway": self._leeway, - "key": self._secret, - } +### Example 1: Adding `jti` and `nbf` - if self._issuer: - kwargs["issuer"] = self._issuer - if self._audience: - kwargs["audience"] = self._audience +```python +import uuid +from typing import Any + +from fastapi_cachex.session.models import SessionToken - try: - payload = self.jwt_encoder.decode(token_str, **kwargs) - except Exception as e: - msg = "Invalid JWT token" - raise ValueError(msg) from e - # Extract the standard fields - sid = str(payload["sid"]) - iat = int(payload["iat"]) - issued_at = datetime.fromtimestamp(iat, tz=timezone.utc) +class ExtendedJWTSerializer(CustomClaimsJWTSerializer): + """Adds the jti and nbf claims.""" - # Optional: record the jti for auditing - jti = payload.get("jti") - # logger.info("JWT decoded: sid=%s, jti=%s", sid, jti) + required_claims = ("jti", "nbf") - return SessionToken(session_id=sid, signature="", issued_at=issued_at) + def extra_claims(self, token: SessionToken) -> dict[str, Any]: + return { + "jti": str(uuid.uuid4()), # Unique token ID + "nbf": int(token.issued_at.timestamp()), # Not before = issued at + } ``` ### Example 2: Adding Multi-Tenant Custom Claims ```python -from __future__ import annotations - -from datetime import datetime, timezone from typing import Any from fastapi_cachex.session import SessionConfig from fastapi_cachex.session.models import SessionToken -from fastapi_cachex.session.token_serializers import JWTTokenSerializer -class MultiTenantJWTSerializer(JWTTokenSerializer): - """Multi-tenant JWT serializer that adds tenant_id and api_version.""" +class MultiTenantJWTSerializer(CustomClaimsJWTSerializer): + """Adds tenant_id and api_version, and rejects tokens for other tenants.""" + + required_claims = ("tenant_id", "api_version") def __init__( - self, - config: SessionConfig, - tenant_id: str, - api_version: str = "v1", - jwt_module: Any | None = None, + self, config: SessionConfig, tenant_id: str, api_version: str = "v1" ) -> None: - super().__init__(config, jwt_module) + super().__init__(config) self.tenant_id = tenant_id self.api_version = api_version - def to_string(self, token: SessionToken) -> str: - """Encode a SessionToken as a JWT, including tenant information.""" - iat = int(token.issued_at.timestamp()) - if token.expires_at is not None: - exp = int(token.expires_at.timestamp()) - else: - exp = iat + int(self._session_ttl) - - payload: dict[str, object] = { - "sid": token.session_id, - "iat": iat, - "exp": exp, - # Custom claims - "tenant_id": self.tenant_id, - "api_version": self.api_version, - } - - if self._issuer: - payload["iss"] = self._issuer - if self._audience: - payload["aud"] = self._audience - - encoded = self.jwt_encoder.encode( - payload, self._secret, algorithm=self._algorithm - ) - return str(encoded) - - def from_string(self, token_str: str) -> SessionToken: - """Decode and verify a JWT, validating the tenant information.""" - options = { - "require": ["sid", "iat", "exp", "tenant_id", "api_version"], - "verify_signature": True, - "verify_exp": True, - "verify_iat": True, - } - - kwargs: dict[str, object] = { - "algorithms": [self._algorithm], - "options": options, - "leeway": self._leeway, - "key": self._secret, - } - - if self._issuer: - kwargs["issuer"] = self._issuer - if self._audience: - kwargs["audience"] = self._audience - - try: - payload = self.jwt_encoder.decode(token_str, **kwargs) - except Exception as e: - msg = "Invalid JWT token" - raise ValueError(msg) from e + def extra_claims(self, token: SessionToken) -> dict[str, Any]: + return {"tenant_id": self.tenant_id, "api_version": self.api_version} - # Validate the tenant information + def check_claims(self, payload: dict[str, Any]) -> None: if payload["tenant_id"] != self.tenant_id: msg = f"Invalid tenant_id: expected {self.tenant_id}, got {payload['tenant_id']}" raise ValueError(msg) - if payload["api_version"] != self.api_version: msg = f"Unsupported API version: {payload['api_version']}" raise ValueError(msg) - - # Extract the standard fields - sid = str(payload["sid"]) - iat = int(payload["iat"]) - issued_at = datetime.fromtimestamp(iat, tz=timezone.utc) - - return SessionToken(session_id=sid, signature="", issued_at=issued_at) ``` ### Using a Custom Serializer @@ -364,7 +313,11 @@ class MultiTenantJWTSerializer(JWTTokenSerializer): from fastapi import FastAPI from fastapi_cachex.backends import AsyncRedisCacheBackend -from fastapi_cachex.session import SessionConfig, SessionManager, SessionMiddleware +from fastapi_cachex.session import ( + FastAPICacheXSessionMiddleware, + SessionConfig, + SessionManager, +) app = FastAPI() @@ -390,7 +343,7 @@ manager = SessionManager(backend, config, token_serializer=custom_serializer) # Add the middleware app.add_middleware( - SessionMiddleware, + FastAPICacheXSessionMiddleware, session_manager=manager, config=config, ) @@ -435,12 +388,12 @@ from fastapi import Depends, FastAPI, HTTPException from fastapi_cachex.backends import AsyncRedisCacheBackend from fastapi_cachex.session import ( + FastAPICacheXSessionMiddleware, Session, SessionConfig, SessionManager, - SessionMiddleware, SessionUser, - get_session, + require_user_session, ) # Uses the MultiTenantJWTSerializer defined above @@ -467,7 +420,7 @@ serializer = MultiTenantJWTSerializer( manager = SessionManager(backend, config, token_serializer=serializer) app.add_middleware( - SessionMiddleware, + FastAPICacheXSessionMiddleware, session_manager=manager, config=config, ) @@ -492,11 +445,13 @@ async def login(username: str, password: str) -> dict[str, str]: @app.get("/api/profile") -async def get_profile(session: Session = Depends(get_session)) -> dict[str, str | None]: +async def get_profile( + session: Session = Depends(require_user_session), +) -> dict[str, str | None]: """Protected endpoint; tenant_id is validated automatically.""" # tenant_id and api_version were already validated while decoding the JWT. - # A token for another tenant fails to decode, so the middleware sets no - # session and get_session responds with 401. + # A token for another tenant fails to decode, so the middleware loads no + # session and require_user_session responds with 401. assert session.user is not None return { "user_id": session.user.user_id, @@ -530,47 +485,52 @@ Always validate custom claims in `from_string()`: ```python # ❌ Bad: no validation -payload = self.jwt_encoder.decode(token_str, **kwargs) +payload = jwt.decode(token_str, self.secret, algorithms=[self.algorithm]) tenant_id = payload.get("tenant_id") # May be missing or invalid # ✅ Good: strict validation -options = {"require": ["sid", "iat", "exp", "tenant_id"]} -payload = self.jwt_encoder.decode(token_str, **kwargs) -if payload["tenant_id"] != self.expected_tenant_id: +payload = jwt.decode( + token_str, + self.secret, + algorithms=[self.algorithm], + options={"require": ["sid", "iat", "exp", "tenant_id"]}, +) +if payload["tenant_id"] != self.tenant_id: raise ValueError("Invalid tenant_id") ``` ### 4. Key Rotation -To support key rotation, you can use the `kid` (Key ID) header parameter. The following is a sketch; `payload`, `kwargs` and `_get_key_by_id()` are yours to fill in: +To support key rotation, you can use the `kid` (Key ID) header parameter. The following is a sketch built on `CustomClaimsJWTSerializer`; building `payload` and `kwargs` works as in its `to_string()` and `from_string()`: ```python -class KeyRotationJWTSerializer(JWTTokenSerializer): +class KeyRotationJWTSerializer(CustomClaimsJWTSerializer): def __init__( - self, config: SessionConfig, key_id: str, jwt_module: Any | None = None + self, config: SessionConfig, keys: dict[str, str], current_key_id: str ) -> None: - super().__init__(config, jwt_module) - self.key_id = key_id + super().__init__(config) + # Key ID -> secret. Keep a retired key until its tokens have expired. + self.keys = keys + self.current_key_id = current_key_id def to_string(self, token: SessionToken) -> str: - # Add kid to the JWT header - encoded = self.jwt_encoder.encode( + # Sign with the current key and name it in the header + return jwt.encode( payload, - self._secret, - algorithm=self._algorithm, - headers={"kid": self.key_id}, + self.keys[self.current_key_id], + algorithm=self.algorithm, + headers={"kid": self.current_key_id}, ) - return str(encoded) def from_string(self, token_str: str) -> SessionToken: - # Parse the header to obtain kid - header = self.jwt_encoder.get_unverified_header(token_str) - kid = header.get("kid") - - # Pick the matching key based on kid - key = self._get_key_by_id(kid) + # Read kid from the (not yet verified) header and pick the matching key + kid = jwt.get_unverified_header(token_str).get("kid") + key = self.keys.get(kid) + if key is None: + msg = "Unknown key ID" + raise ValueError(msg) - payload = self.jwt_encoder.decode(token_str, key=key, **kwargs) + payload = jwt.decode(token_str, key, algorithms=[self.algorithm], **kwargs) # ... ``` @@ -579,6 +539,7 @@ class KeyRotationJWTSerializer(JWTTokenSerializer): Add tests for your custom serializer: ```python +import jwt import pytest from fastapi_cachex.backends.memory import MemoryBackend @@ -603,12 +564,8 @@ async def test_custom_claims_included(): session, token = await manager.create_session(user=user) # The token can be decoded and carries the custom claim - assert ( - serializer.jwt_encoder.decode(token, options={"verify_signature": False})[ - "tenant_id" - ] - == "test-tenant" - ) + claims = jwt.decode(token, options={"verify_signature": False}) + assert claims["tenant_id"] == "test-tenant" # get_session returns (session, renewed_token) retrieved, _renewed = await manager.get_session(token) @@ -653,7 +610,7 @@ A: In most cases, no. `nbf` is for tokens that are issued in advance but become ### Q: Can I add claims without writing code? -A: Not currently; custom claims require subclassing `JWTTokenSerializer`. A future version might add a configuration option such as the hypothetical one below (it does not exist today, and `SessionConfig` rejects unknown fields): +A: Not currently; custom claims require a custom `token_serializer`, such as the classes in the [Extension Guide](#extension-guide-adding-custom-claims). A future version might add a configuration option such as the hypothetical one below (it does not exist today, and `SessionConfig` rejects unknown fields): ```python SessionConfig( diff --git a/docs/LOCK.md b/docs/LOCK.md index 0a74702..1d2d2d9 100644 --- a/docs/LOCK.md +++ b/docs/LOCK.md @@ -1,6 +1,6 @@ # Distributed Lock -`CacheLock` provides a distributed lock helper built on backend atomic primitives (`set_if_absent`, `delete_if_equals`, `expire_if_equals`). It guarantees mutual exclusion across multiple processes or containers sharing the same cache backend. +`CacheLock` is a distributed lock built on backend atomic primitives (`set_if_absent`, `delete_if_equals`, `expire_if_equals`). While a holder keeps its lock within the lock's `ttl`, no other process or container sharing the same cache backend can acquire it. The lock is a lease, not a fencing lock: a holder that runs past the `ttl` without renewing loses it, and another caller may acquire it while the first is still working (see **TTL Expiration** below). ```python from fastapi import HTTPException @@ -8,7 +8,7 @@ from fastapi_cachex import CacheLock, LockTimeoutError # Usage as an async context manager: async with CacheLock(f"report:{report_id}", ttl=30): - ... # only one process holds the lock at any time + ... # one holder at a time, as long as the work fits in the ttl # Explicit acquire and release calls: lock = CacheLock(f"stream:{user_id}", ttl=60) @@ -25,16 +25,17 @@ finally: - **Safety & Token Ownership**: Each `CacheLock` instance generates a unique token (`secrets.token_hex(16)`) stored inside a `CacheEntry`. Releases (`release()`) and extensions (`extend()`) use owner-checked backend primitives (`delete_if_equals` and `expire_if_equals`), so a holder whose lock expired cannot release or renew a lock claimed by someone else. - **Blocking & Non-blocking Modes**: - - Non-blocking (`acquire(blocking=False)`): Performs a single atomic `set_if_absent` and immediately returns `True` if acquired or `False` if held. - - Blocking (`acquire(blocking=True, timeout=None, poll_interval=0.1)`): Retries at `poll_interval` seconds until acquired or until `timeout` seconds elapse. The default `timeout=None` makes a blocking `acquire()` (and `async with CacheLock(...)`) wait indefinitely until the lock becomes free. If a finite `timeout` is reached, `acquire()` returns `False`. + - Non-blocking (`acquire(blocking=False)`): Performs a single atomic `set_if_absent` and immediately returns `True` if acquired or `False` if held. + - Blocking (`acquire(blocking=True, timeout=None, poll_interval=0.1)`): Retries at `poll_interval` seconds until acquired or until `timeout` seconds elapse. The default `timeout=None` makes a blocking `acquire()` (and `async with CacheLock(...)`) wait indefinitely until the lock becomes free. If a finite `timeout` is reached, `acquire()` returns `False`. - **Context Manager Timeouts**: Entering a context manager (`async with CacheLock(...)`) invokes `acquire()`. If acquisition fails or times out, it raises `LockTimeoutError`. - **TTL Expiration**: If a task takes longer than its `ttl` and fails to renew, the lock entry expires in the backend and becomes free. Another process or container can then acquire the lock while the original code is still running. Subsequent calls to `extend()` or `release()` by the original holder will safely return `False` without throwing an error. Always choose a `ttl` longer than the expected work, or call `extend()` periodically during long-running operations. - **TTL Renewal (`extend`)**: `extend(ttl)` updates the key's TTL only while the lock is still owned by this holder instance, preventing race conditions on expired locks. - **One Instance per Acquisition Rule**: A single `CacheLock` instance tracks its active ownership state. Re-entering or sharing a single `CacheLock` instance across concurrent tasks raises a `RuntimeError`. Instantiate a new `CacheLock` instance for each acquisition. - **Namespace**: Lock keys live under their own `lock:` prefix by default (e.g. `lock:report:123`), separate from `cache:` and `oauth_state:`. +- **Backend**: Without `backend=`, a lock uses the backend registered with `BackendProxy.set()`. Unlike `@cache`, it does not fall back to a `MemoryBackend`: with no backend registered, `acquire()` raises `BackendNotFoundError`. A lock only excludes processes that share its backend, so a per-process `MemoryBackend` only coordinates tasks within one process. > [!NOTE] > `CacheLock` works on all built-in backends (`MemoryBackend`, `AsyncRedisCacheBackend`, `MemcachedBackend`). -> Redis executes Lua scripts for atomic operations, Memcached uses `ADD` and `CAS`, and the memory backend operates under its internal lock. +> Redis uses `SET NX EX` and Lua scripts, Memcached uses `ADD` and `CAS`, and the memory backend operates under its internal lock. The full class signature and options are documented in the [API reference](api/lock.md). diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index e60e145..1c3f7d4 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -42,6 +42,7 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*" - `get_or_set()` 不提供 cache stampede 保護:同一個鍵同時發生多次未命中時,每一次都會執行 `factory`。 - `add()` 只在鍵尚未被占用時寫入值,並回傳是否有寫入。檢查與寫入是同一個後端原子操作(`set_if_absent`),因此適合「每個鍵只做一次」的工作,例如 webhook 或電子郵件的去重。已過期的鍵視為未被占用;存放無法解碼之值的鍵則不算,即使 `get()` 會把它當成未命中。 - 鍵預設位於獨立、以 `cache:` 為前綴的命名空間,與 HTTP 路由快取及 OAuth state 分開,因此 `clear()`/`clear_prefix()` 絕不會動到無關的快取項目。 +- 前綴是以單純的字串前綴比對。因此 `key_prefix="cache:"` 的 manager 也會清除 `key_prefix="cache:users:"` 的 manager 的項目;而空的 `key_prefix` 會讓 `clear()` 移除後端中的所有內容,包括 HTTP 回應、鎖、OAuth state 與 Session。請讓每個 manager 的前綴都不以另一個 manager 的前綴開頭。 - `AppCache` 依賴項在第一次使用時會建立並註冊一個預設的 `CacheManager`;`CacheManagerProxy.set()` 則可改為註冊你自己的實例。 > [!NOTE] diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index 3e6c72e..0889ed7 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -11,7 +11,7 @@ | 簡單快取 | Memcached | 穩定、成熟(但無法列舉鍵,因此不支援依模式/路徑清除,也無法監控) | | 多行程部署 | Redis | 共用快取、一致性 | -所有後端都會以前綴為鍵建立命名空間(預設為 `fastapi_cachex:`,可用 `key_prefix=` 變更),以避免與其他應用程式衝突。 +Redis 與 Memcached 後端會以前綴為鍵建立命名空間(預設為 `fastapi_cachex:`,可用 `key_prefix=` 變更),以避免與同一台伺服器上的其他應用程式衝突。`MemoryBackend` 只存在於單一行程內,沒有前綴。 ## 記憶體(預設) {#in-memory-default} @@ -64,6 +64,7 @@ BackendProxy.set(backend) - 使用 SCAN 而非 KEYS,可安全用於正式環境(不會阻塞) - 預設以 `fastapi_cachex:` 前綴建立命名空間;多租戶情境可傳入 `key_prefix="myapp:cache:"` - 只有傳給 `clear_pattern()` 的模式是萬用字元(glob)模式。鍵前綴與傳給 `clear_path()` 的路徑都以字面值比對,因此其中的 `*`、`?`、`[` 或 `]` 不會觸及前綴以外的鍵,也不會漏掉該路徑 +- `clear_pattern()` 比對的是邏輯鍵,也就是不含後端前綴的鍵,並一律自行加上前綴。0.3.8 以前,以前綴開頭的模式會先去掉前綴再比對。在只有這種寫法能比對到項目時,它仍可使用,但會發出 `DeprecationWarning`,直到 0.4.0 為止 **從模型設定**:`RedisConfig` 是具有相同設定項與驗證的 pydantic 模型,當設定來自環境變數或設定檔時很方便: @@ -76,7 +77,7 @@ config = RedisConfig( port=6379, password=None, # SecretStr | None db=0, - encoding="utf-8", # 用戶端解碼伺服器回應的方式 + encoding="utf-8", # 保持 UTF-8;見下方說明 socket_timeout=1.0, # 秒;適用於讀取/寫入 socket_connect_timeout=1.0, key_prefix="fastapi_cachex:", @@ -86,6 +87,8 @@ 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 移除。 + 除非你需要 RESP3 的功能,*而且*你的 `hiredis` 建置支援它(RESP3 需要 hiredis >= 3.0),否則請保留 `protocol=2`。Redis 8.0 支援 RESP3,但較舊的 hiredis 會無法協商使用它。 ## Memcached {#memcached} @@ -144,12 +147,13 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300): await backend.delete_if_equals(f"stream:{user_id}", owner) ``` -- `increment(key, delta=1, ttl=None) -> int`:記憶體後端在鎖內執行讀取—修改—寫入,Redis 執行 Lua 腳本(`EXISTS` + `INCRBY` + `EXPIRE`),Memcached 則使用 `ADD` + `INCR`/`DECR`(Memcached 的計數器最低停在 0)。計數器可透過 `get()` 讀到,形式為 fingerprint 為 `COUNTER_FINGERPRINT`、內容為十進位數值的 `CacheEntry`,因此 `delete`/`clear*` 與監控路由都會把它當成一般項目處理。對存放快取回應的鍵執行 increment 會拋出 `CacheXError`。`delta` 必須是 signed 64 位元範圍內的 `int`,否則會在存取後端之前拋出 `TypeError` 或 `ValueError`。 +- `increment(key, delta=1, ttl=None) -> int`:記憶體後端在鎖內執行讀取—修改—寫入,Redis 執行 Lua 腳本(`EXISTS` + `INCRBY` + `EXPIRE`),Memcached 則使用 `ADD` + `INCR`/`DECR`(Memcached 的計數器最低停在 0)。計數器可透過 `get()` 讀到,形式為 fingerprint 為 `COUNTER_FINGERPRINT`、內容為十進位數值的 `CacheEntry`,因此 `delete`/`clear*` 與監控路由都會把它當成一般項目處理。對存放其他內容的鍵執行 increment,在每個後端上都會拋出 `CacheXError`,即使是本文剛好是數字的快取回應也一樣。以 `set(key, counter_entry(n))` 寫入的計數器在每個後端上都可以 increment,唯一的例外是 Memcached 的計數器沒有正負號:在它上面 `n` 必須介於 0 到 2**64 - 1 之間,對負數的計數器執行 increment 會拋出 `CacheXError`。`delta` 必須是 signed 64 位元範圍內的 `int`,否則會在存取後端之前拋出 `TypeError` 或 `ValueError`。 - `get_and_delete(key) -> CacheEntry | None`:記憶體後端在鎖內 pop,Redis 使用 `GETDEL`(伺服器 6.2 以上),Memcached 使用 `GETS` + `exptime=-1` 的 `CAS` 寫入(若中間有其他寫入者替換了值則會重試;連續 16 次都被替換時會拋出 `CacheXError`,而不是當成鍵不存在)。`StateManager.consume_state`、`StateManager.delete_state`、`CacheManager.delete` 與 `invalidate()` 都建立在它之上。 - `set_if_absent(key, value, ttl=None) -> bool`:只在 `key` 不存在時儲存 `value`(已過期的鍵視為不存在),並回報是否有寫入。記憶體後端在鎖內檢查,Redis 使用 `SET NX EX`,Memcached 使用 `ADD`。 - `delete_if_equals(key, expected) -> bool`:只在 `key` 仍存放 `expected` 時才移除它,因此項目已過期的持有者無法釋放已被他人取得的鎖。請在你儲存的項目中放入唯一的權杖,並以同一個項目釋放。記憶體後端在鎖內比較,Redis 透過 Lua 腳本刪除,並在腳本中重新檢查先前比較過的值,Memcached 則使用 `GETS` + 一個讓項目立即過期的 `CAS` 寫入(傳統協定的 `DELETE` 不接受 CAS 權杖)。 +- `expire_if_equals(key, expected, ttl) -> bool`:只在 `key` 仍存放 `expected` 時,才把它的 TTL 更新為 `ttl` 秒,因此長時間執行的鎖持有者可以續約租期,而不會在鎖已過期時動到別人的鎖。記憶體後端在鎖內更新,Redis 先在 Python 中比較,再執行 Lua 腳本(`GET` 比較 + `EXPIRE`),Memcached 則使用 `GETS` + 以新 exptime 寫回相同位元組的 `CAS`(`TOUCH` 不接受 CAS 權杖)。 -這四個方法在 `BaseCacheBackend` 上都有非原子性的後備實作,因此只實作抽象方法的第三方後端仍可正常運作;覆寫它們才能得到真正的原子性。 +這五個方法在 `BaseCacheBackend` 上都有非原子性的後備實作,因此只實作抽象方法的第三方後端仍可正常運作;覆寫它們才能得到真正的原子性。 ## TTL 值 {#ttl-values} diff --git a/i18n/zh-TW/docs/CACHE_FLOW.md b/i18n/zh-TW/docs/CACHE_FLOW.md index 2d5fa2f..98436a6 100644 --- a/i18n/zh-TW/docs/CACHE_FLOW.md +++ b/i18n/zh-TW/docs/CACHE_FLOW.md @@ -108,7 +108,7 @@ host 與路徑會先經過百分比編碼:`|` 變成 `%7C`,`%` 變成 `%25` | 其他情況 | 依序為:`public` 或 `private`、`max-age=`、`must-revalidate`、`stale-while-revalidate=` 或 `stale-if-error=`、`immutable` | > [!NOTE] -> 沒有設定 `ttl`(或設定 `ttl=0`,此時會送出 `max-age=0`)時,項目仍會寫入(不設過期時間),但永遠不會直接拿來回應:它只用於以 `304` 回應相符的 `If-None-Match`。請設定正數的 `ttl`,讓伺服器重播快取的回應。 +> 沒有設定 `ttl`(或設定 `ttl=0`,此時會送出 `max-age=0`)時,既不讀取也不寫入後端:每個請求都會執行 handler,相符的 `If-None-Match` 只有在與新產生的回應比對之後才會以 `304` 回應。請設定正數的 `ttl`,讓伺服器儲存並重播回應。 > [!WARNING] > **預設的快取鍵不包含使用者身分**,而且後端由所有 worker 與所有使用者共用。直接在需要驗證的端點上加上 `@cache(ttl=...)`,會把使用者 A 的回應提供給下一個請求相同路徑的使用者 B。 @@ -149,8 +149,8 @@ if request.method != "GET": if no_store: return await render() # 不讀取,不寫入 -if private: - response, etag = await render() # 共用後端既不讀取也不寫入 +if private or not ttl: + response, etag = await render() # 既不讀取也不寫入後端 return not_modified(...) if etag_matches(client_etag, etag) else response entry = await backend.get(cache_key) # 過期的項目已在此略過 @@ -162,7 +162,7 @@ if client_etag and no_cache: elif client_etag and entry and etag_matches(client_etag, entry.fingerprint): return not_modified(...) # 304,handler 不執行 -if entry and not no_cache and ttl is not None: +if entry and not no_cache: return Response( # 200,handler 不執行 content=entry.content, status_code=entry.status_code, @@ -332,7 +332,7 @@ async def cleanup_task(): | `no_store=True` | 既不讀取也不寫入快取;端點每次都會執行 | | `no_cache=True` | 端點每次都會執行以重新計算 ETag;與用戶端的 `If-None-Match` 相符時仍回傳 304,ETag 改變時會更新快取 | | `private=True` | **共用後端**既不讀取也不寫入;仍會送出 `Cache-Control: private`,並以新產生的內容比對 ETag | -| 沒有 `ttl` | 項目寫入時不設過期時間,但只用於 `If-None-Match` 重新驗證;沒有相符驗證器的請求每次都會執行 handler | +| 沒有 `ttl`(或 `ttl=0`) | 與 `private=True` 一樣,既不讀取也不寫入後端;端點每次都會執行,並以新產生的內容比對 ETag | | 快取過期(TTL 已到) | 端點會再次執行;`MemoryBackend` 讀取到過期項目時會當場刪除 | | 非 2xx 或 206 回應 | 原樣回傳、不寫入,既有的項目不受影響 | | 串流/檔案回應 | 無法計算 ETag;原樣回傳且不寫入 | diff --git a/i18n/zh-TW/docs/CONTRIBUTING.md b/i18n/zh-TW/docs/CONTRIBUTING.md new file mode 100644 index 0000000..788717c --- /dev/null +++ b/i18n/zh-TW/docs/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# 貢獻 FastAPI-CacheX {#contributing-to-fastapi-cachex} + +我們很歡迎你的參與!我們希望讓貢獻 FastAPI-CacheX 盡可能簡單而透明,無論是: + +- 回報錯誤 +- 討論程式碼的現況 +- 提交修正 +- 提議新功能 +- 成為維護者 + +## 開發流程 {#development-process} + +1. Fork 這個專案 +2. 建立你的功能分支(`git checkout -b feature/AmazingFeature`) +3. 提交你的變更(`git commit -m 'Add some AmazingFeature'`) +4. 推送到該分支(`git push origin feature/AmazingFeature`) +5. 開啟 Pull Request + +## 開發環境設定 {#development-setup} + +設定開發環境的詳細步驟,請參考[開發指南](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/)(英文)。 + +> [!WARNING] +> Redis 與 Memcached 的測試會清空它們連線的伺服器,因此除非你以 `CACHEX_TEST_REDIS_PORT`/`CACHEX_TEST_MEMCACHED_PORT` 指定連接埠,否則這些測試會被略過。請讓它們連到用完即丟的容器,絕對不要連到你想保留資料的伺服器,詳見 [Redis 與 Memcached 測試需主動啟用](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#redis-and-memcached-tests-are-opt-in)(英文)。 + +## Pull Request 流程 {#pull-request-process} + +1. 變更介面時,請更新 `docs/` 底下對應的指南(若變更應出現在首頁,也請更新 README)。只有英文頁面需要更新:[繁體中文翻譯](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#traditional-chinese-translation)(英文)允許落後於英文版。 +2. 若你的變更改變了行為、新增了公開 API,或修正了使用者可能遇到的問題,請在 [CHANGELOG.md](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) 的 `## [Unreleased]` 段落新增一筆項目。項目以粗體的一行摘要開頭,寫成 `- **What changed.** The details...`:發行說明只會列出這些摘要,缺少摘要的項目會讓 CI 失敗。發行時若該段落是空的,發行流程也會拒絕執行,因此遺漏終究會被發現,但要到發行時才會發現,而且只會知道「有人忘了寫」,無法得知是哪個 PR。 +3. 為任何新的依賴、功能或變更更新文件。新的公開 API 需要 docstring;若它位於尚未涵蓋的模組中,還需要在 `docs/api/` 底下新增項目。請以 `uv run zensical build --strict` 檢查網站(見[文件網站](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/#documentation-site)(英文))。 +4. 取得至少一位其他開發者的同意後,PR 即可合併。 + +## 有任何問題? {#any-questions} + +如果需要任何協助,歡迎開一個帶有 `question` 標籤的 issue! diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index aa0376c..7921ba6 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -37,7 +37,7 @@ async def non_store_endpoint(): | 指令 | 設定方式 | 寫入標頭 | 對伺服器端快取的影響 | |--------------------------|------------------------------------------|--------------------|----------------------------------------------------------------------------------------------------------------| -| `max-age` | `ttl=N` | :white_check_mark: | `N` 秒內直接回傳已儲存的回應,不執行 handler(`ttl=0` 或未設定:不直接回傳)。 | +| `max-age` | `ttl=N` | :white_check_mark: | `N` 秒內直接回傳已儲存的回應,不執行 handler(`ttl=0` 或未設定:不儲存任何內容)。 | | `no-cache` | `no_cache=True` | :white_check_mark: | 每個請求都執行 handler;回應仍會儲存,`If-None-Match` 相符時回 304。 | | `no-store` | `no_store=True` | :white_check_mark: | 不讀取也不儲存,也不設定 ETag。 | | `private` | `private=True` | :white_check_mark: | 完全不經過後端;每個請求都執行 handler,ETag 重新驗證仍有效。 | @@ -65,7 +65,7 @@ async def non_store_endpoint(): - **帶有 `If-None-Match` 標頭**:ETag 相符時回傳 HTTP 304 Not Modified - **使用 `no-cache` 指令**:先以新產生的內容強制重新驗證,再決定是否回 304 - **使用 `private=True`**:不從共用後端讀取,也不寫入;每次都執行 handler,只有 `If-None-Match` 重新驗證有效 -- **未設定 `ttl`**(`ttl=None`):快取的回應本文永遠不會直接回傳;每個請求都會執行 handler,唯一的例外是 `If-None-Match` 與已儲存 ETag 相符的請求,會得到 304 +- **未設定 `ttl`**(`ttl=None`):與 `private=True` 相同,不從後端讀取,也不寫入。每個請求都會執行 handler,只有當 `If-None-Match` 與新產生的回應相符時才回 304,因此內容變更後,舊的 ETag 永遠不會得到 304 - **使用 `ttl=0`**:送出 `max-age=0`,其餘行為與 `ttl=None` 相同。負數、非 `int`(例如 `1.5` 或 `True`)或超過 `MAX_TTL`(見 [TTL 值](BACKENDS.md#ttl-values))的 `ttl`,都會在套用裝飾器時以 `CacheXError` 拒絕 只有成功的回應會被儲存。handler *回傳* 非 2xx 狀態的回應(例如 `Response(..., status_code=404)`)會原樣傳出、永不快取,因此暫時性的錯誤不會取代或污染上一筆正常的項目。`206 Partial Content` 同樣排除在外,因為它的本文只對產生它的那個 `Range` 請求有意義。`Set-Cookie` 永遠不會被儲存或重播。 @@ -116,7 +116,7 @@ app.add_middleware( ) ``` -所有後端都會自動替鍵加上前綴(例如 `fastapi_cachex:`)作為命名空間,以避免與其他應用程式衝突。`CacheManager`(見[應用層快取](APP_CACHE.md))則使用另一個較簡單、以 `cache:` 為前綴的鍵命名空間,而不是這種以 `|||` 分隔的格式,因為它的鍵與 HTTP 請求無關。 +Redis 與 Memcached 後端還會在每個鍵前面加上自己的前綴(預設為 `fastapi_cachex:`),讓其他應用程式可以共用同一台伺服器;`MemoryBackend` 沒有前綴。`CacheManager`(見[應用層快取](APP_CACHE.md))則使用另一個較簡單、以 `cache:` 為前綴的鍵命名空間,而不是這種以 `|||` 分隔的格式,因為它的鍵與 HTTP 請求無關。 ### 需驗證身分的端點 {#authenticated-endpoints} @@ -179,6 +179,8 @@ async def my_dashboard(user: CurrentUser, response: Response): > > 以原始請求標頭組成的鍵等同於水平權限提升:送出 `X-User-Id: ` 就會拿到該使用者的快取回應。 +key builder 只在 `@cache` 讀取或寫入後端時執行,因此 `no_store=True`、`private=True` 或沒有 `ttl` 的路由不會呼叫它。0.3.8 以前它仍會被呼叫,但只用於除錯日誌。請讓它不帶副作用。 + ## 清除快取 {#clearing-the-cache} ### 依路徑或模式 {#by-path-or-pattern} @@ -238,7 +240,7 @@ async def update_item(item_id: int, request: Request): return {"invalidated": await invalidate(StarletteRequest(scope))} ``` -`invalidate(request, key_builder=None)` 在項目存在且已移除時回傳 `True`,否則回傳 `False`(包括尚未設定後端的情況;它永遠不會拋出例外)。傳入的請求必須能產生快取路由的鍵:相同的方法、主機、路徑與查詢字串。如果快取路由使用自訂的 `key_builder`,這裡也要傳入同一個,否則鍵不會相符。 +`invalidate(request, key_builder=None)` 在項目存在且已移除時回傳 `True`,否則回傳 `False`,包括尚未設定後端的情況。後端本身的錯誤則會拋給呼叫端(見[後端發生錯誤時](#when-the-backend-fails))。傳入的請求必須能產生快取路由的鍵:相同的方法、主機、路徑與查詢字串。如果快取路由使用自訂的 `key_builder`,這裡也要傳入同一個,否則鍵不會相符。 ## 監控路由 {#monitoring-routes} diff --git a/i18n/zh-TW/docs/JWT_CLAIMS.md b/i18n/zh-TW/docs/JWT_CLAIMS.md index 040dd0f..c706e22 100644 --- a/i18n/zh-TW/docs/JWT_CLAIMS.md +++ b/i18n/zh-TW/docs/JWT_CLAIMS.md @@ -87,7 +87,7 @@ FastAPI-CacheX 採用**有狀態 Session** 模型,與純粹無狀態的 JWT └─────────────────────────────────────────────────────────┘ ``` -有效的 JWT 簽章是必要條件,但並不充分:解碼之後,`SessionManager.get_session()` 仍會從後端載入 Session,並在 Session 不存在、不在啟用狀態、已過期、超過 `absolute_timeout`,或未通過 IP/User-Agent 綁定檢查時拒絕它。任何解碼失敗都會以 `SessionTokenError` 拋出,`SessionMiddleware` 會將其視為「沒有 Session」。 +有效的 JWT 簽章是必要條件,但並不充分:解碼之後,`SessionManager.get_session()` 仍會從後端載入 Session,並在 Session 不存在、不在啟用狀態、已過期、超過 `absolute_timeout`,或未通過 IP/User-Agent 綁定檢查時拒絕它。任何解碼失敗都會以 `SessionTokenError` 拋出,Session 中介軟體(`FastAPICacheXSessionMiddleware`)會將其視為「沒有 Session」。 ### 為什麼採用有狀態 Session {#why-a-stateful-session} @@ -173,187 +173,136 @@ await session_manager.delete_session("session-abc123") ## 擴充指南:加入自訂 claim {#extension-guide-adding-custom-claims} -如果你的應用程式需要額外的 JWT claim,請繼承 `JWTTokenSerializer`,並透過 `SessionManager` 的 `token_serializer` 參數傳入實例。任何具有 `to_string(token) -> str` 與 `from_string(token_str) -> SessionToken` 方法的物件(即 `TokenSerializer` 協定)都可以;`from_string()` 遇到無效權杖時應拋出 `ValueError`,`SessionManager` 會將它轉換為 `SessionTokenError`。 +如果你的應用程式需要額外的 JWT claim,請撰寫自己的序列化器,並透過 `SessionManager` 的 `token_serializer` 參數傳入實例。任何具有 `to_string(token) -> str` 與 `from_string(token_str) -> SessionToken` 方法的物件(即 `TokenSerializer` 協定)都可以;`from_string()` 遇到無效權杖時應拋出 `ValueError`,`SessionManager` 會將它轉換為 `SessionTokenError`。 -下面的範例在 `to_string()` 中與內建序列化器一樣採用 `token.expires_at`,讓 `exp` 持續跟著滑動過期。 - -### 範例 1:加入 `jti` 與 `nbf` {#example-1-adding-jti-and-nbf} +下面的基底類別做的事與內建的 `JWTTokenSerializer` 相同,並為額外的 claim 留下兩個掛鉤。它從 `SessionConfig` 的公開欄位讀取設定並自行保存,而不是存取 `JWTTokenSerializer` 的私有屬性,因為那些屬性在任何版本都可能改變。它與內建序列化器一樣,在 `to_string()` 中採用 `token.expires_at`,讓 `exp` 持續跟著滑動過期。 ```python from __future__ import annotations -import uuid from datetime import datetime, timezone +from typing import Any + +import jwt +from fastapi_cachex.session import SessionConfig from fastapi_cachex.session.models import SessionToken -from fastapi_cachex.session.token_serializers import JWTTokenSerializer -class ExtendedJWTSerializer(JWTTokenSerializer): - """Extended JWT serializer that adds the jti and nbf claims.""" +class CustomClaimsJWTSerializer: + """JWT serializer with the built-in claims plus extra ones from subclasses.""" + + # 除了 sid、iat 與 exp 之外,from_string() 還要求的 claim。 + required_claims: tuple[str, ...] = () + + def __init__(self, config: SessionConfig) -> None: + self.secret = config.secret_key.get_secret_value() + self.algorithm = config.jwt_algorithm # 必須是 HS256、HS384 或 HS512 + self.issuer = config.jwt_issuer + self.audience = config.jwt_audience + self.leeway = config.jwt_leeway + self.session_ttl = config.session_ttl + + def extra_claims(self, token: SessionToken) -> dict[str, Any]: + """Return the claims to add to a new token.""" + return {} + + def check_claims(self, payload: dict[str, Any]) -> None: + """Raise ValueError if the extra claims of a verified token are wrong.""" def to_string(self, token: SessionToken) -> str: - """Encode a SessionToken as a JWT, including jti and nbf.""" + """Encode a SessionToken as a signed JWT.""" iat = int(token.issued_at.timestamp()) if token.expires_at is not None: exp = int(token.expires_at.timestamp()) else: - exp = iat + int(self._session_ttl) + exp = iat + self.session_ttl - payload: dict[str, object] = { - "sid": token.session_id, - "iat": iat, - "exp": exp, - "jti": str(uuid.uuid4()), # 唯一的權杖 ID - "nbf": iat, # 生效時間 = 發行時間 - } + payload: dict[str, Any] = {"sid": token.session_id, "iat": iat, "exp": exp} + if self.issuer: + payload["iss"] = self.issuer + if self.audience: + payload["aud"] = self.audience + payload.update(self.extra_claims(token)) + return jwt.encode(payload, self.secret, algorithm=self.algorithm) - if self._issuer: - payload["iss"] = self._issuer - if self._audience: - payload["aud"] = self._audience + def from_string(self, token_str: str) -> SessionToken: + """Verify a JWT and turn it back into a SessionToken.""" + try: + payload = jwt.decode( + token_str, + self.secret, + algorithms=[self.algorithm], + issuer=self.issuer, + audience=self.audience, + leeway=self.leeway, + options={"require": ["sid", "iat", "exp", *self.required_claims]}, + ) + except jwt.InvalidTokenError as e: + msg = "Invalid JWT token" + raise ValueError(msg) from e - encoded = self.jwt_encoder.encode( - payload, self._secret, algorithm=self._algorithm + self.check_claims(payload) + issued_at = datetime.fromtimestamp(int(payload["iat"]), tz=timezone.utc) + return SessionToken( + session_id=str(payload["sid"]), signature="", issued_at=issued_at ) - return str(encoded) +``` - def from_string(self, token_str: str) -> SessionToken: - """Decode and verify a JWT, including jti and nbf validation.""" - options = { - "require": ["sid", "iat", "exp", "jti"], # 要求 jti - "verify_signature": True, - "verify_exp": True, - "verify_iat": True, - "verify_nbf": True, # 驗證 nbf - } +PyJWT 預設會驗證簽章、`exp`、`iat` 與(存在時的)`nbf`,並在傳入 `issuer`/`audience` 時驗證 `iss`/`aud`。內建序列化器的兩項檢查在這裡沒有重複:它會拒絕非對稱的 `jwt_algorithm`,並在 `secret_key` 短於 HMAC 輸出長度時發出警告。這個類別同樣以 `secret_key` 簽署,因此請使用 `HS*` 演算法;若要使用非對稱演算法,請在類別中保存私鑰與公鑰,並在 `jwt.encode()` 與 `jwt.decode()` 中使用它們。 - kwargs: dict[str, object] = { - "algorithms": [self._algorithm], - "options": options, - "leeway": self._leeway, - "key": self._secret, - } +### 範例 1:加入 `jti` 與 `nbf` {#example-1-adding-jti-and-nbf} - if self._issuer: - kwargs["issuer"] = self._issuer - if self._audience: - kwargs["audience"] = self._audience +```python +import uuid +from typing import Any + +from fastapi_cachex.session.models import SessionToken - try: - payload = self.jwt_encoder.decode(token_str, **kwargs) - except Exception as e: - msg = "Invalid JWT token" - raise ValueError(msg) from e - # 取出標準欄位 - sid = str(payload["sid"]) - iat = int(payload["iat"]) - issued_at = datetime.fromtimestamp(iat, tz=timezone.utc) +class ExtendedJWTSerializer(CustomClaimsJWTSerializer): + """Adds the jti and nbf claims.""" - # 選用:記錄 jti 以供稽核 - jti = payload.get("jti") - # logger.info("JWT decoded: sid=%s, jti=%s", sid, jti) + required_claims = ("jti", "nbf") - return SessionToken(session_id=sid, signature="", issued_at=issued_at) + def extra_claims(self, token: SessionToken) -> dict[str, Any]: + return { + "jti": str(uuid.uuid4()), # 唯一的權杖 ID + "nbf": int(token.issued_at.timestamp()), # 生效時間 = 發行時間 + } ``` ### 範例 2:加入多租戶的自訂 claim {#example-2-adding-multi-tenant-custom-claims} ```python -from __future__ import annotations - -from datetime import datetime, timezone from typing import Any from fastapi_cachex.session import SessionConfig from fastapi_cachex.session.models import SessionToken -from fastapi_cachex.session.token_serializers import JWTTokenSerializer -class MultiTenantJWTSerializer(JWTTokenSerializer): - """Multi-tenant JWT serializer that adds tenant_id and api_version.""" +class MultiTenantJWTSerializer(CustomClaimsJWTSerializer): + """Adds tenant_id and api_version, and rejects tokens for other tenants.""" + + required_claims = ("tenant_id", "api_version") def __init__( - self, - config: SessionConfig, - tenant_id: str, - api_version: str = "v1", - jwt_module: Any | None = None, + self, config: SessionConfig, tenant_id: str, api_version: str = "v1" ) -> None: - super().__init__(config, jwt_module) + super().__init__(config) self.tenant_id = tenant_id self.api_version = api_version - def to_string(self, token: SessionToken) -> str: - """Encode a SessionToken as a JWT, including tenant information.""" - iat = int(token.issued_at.timestamp()) - if token.expires_at is not None: - exp = int(token.expires_at.timestamp()) - else: - exp = iat + int(self._session_ttl) - - payload: dict[str, object] = { - "sid": token.session_id, - "iat": iat, - "exp": exp, - # 自訂 claim - "tenant_id": self.tenant_id, - "api_version": self.api_version, - } - - if self._issuer: - payload["iss"] = self._issuer - if self._audience: - payload["aud"] = self._audience - - encoded = self.jwt_encoder.encode( - payload, self._secret, algorithm=self._algorithm - ) - return str(encoded) - - def from_string(self, token_str: str) -> SessionToken: - """Decode and verify a JWT, validating the tenant information.""" - options = { - "require": ["sid", "iat", "exp", "tenant_id", "api_version"], - "verify_signature": True, - "verify_exp": True, - "verify_iat": True, - } - - kwargs: dict[str, object] = { - "algorithms": [self._algorithm], - "options": options, - "leeway": self._leeway, - "key": self._secret, - } - - if self._issuer: - kwargs["issuer"] = self._issuer - if self._audience: - kwargs["audience"] = self._audience - - try: - payload = self.jwt_encoder.decode(token_str, **kwargs) - except Exception as e: - msg = "Invalid JWT token" - raise ValueError(msg) from e + def extra_claims(self, token: SessionToken) -> dict[str, Any]: + return {"tenant_id": self.tenant_id, "api_version": self.api_version} - # 驗證租戶資訊 + def check_claims(self, payload: dict[str, Any]) -> None: if payload["tenant_id"] != self.tenant_id: msg = f"Invalid tenant_id: expected {self.tenant_id}, got {payload['tenant_id']}" raise ValueError(msg) - if payload["api_version"] != self.api_version: msg = f"Unsupported API version: {payload['api_version']}" raise ValueError(msg) - - # 取出標準欄位 - sid = str(payload["sid"]) - iat = int(payload["iat"]) - issued_at = datetime.fromtimestamp(iat, tz=timezone.utc) - - return SessionToken(session_id=sid, signature="", issued_at=issued_at) ``` ### 使用自訂序列化器 {#using-a-custom-serializer} @@ -364,7 +313,11 @@ class MultiTenantJWTSerializer(JWTTokenSerializer): from fastapi import FastAPI from fastapi_cachex.backends import AsyncRedisCacheBackend -from fastapi_cachex.session import SessionConfig, SessionManager, SessionMiddleware +from fastapi_cachex.session import ( + FastAPICacheXSessionMiddleware, + SessionConfig, + SessionManager, +) app = FastAPI() @@ -390,7 +343,7 @@ manager = SessionManager(backend, config, token_serializer=custom_serializer) # 加入中介軟體 app.add_middleware( - SessionMiddleware, + FastAPICacheXSessionMiddleware, session_manager=manager, config=config, ) @@ -435,12 +388,12 @@ from fastapi import Depends, FastAPI, HTTPException from fastapi_cachex.backends import AsyncRedisCacheBackend from fastapi_cachex.session import ( + FastAPICacheXSessionMiddleware, Session, SessionConfig, SessionManager, - SessionMiddleware, SessionUser, - get_session, + require_user_session, ) # 使用上面定義的 MultiTenantJWTSerializer @@ -467,7 +420,7 @@ serializer = MultiTenantJWTSerializer( manager = SessionManager(backend, config, token_serializer=serializer) app.add_middleware( - SessionMiddleware, + FastAPICacheXSessionMiddleware, session_manager=manager, config=config, ) @@ -492,11 +445,13 @@ async def login(username: str, password: str) -> dict[str, str]: @app.get("/api/profile") -async def get_profile(session: Session = Depends(get_session)) -> dict[str, str | None]: +async def get_profile( + session: Session = Depends(require_user_session), +) -> dict[str, str | None]: """Protected endpoint; tenant_id is validated automatically.""" # tenant_id 與 api_version 已在解碼 JWT 時驗證過。 - # 其他租戶的權杖會解碼失敗,因此中介軟體不會設定 - # Session,get_session 會回應 401。 + # 其他租戶的權杖會解碼失敗,因此中介軟體不會載入 + # Session,require_user_session 會回應 401。 assert session.user is not None return { "user_id": session.user.user_id, @@ -530,47 +485,52 @@ async def get_profile(session: Session = Depends(get_session)) -> dict[str, str ```python # ❌ 錯誤:沒有驗證 -payload = self.jwt_encoder.decode(token_str, **kwargs) +payload = jwt.decode(token_str, self.secret, algorithms=[self.algorithm]) tenant_id = payload.get("tenant_id") # 可能不存在或無效 # ✅ 正確:嚴格驗證 -options = {"require": ["sid", "iat", "exp", "tenant_id"]} -payload = self.jwt_encoder.decode(token_str, **kwargs) -if payload["tenant_id"] != self.expected_tenant_id: +payload = jwt.decode( + token_str, + self.secret, + algorithms=[self.algorithm], + options={"require": ["sid", "iat", "exp", "tenant_id"]}, +) +if payload["tenant_id"] != self.tenant_id: raise ValueError("Invalid tenant_id") ``` ### 4. 金鑰輪替 {#4-key-rotation} -若要支援金鑰輪替,可以使用 `kid`(Key ID)標頭參數。以下只是概略示意;`payload`、`kwargs` 與 `_get_key_by_id()` 需要你自行補上: +若要支援金鑰輪替,可以使用 `kid`(Key ID)標頭參數。以下是以 `CustomClaimsJWTSerializer` 為基礎的概略示意;`payload` 與 `kwargs` 的建立方式與它的 `to_string()`、`from_string()` 相同: ```python -class KeyRotationJWTSerializer(JWTTokenSerializer): +class KeyRotationJWTSerializer(CustomClaimsJWTSerializer): def __init__( - self, config: SessionConfig, key_id: str, jwt_module: Any | None = None + self, config: SessionConfig, keys: dict[str, str], current_key_id: str ) -> None: - super().__init__(config, jwt_module) - self.key_id = key_id + super().__init__(config) + # 金鑰 ID -> 密鑰。舊金鑰請保留到它簽署的權杖都過期為止。 + self.keys = keys + self.current_key_id = current_key_id def to_string(self, token: SessionToken) -> str: - # 在 JWT 標頭中加入 kid - encoded = self.jwt_encoder.encode( + # 以目前的金鑰簽署,並在標頭中註明它 + return jwt.encode( payload, - self._secret, - algorithm=self._algorithm, - headers={"kid": self.key_id}, + self.keys[self.current_key_id], + algorithm=self.algorithm, + headers={"kid": self.current_key_id}, ) - return str(encoded) def from_string(self, token_str: str) -> SessionToken: - # 解析標頭以取得 kid - header = self.jwt_encoder.get_unverified_header(token_str) - kid = header.get("kid") - - # 依 kid 選擇對應的金鑰 - key = self._get_key_by_id(kid) + # 從(尚未驗證的)標頭讀取 kid,並挑選對應的金鑰 + kid = jwt.get_unverified_header(token_str).get("kid") + key = self.keys.get(kid) + if key is None: + msg = "Unknown key ID" + raise ValueError(msg) - payload = self.jwt_encoder.decode(token_str, key=key, **kwargs) + payload = jwt.decode(token_str, key, algorithms=[self.algorithm], **kwargs) # ... ``` @@ -579,6 +539,7 @@ class KeyRotationJWTSerializer(JWTTokenSerializer): 為你的自訂序列化器加上測試: ```python +import jwt import pytest from fastapi_cachex.backends.memory import MemoryBackend @@ -603,12 +564,8 @@ async def test_custom_claims_included(): session, token = await manager.create_session(user=user) # 權杖可以解碼,且帶有自訂 claim - assert ( - serializer.jwt_encoder.decode(token, options={"verify_signature": False})[ - "tenant_id" - ] - == "test-tenant" - ) + claims = jwt.decode(token, options={"verify_signature": False}) + assert claims["tenant_id"] == "test-tenant" # get_session 回傳 (session, renewed_token) retrieved, _renewed = await manager.get_session(token) @@ -653,7 +610,7 @@ A:大多數情況下不需要。`nbf` 用於預先發行、但稍後才生效 ### Q:可以不寫程式碼就加入 claim 嗎? {#q-can-i-add-claims-without-writing-code} -A:目前不行;自訂 claim 需要繼承 `JWTTokenSerializer`。未來版本或許會加入像下面這樣的設定選項(這只是假設,目前並不存在,而且 `SessionConfig` 會拒絕未知的欄位): +A:目前不行;自訂 claim 需要自訂的 `token_serializer`,例如[擴充指南](#extension-guide-adding-custom-claims)中的類別。未來版本或許會加入像下面這樣的設定選項(這只是假設,目前並不存在,而且 `SessionConfig` 會拒絕未知的欄位): ```python SessionConfig( diff --git a/i18n/zh-TW/docs/LOCK.md b/i18n/zh-TW/docs/LOCK.md new file mode 100644 index 0000000..54e6f08 --- /dev/null +++ b/i18n/zh-TW/docs/LOCK.md @@ -0,0 +1,40 @@ +# 分散式鎖 {#distributed-lock} + +`CacheLock` 是建立在後端原子操作(`set_if_absent`、`delete_if_equals`、`expire_if_equals`)之上的分散式鎖。只要持有者在鎖的 `ttl` 內保有它,共用同一個快取後端的其他行程或容器就無法取得這把鎖。這把鎖是一份租約,而不是 fencing lock:持有者若超過 `ttl` 仍未續約,就會失去它,其他呼叫者可能在原持有者仍在工作時取得這把鎖(見下方的 **TTL 過期**)。 + +```python +from fastapi import HTTPException +from fastapi_cachex import CacheLock, LockTimeoutError + +# 當作非同步 context manager 使用: +async with CacheLock(f"report:{report_id}", ttl=30): + ... # 同一時間只有一個持有者,前提是工作在 ttl 內完成 + +# 明確呼叫 acquire 與 release: +lock = CacheLock(f"stream:{user_id}", ttl=60) +if not await lock.acquire(blocking=False): + raise HTTPException(409, detail="Lock already held") +try: + ... + await lock.extend(60) # 長時間執行的工作在過期前續約 +finally: + await lock.release() +``` + +## 行為 {#behavior} + +- **安全性與權杖所有權**:每個 `CacheLock` 實例都會產生一個唯一的權杖(`secrets.token_hex(16)`),存放在 `CacheEntry` 中。釋放(`release()`)與續約(`extend()`)都使用會檢查持有者的後端原子操作(`delete_if_equals` 與 `expire_if_equals`),因此鎖已過期的持有者無法釋放或續約已被他人取得的鎖。 +- **阻塞與非阻塞模式**: + - 非阻塞(`acquire(blocking=False)`):只執行一次原子性的 `set_if_absent`,取得時立即回傳 `True`,已被占用時回傳 `False`。 + - 阻塞(`acquire(blocking=True, timeout=None, poll_interval=0.1)`):每隔 `poll_interval` 秒重試一次,直到取得鎖或經過 `timeout` 秒為止。預設的 `timeout=None` 會讓阻塞的 `acquire()`(以及 `async with CacheLock(...)`)一直等到鎖被釋放。若到達有限的 `timeout`,`acquire()` 會回傳 `False`。 +- **Context manager 逾時**:進入 context manager(`async with CacheLock(...)`)時會呼叫 `acquire()`。若取得失敗或逾時,會拋出 `LockTimeoutError`。 +- **TTL 過期**:若工作花費的時間超過 `ttl` 且沒有續約,鎖的項目會在後端過期並被釋出。此時其他行程或容器就能在原本的程式碼仍在執行時取得這把鎖。原持有者之後呼叫 `extend()` 或 `release()` 會安全地回傳 `False`,而不會拋出錯誤。請選擇比預期工作時間更長的 `ttl`,或在長時間執行的操作中定期呼叫 `extend()`。 +- **TTL 續約(`extend`)**:`extend(ttl)` 只在鎖仍由這個持有者實例擁有時才更新鍵的 TTL,避免在已過期的鎖上發生競爭條件。 +- **每次取得使用一個實例**:單一 `CacheLock` 實例會追蹤自己目前的持有狀態。重複進入同一個 `CacheLock` 實例,或在並行的 task 之間共用它,都會拋出 `RuntimeError`。每次取得鎖時請建立新的 `CacheLock` 實例。 +- **命名空間**:鎖的鍵預設位於獨立的 `lock:` 前綴下(例如 `lock:report:123`),與 `cache:` 及 `oauth_state:` 分開。 +- **後端**:未傳入 `backend=` 時,鎖會使用以 `BackendProxy.set()` 註冊的後端。與 `@cache` 不同,它不會改用 `MemoryBackend`:尚未註冊任何後端時,`acquire()` 會拋出 `BackendNotFoundError`。鎖只能排除共用同一個後端的行程,因此每個行程各自一份的 `MemoryBackend` 只能協調同一個行程內的 task。 + +> [!NOTE] +> `CacheLock` 可用於所有內建後端(`MemoryBackend`、`AsyncRedisCacheBackend`、`MemcachedBackend`)。Redis 使用 `SET NX EX` 與 Lua 腳本,Memcached 使用 `ADD` 與 `CAS`,記憶體後端則在其內部鎖之內操作。 + +完整的類別簽章與選項請見 [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/lock/)(英文)。 diff --git a/i18n/zh-TW/docs/index.md b/i18n/zh-TW/docs/index.md index 351a3d0..f447781 100644 --- a/i18n/zh-TW/docs/index.md +++ b/i18n/zh-TW/docs/index.md @@ -1,4 +1,4 @@ -# FastAPI-Cache X +# FastAPI-Cache X {#fastapi-cache-x} [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) @@ -18,7 +18,7 @@ FastAPI-CacheX 是 FastAPI 的高效能快取擴充套件:提供支援 `Cache- **文件:** (尚未翻譯的頁面與完整 API 參考請見[英文文件](https://fastapi-cachex.readthedocs.io/en/latest/)) -## 功能特點 +## 功能特點 {#features} - **HTTP 快取**:GET 路由專用的 `@cache` 裝飾器,支援 `Cache-Control`、`ETag` / `If-None-Match`(304)與單一路由的快取失效。 - **應用層快取**:`CacheManager` 可在自己的程式碼中快取任意 JSON 值,提供未命中時才計算的 `get_or_set()` 與原子性的「不存在才寫入」`add()`。 @@ -26,7 +26,7 @@ FastAPI-CacheX 是 FastAPI 的高效能快取擴充套件:提供支援 `Cache- - **Session(可選)**:以 HMAC 簽章或 JWT 發行的 Session 權杖,可經由標頭、Bearer 權杖或 Cookie 傳遞,支援滑動過期與 IP / User-Agent 綁定。 - **OAuth state**:OAuth / OIDC 流程中用於防範 CSRF 的一次性 state 權杖。 -## 安裝 +## 安裝 {#installation} ```bash uv add fastapi-cachex @@ -42,7 +42,7 @@ uv add fastapi-cachex Extra 可以組合:`uv add "fastapi-cachex[redis,jwt]"`。 -## 快速開始 +## 快速開始 {#quick-start} ```python from fastapi import FastAPI @@ -73,7 +73,7 @@ async def report(cache: AppCache): > [!WARNING] > 預設的快取鍵不包含使用者身分。需要驗證身分的端點請使用 `private=True` 或依使用者區分的 key builder,詳見 [需驗證身分的端點](HTTP_CACHING.md#authenticated-endpoints)。 -## 文件 +## 文件 {#documentation} - [HTTP 快取](HTTP_CACHING.md):`@cache` 裝飾器、Cache-Control 指令、快取鍵、快取失效與監控路由 - [快取流程](CACHE_FLOW.md):快取請求內部的處理流程 @@ -81,10 +81,11 @@ async def report(cache: AppCache): - [後端](BACKENDS.md):選擇與設定後端、原子操作的基本功能 - [Session 管理](SESSION.md)與 [JWT claims](JWT_CLAIMS.md) - [OAuth state](STATE.md):一次性的 OAuth / CSRF state 權杖 +- [分散式鎖](LOCK.md):以 `CacheLock` 在多個行程之間互斥 - [API 參考](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)(英文) -- [開發指南](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/)與[貢獻指南](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)(英文) +- [開發指南](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/)(英文)與[貢獻指南](CONTRIBUTING.md) - [變更紀錄](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md)(英文) · [已知限制與規劃中的工作](https://github.com/allen0099/FastAPI-CacheX/issues) -## 授權 +## 授權 {#license} 本專案採用 Apache License 2.0 授權,詳見 [LICENSE](https://github.com/allen0099/FastAPI-CacheX/blob/master/LICENSE)。 diff --git a/zensical.zh-TW.toml b/zensical.zh-TW.toml index 897a0ec..b56e4a8 100644 --- a/zensical.zh-TW.toml +++ b/zensical.zh-TW.toml @@ -30,11 +30,12 @@ nav = [ { "Session 管理" = "SESSION.md" }, { "JWT claims" = "JWT_CLAIMS.md" }, { "OAuth state" = "STATE.md" }, + { "分散式鎖" = "LOCK.md" }, ] }, { "API 參考(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/" }, - { "開發(英文)" = [ - { "開發指南" = "https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/" }, - { "貢獻指南" = "https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/" }, + { "開發" = [ + { "開發指南(英文)" = "https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/" }, + { "貢獻指南" = "CONTRIBUTING.md" }, ] }, { "變更紀錄(英文)" = "https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md" }, ]