diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b0353..2017651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,22 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2. ### Fixed +- Docstrings and guides that disagreed with the code are corrected. Most + visible: + - `SessionConfig.sliding_threshold` now describes renewal once less than + that fraction of the TTL remains; it used to say the opposite. + - The `cache()` arguments `no_cache`, `stale_ttl`, `private` and `ttl` are + described by what they do, and the docstring gains a `Raises:` section. + - The per-user `key_builder` example in the HTTP caching guide no longer + sets `private=True`, which bypassed the backend and made the key builder + unused. + - The state quick start catches `StateError`, so a malformed state is a 400 + instead of a 500. + - The `get_session_manager` 500 message and the session dependency + docstrings name `FastAPICacheXSessionMiddleware` instead of the + deprecated `SessionMiddleware`. + - The monitoring routes no longer claim to count cache hits. + - The Redis backend now matches its key prefix and the path given to `clear_path()` literally when it builds `SCAN` patterns. Glob characters in them used to be live: `clear_path("/files/[draft]")` missed the cached entry diff --git a/CLAUDE.md b/CLAUDE.md index f135bc7..61713b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,6 @@ Four non-abstract atomic primitives live on the base class with non-atomic fallb - Ruff is configured with `extend-select = ['ALL']` with specific ignores (see `pyproject.toml`). Notable: E501 (line length), B008 (function calls in defaults), FBT001/FBT002 (boolean args — intentional for Cache-Control API). - mypy runs in strict mode on the package (not tests). - pydocstring convention is Google style. -- `from __future__ import annotations` is used for forward references. +- Forward references are mostly quoted annotations with `TYPE_CHECKING` imports; only a couple of modules use `from __future__ import annotations`. - All public functions must have complete type annotations. - Coverage threshold is 90% (enforced by `pytest-cov`). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 42e35a6..8af5e4b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -179,7 +179,9 @@ uv run mypy fastapi_cachex --strict - Make sure all functions have type annotations - Use `Type | None` for parameters that could be None (the codebase uses PEP 604 unions, not `Optional`) -- Use `from __future__ import annotations` for forward references +- Write forward references as quoted annotations (`"SessionManager"`), importing the name under + `if TYPE_CHECKING:` when it is only needed for typing. Most modules do this; only a couple use + `from __future__ import annotations` - Keep `fastapi_cachex/py.typed` in place; it is what makes the installed package typed for users (it is listed under `[tool.uv.build-backend] include` in `pyproject.toml`) diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 3d88131..6590c65 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -120,9 +120,12 @@ HTTP requests. > cache it, and `If-None-Match` revalidation still works against freshly > rendered content. > 2. **A key builder that includes the caller's identity** — use this when you -> do want a server-side cache per user. +> do want a server-side cache per user. Leave `private` unset: `private=True` +> bypasses the backend, so the key builder would never be used. ```python +from fastapi import Request, Response + from fastapi_cachex import cache from fastapi_cachex.types import CACHE_KEY_SEPARATOR @@ -149,11 +152,19 @@ def per_user_key(request: Request) -> str: @app.get("/me/dashboard") -@cache(ttl=60, private=True, key_builder=per_user_key) -async def my_dashboard(user: CurrentUser): +@cache(ttl=60, key_builder=per_user_key) +async def my_dashboard(user: CurrentUser, response: Response): + # Without `private`, the response goes out as `Cache-Control: max-age=60`, + # which a shared cache (CDN, reverse proxy) may store. Vary on whatever + # carries the identity so such a cache keeps one copy per user. + response.headers["Vary"] = "Authorization" return build_dashboard(user) ``` +A per-user entry is only safe from shared caches in front of your app if they +honour `Vary` for that header. When they don't, or when identity comes from +something a shared cache cannot see, use option 1 instead. + > [!CAUTION] > The key builder decides who sees whose data, so the identity it reads must > come from something already verified — a claim from a checked token, a user diff --git a/docs/STATE.md b/docs/STATE.md index 0a4a272..a65edbd 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -20,7 +20,7 @@ from fastapi.responses import RedirectResponse from fastapi_cachex import BackendProxy from fastapi_cachex.backends import MemoryBackend -from fastapi_cachex.state import InvalidStateError, StateExpiredError, StateManagerDep +from fastapi_cachex.state import StateError, StateManagerDep app = FastAPI() BackendProxy.set(MemoryBackend()) @@ -38,7 +38,7 @@ async def login(states: StateManagerDep): async def callback(state: str, code: str, states: StateManagerDep): try: data = await states.consume_state(state) # one-time: deleted on retrieval - except (InvalidStateError, StateExpiredError) as e: + except StateError as e: # unknown, expired or malformed state raise HTTPException(status_code=400, detail="Invalid state") from e # Exchange the code for tokens, create a session ... diff --git a/fastapi_cachex/backends/base.py b/fastapi_cachex/backends/base.py index 3565e9a..d9e7d27 100644 --- a/fastapi_cachex/backends/base.py +++ b/fastapi_cachex/backends/base.py @@ -77,8 +77,9 @@ async def delete_many(self, keys: Iterable[str]) -> int: The base implementation deletes one key at a time and reports how many were attempted, since ``delete`` does not say whether the key - existed. The built-in backends override it with a single batched - operation that counts what was actually removed. + existed. The memory and Redis backends override it with a single + batched operation that counts what was actually removed; Memcached + keeps this per-key loop. """ count = 0 for key in keys: diff --git a/fastapi_cachex/backends/memcached.py b/fastapi_cachex/backends/memcached.py index 1cf4d76..f5c0fef 100644 --- a/fastapi_cachex/backends/memcached.py +++ b/fastapi_cachex/backends/memcached.py @@ -264,13 +264,16 @@ async def delete(self, key: str) -> None: async def clear(self) -> None: """Clear all values from cache. - Note: Memcached's flush_all affects the entire server. - Consider using clear_path() with your specific keys instead. + Note: Memcached's flush_all affects the entire server, including + other applications' keys. Memcached cannot enumerate keys, so there + is no way to clear only this namespace; delete keys you know by name + with ``delete()``/``delete_many()`` instead. """ warnings.warn( "Memcached.clear() flushes ALL cached data from the server, " - "affecting other applications. Consider using clear_path() instead " - "to selectively remove only this namespace's keys.", + "affecting other applications. Memcached cannot enumerate keys, so " + "this namespace cannot be cleared on its own; delete known keys " + "with delete() or delete_many() instead.", RuntimeWarning, stacklevel=2, ) @@ -280,14 +283,15 @@ async def clear(self) -> None: async def clear_path(self, path: str, include_params: bool = False) -> int: """Clear cached responses for a specific path. - Note: Memcached does not support pattern-based queries. - This method can only delete keys if the exact key is provided, - or will try to match keys in memory if include_params=True. - For better pattern support, consider using Redis backend. + Note: Memcached does not support pattern-based queries, so this + only deletes the key that is exactly ``path``. HTTP route keys + (``method|||host|||path|||query``) are not matched. For path-based + clearing, use the Redis or memory backend. Args: - path: The path to clear cache for - include_params: Currently unsupported (Memcached limitation) + path: The exact key to delete + include_params: Unsupported; emits a ``RuntimeWarning`` and is + otherwise ignored Returns: Number of cache entries cleared (0 or 1 for exact match only) diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index c5e30bb..66a5f49 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -437,24 +437,45 @@ def cache( ) -> Callable[[HandlerCallable], AsyncResponseCallable]: """Cache decorator for FastAPI route handlers. + Only GET requests go through the cache; other methods run the handler + unchanged. + Args: - ttl: Time-to-live in seconds for cache entries, sent as ``max-age``. + ttl: How long, in seconds, a stored response may be served without + running the handler. The same value is sent as ``max-age``. ``ttl=0`` sends ``max-age=0`` and, like ``None``, keeps the entry only for ETag revalidation: the body is never served from the cache, but a matching ``If-None-Match`` still gets a 304. Negative values are rejected. - stale_ttl: Additional time-to-live for stale cache entries - stale: Stale response handling strategy ('error' or 'revalidate') - no_cache: Whether to disable caching - no_store: Whether to prevent storing responses - public: Whether responses can be cached by shared caches - private: Whether responses are for single user only - immutable: Whether cached responses never change - must_revalidate: Whether to force revalidation when stale - key_builder: Custom function to build cache keys. If None, uses default_key_builder + stale_ttl: Seconds sent with the directive chosen by ``stale``. It only + shapes the ``Cache-Control`` header; the backend entry still + expires after ``ttl``. Must be given together with ``stale``. + stale: ``"revalidate"`` sends ``stale-while-revalidate=``, + ``"error"`` sends ``stale-if-error=``. + no_cache: Run the handler on every request and send ``no-cache``. The + response is still stored and ``If-None-Match`` still gets a 304 + when it matches the fresh ETag. The header then carries only + ``no-cache`` (plus ``must-revalidate`` when set); ``ttl``, + ``stale``, ``public``/``private`` and ``immutable`` are left out. + no_store: Run the handler, store nothing, and send ``no-store``. Takes + precedence over every other option. + public: Send ``public``. Mutually exclusive with ``private``. + private: Send ``private`` and bypass the shared backend entirely: the + handler runs on every request and nothing is read or stored. ETag + revalidation still works against the freshly rendered response. + Mutually exclusive with ``public``. + immutable: Send ``immutable``. + must_revalidate: Send ``must-revalidate``. + key_builder: Custom function to build cache keys. If None, uses + ``default_key_builder``. Returns: Decorator function that wraps route handlers with caching logic + + Raises: + CacheXError: When the decorator is applied, if ``stale`` and + ``stale_ttl`` are not given together, if ``public`` and + ``private`` are both set, or if ``ttl`` is negative. """ def decorator(func: HandlerCallable) -> AsyncResponseCallable: diff --git a/fastapi_cachex/manager.py b/fastapi_cachex/manager.py index ac9398a..7b935fc 100644 --- a/fastapi_cachex/manager.py +++ b/fastapi_cachex/manager.py @@ -42,6 +42,8 @@ def __init__( without an explicit ttl. None means no expiry by default. Raises: + BackendNotFoundError: If ``backend`` is None and no backend has + been set with ``BackendProxy.set()``. ValueError: If ``default_ttl`` is zero or negative. """ self.backend = backend if backend is not None else BackendProxy.get() diff --git a/fastapi_cachex/routes.py b/fastapi_cachex/routes.py index ce15686..9a93c65 100644 --- a/fastapi_cachex/routes.py +++ b/fastapi_cachex/routes.py @@ -22,7 +22,7 @@ @dataclass class CacheHitRecord: - """Record for a single cache hit.""" + """One cached route entry. Despite the name, hits are not counted.""" cache_key: str method: str @@ -36,7 +36,7 @@ class CacheHitRecord: @dataclass class CacheHitSummary: - """Summary of cache hit statistics.""" + """Summary of the cached route entries.""" total_cached_entries: int active_entries: int @@ -45,7 +45,10 @@ class CacheHitSummary: @dataclass class CacheHitsResponse: - """Response for cached hits endpoint.""" + """Response for the ``/cached-hits`` endpoint. + + The ``*_hits`` fields count cached entries, not requests served from them. + """ cached_hits: list[CacheHitRecord] total_hits: int @@ -241,9 +244,10 @@ def add_routes( ) -> None: """Add cache monitoring routes to the FastAPI application. - This function allows users to optionally add cache monitoring routes - to their FastAPI application. Users can call this function to enable - cache hit tracking and cache record display. + Mounts two read-only routes that report what the configured backend + currently holds. They inspect stored entries; nothing counts cache hits. + The routes have no authentication of their own, so pass ``dependencies`` + in production. Args: app: FastAPI application instance @@ -261,14 +265,16 @@ def add_routes( expiry are still reported. Defaults to True. Example: + ```python from fastapi import FastAPI from fastapi_cachex import add_routes app = FastAPI() add_routes(app) # Routes at /cached-hits and /cached-records - # Or with prefix - add_routes(app, prefix="/api/cache") # Routes at /api/cache/cached-hits and /api/cache/cached-records + # Or with a prefix: /api/cache/cached-hits and /api/cache/cached-records + add_routes(app, prefix="/api/cache") + ``` """ @app.get( @@ -277,13 +283,14 @@ def add_routes( dependencies=dependencies, ) async def get_cached_hits() -> CacheHitsResponse: - """Return cached hit records. + """List the cached route entries. - Shows cache statistics including which routes are frequently being cached, - hit counts, and cache key information. + Splits every cached key into method, host, path and query, with its + ETag and expiry, plus counts of valid and expired entries and the + distinct cached paths. Cache hits are not counted. Returns: - CacheHitsResponse containing cache hit records and statistics + CacheHitsResponse describing the cached entries """ return _cached_hits(_parse_entries(await _cache_data())) diff --git a/fastapi_cachex/session/config.py b/fastapi_cachex/session/config.py index ef900a0..c8f4683 100644 --- a/fastapi_cachex/session/config.py +++ b/fastapi_cachex/session/config.py @@ -87,7 +87,7 @@ class SessionConfig(BaseModel): default=0.5, ge=0.0, le=1.0, - description="Fraction of TTL that must pass before sliding refresh (0.5 = refresh after half TTL)", + description="Renew once less than this fraction of session_ttl remains (0.5 = renew in the second half of the TTL)", ) # Token settings diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py index 1e47184..066c0db 100644 --- a/fastapi_cachex/session/dependencies.py +++ b/fastapi_cachex/session/dependencies.py @@ -32,7 +32,8 @@ def get_optional_session( """Get session from request state (optional). This dependency automatically displays the authorization input box in OpenAPI/Swagger UI. - The actual authentication is handled by SessionMiddleware; the credentials parameter + The actual authentication is handled by the session middleware + (``FastAPICacheXSessionMiddleware``); the credentials parameter is only used to generate the OpenAPI security scheme. Args: @@ -52,7 +53,8 @@ def get_session( """Get session from request state (required). This dependency automatically displays the authorization input box in OpenAPI/Swagger UI. - The actual authentication is handled by SessionMiddleware; the credentials parameter + The actual authentication is handled by the session middleware + (``FastAPICacheXSessionMiddleware``); the credentials parameter is only used to generate the OpenAPI security scheme. Args: @@ -79,7 +81,8 @@ def get_session_manager(request: Request) -> "SessionManager": """Get SessionManager instance from app state. This dependency allows you to access the SessionManager instance - that was registered via SessionMiddleware. Use this when you need + that the session middleware (``FastAPICacheXSessionMiddleware``) + registered on ``app.state`` when it handled its first request. Use this when you need to perform session operations like create, delete, or regenerate. Example: @@ -103,7 +106,8 @@ async def login( SessionManager instance Raises: - HTTPException: 500 if SessionManager not found in app state + HTTPException: 500 if no session middleware has registered a + SessionManager yet """ manager: SessionManager | None = getattr( request.app.state, "__fastapi_cachex_session_manager", None @@ -111,7 +115,10 @@ async def login( if manager is None: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="SessionManager not initialized. Ensure SessionMiddleware is added to the app.", + detail=( + "SessionManager not initialized. Ensure " + "FastAPICacheXSessionMiddleware is added to the app." + ), ) return manager diff --git a/fastapi_cachex/session/manager.py b/fastapi_cachex/session/manager.py index 63aebe8..83a6c25 100644 --- a/fastapi_cachex/session/manager.py +++ b/fastapi_cachex/session/manager.py @@ -402,8 +402,10 @@ async def clear_expired_sessions(self) -> int: async def _iter_sessions(self) -> AsyncIterator[tuple[str, Session]]: """Yield every readable session under this manager's prefix with its key. - Backends without key enumeration (which raise NotImplementedError) - simply yield nothing. + Backends that cannot enumerate keys yield nothing: the built-in + Memcached backend returns ``[]`` from ``get_all_keys()`` with a + ``RuntimeWarning``, and a custom backend may raise + ``NotImplementedError`` instead. """ try: all_keys = await self.backend.get_all_keys() diff --git a/fastapi_cachex/state/manager.py b/fastapi_cachex/state/manager.py index fa77f8e..278dd6c 100644 --- a/fastapi_cachex/state/manager.py +++ b/fastapi_cachex/state/manager.py @@ -63,6 +63,8 @@ def __init__( default_ttl: Default time-to-live in seconds for state Raises: + BackendNotFoundError: If ``backend`` is None and no backend has + been set with ``BackendProxy.set()``. ValueError: If ``default_ttl`` is zero or negative. """ validate_ttl(default_ttl)