Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
4 changes: 3 additions & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
17 changes: 14 additions & 3 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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 ...
Expand Down
5 changes: 3 additions & 2 deletions fastapi_cachex/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 14 additions & 10 deletions fastapi_cachex/backends/memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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)
Expand Down
41 changes: 31 additions & 10 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<stale_ttl>``,
``"error"`` sends ``stale-if-error=<stale_ttl>``.
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:
Expand Down
2 changes: 2 additions & 0 deletions fastapi_cachex/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
31 changes: 19 additions & 12 deletions fastapi_cachex/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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()))

Expand Down
2 changes: 1 addition & 1 deletion fastapi_cachex/session/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions fastapi_cachex/session/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -103,15 +106,19 @@ 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
)
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

Expand Down
6 changes: 4 additions & 2 deletions fastapi_cachex/session/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions fastapi_cachex/state/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading