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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ The library has four independent subsystems:
- Fails open by default (`fail_open=True`): a backend error on `get` is logged and treated as a miss, one on `set` is logged and the response served unstored. `fail_open=False` propagates the error.
- Only GET requests are cached; other methods bypass the cache entirely.
- Cache keys follow the format `method|||host|||path|||query_params` (separator defined in `types.py`). Host and path go through `escape_key_component` (`|` → `%7C`, `%` → `%25`) so client input cannot inject the separator; `clear_path` encodes its argument and `routes.py` decodes for display.
- `BackendProxy` is a non-instantiable class-level singleton (via `ProxyMeta`). Call `BackendProxy.set(backend)` at app startup; `BackendProxy.get()` raises `BackendNotFoundError` if unset. Falls back to `MemoryBackend` automatically inside `@cache` if no backend is set.
- `BackendProxy` is a non-instantiable class-level singleton (via `ProxyMeta`). Call `BackendProxy.set(backend)` at app startup; `BackendProxy.get()` raises `BackendNotFoundError` if unset. `get_backend_or_fallback()` registers a `MemoryBackend` when none is set; `@cache`, `CacheBackend` and `AppCache` use it.
- `ProxyBase.get_or_create(factory)` is the one lazy get-or-create: a per-class `threading.Lock` (sync dependencies run in worker threads; per class so a factory can call another proxy's `get_or_create`). Used by `get_backend_or_fallback`, `get_app_cache` and `get_state_manager` (no memory fallback for states).
- Cache values are stored as `CacheEntry(fingerprint, content, media_type)` dataclass (defined in `types.py`).

**2. Application-Level Caching (`fastapi_cachex/manager.py`, `manager_proxy.py`)**
Expand Down
6 changes: 5 additions & 1 deletion docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,11 @@ something a shared cache cannot see, use option 1 instead.
### By path or pattern

The clearing methods live on the backend, which you can inject with the
`CacheBackend` dependency or fetch with `BackendProxy.get()`:
`CacheBackend` dependency or fetch with `BackendProxy.get()`. With no backend
configured, `CacheBackend` registers the same `MemoryBackend` fallback that
`@cache` would, so it works before any cached route has run (before 0.3.8 it
answered `500` until then); `BackendProxy.get()` still raises
`BackendNotFoundError`.

```python
from fastapi_cachex import CacheBackend
Expand Down
34 changes: 13 additions & 21 deletions fastapi_cachex/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
"""FastAPI dependency injection utilities for cache control."""

import threading
from typing import Annotated

from fastapi import Depends

from .backends.base import BaseCacheBackend
from .exceptions import ProxyNotSetError
from .manager import CacheManager
from .manager_proxy import CacheManagerProxy
from .proxy import BackendProxy
from .proxy import get_backend_or_fallback

_manager_lock = threading.Lock()


def get_cache_backend() -> BaseCacheBackend:
"""Dependency to get the current cache backend instance."""
return BackendProxy.get()
"""Dependency to get the current cache backend instance.

With no backend configured this falls back to a `MemoryBackend` and
registers it, the same way `@cache` and `AppCache` do. It used to raise
`BackendNotFoundError` (a 500) until some `@cache` route had run and
installed the fallback first.
"""
return get_backend_or_fallback()


CacheBackend = Annotated[BaseCacheBackend, Depends(get_cache_backend)]
Expand All @@ -36,20 +37,11 @@ def get_app_cache() -> CacheManager:
dependency worked depended on whether some `@cache` route had already run
and installed the fallback first.
"""
try:
return CacheManagerProxy.get()
except ProxyNotSetError:
pass
# Checked again under the lock: FastAPI runs this sync dependency in a
# worker thread, so concurrent first requests would otherwise each build
# and register their own manager (and fallback backend).
with _manager_lock:
try:
return CacheManagerProxy.get()
except ProxyNotSetError:
manager = CacheManager(backend=get_backend_or_fallback())
CacheManagerProxy.set(manager)
return manager
return CacheManagerProxy.get_or_create(_default_manager)


def _default_manager() -> CacheManager:
return CacheManager(backend=get_backend_or_fallback())


AppCache = Annotated[CacheManager, Depends(get_app_cache)]
67 changes: 48 additions & 19 deletions fastapi_cachex/proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import threading
import warnings
from logging import getLogger
from typing import TYPE_CHECKING
from typing import ClassVar
from typing import Generic
from typing import NoReturn
Expand All @@ -15,14 +16,13 @@
from .exceptions import BackendNotFoundError
from .exceptions import ProxyNotSetError

if TYPE_CHECKING:
from collections.abc import Callable

ProxyInstance = TypeVar("ProxyInstance")

logger = getLogger(__name__)

# Serialises the lazy fallback below. `get_app_cache` is a sync dependency that
# FastAPI runs in a worker thread, so two first requests can reach it at once.
_fallback_lock = threading.Lock()


class ProxyMeta(type):
"""Metaclass for BackendProxy to prevent instantiation."""
Expand All @@ -39,6 +39,16 @@ class ProxyBase(Generic[ProxyInstance], metaclass=ProxyMeta):
_instance: ProxyInstance | None = None
# Raised by `get()` while no instance is set.
_not_set_error: ClassVar[type[BackendNotFoundError]] = ProxyNotSetError
# Serialises `get_or_create`. A threading lock, because the sync FastAPI
# dependencies built on it run in worker threads. One per class, so a
# factory may call another proxy's `get_or_create` (the default
# `CacheManager` needs a backend) without deadlocking.
_create_lock: ClassVar[threading.Lock] = threading.Lock()

def __init_subclass__(cls, **kwargs: object) -> None:
"""Give every proxy class its own creation lock."""
super().__init_subclass__(**kwargs)
cls._create_lock = threading.Lock()

@classmethod
def get(cls) -> ProxyInstance:
Expand All @@ -56,6 +66,31 @@ def get(cls) -> ProxyInstance:
raise cls._not_set_error(msg)
return cls._instance

@classmethod
def get_or_create(cls, factory: Callable[[], ProxyInstance]) -> ProxyInstance:
"""Return the current instance, creating and registering one if unset.

The check and the registration happen under the class's lock, so
concurrent first callers, including ones on worker threads, all get
the same instance: ``factory`` runs at most once. If it raises, nothing
is registered and the error propagates.

Args:
factory: Builds the instance when none is set

Returns:
The registered instance
"""
instance = cls._instance
if instance is not None:
return instance
with cls._create_lock:
instance = cls._instance
if instance is None:
instance = factory()
cls.set(instance)
return instance

@classmethod
def set(cls, instance: ProxyInstance | None) -> None:
"""Set the instance for the proxy.
Expand Down Expand Up @@ -115,20 +150,14 @@ def set_backend(backend: BaseCacheBackend | None) -> None:
def get_backend_or_fallback() -> BaseCacheBackend:
"""Return the configured backend, registering a `MemoryBackend` if none is.

Used by `@cache` and the `AppCache` dependency. The check and the
registration happen under one lock, so concurrent first callers — including
ones on worker threads — all end up with the same fallback instead of each
Used by `@cache`, `CacheBackend` and `AppCache`. Built on
`BackendProxy.get_or_create`, so concurrent first callers, including ones
on worker threads, all end up with the same fallback instead of each
installing its own and overwriting the others.
"""
try:
return BackendProxy.get()
except BackendNotFoundError:
pass
with _fallback_lock:
try:
return BackendProxy.get()
except BackendNotFoundError:
backend = MemoryBackend()
BackendProxy.set(backend)
logger.debug("No backend configured; using MemoryBackend fallback")
return backend
return BackendProxy.get_or_create(_memory_fallback)


def _memory_fallback() -> BaseCacheBackend:
logger.debug("No backend configured; using MemoryBackend fallback")
return MemoryBackend()
17 changes: 8 additions & 9 deletions fastapi_cachex/state/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

from fastapi import Depends

from fastapi_cachex.exceptions import ProxyNotSetError

from .manager import StateManager
from .proxy import StateManagerProxy

Expand All @@ -15,14 +13,15 @@ def get_state_manager() -> StateManager:

Lazily creates and registers a default StateManager (backed by
BackendProxy) the first time it's requested, unless one was already
set via StateManagerProxy.set(...).
set via StateManagerProxy.set(...). Concurrent first calls share one
instance.

Unlike `AppCache`, it does not fall back to a `MemoryBackend`: OAuth
states must be readable by whichever worker handles the callback, so with
no backend configured it raises `BackendNotFoundError` and registers
nothing.
"""
try:
return StateManagerProxy.get()
except ProxyNotSetError:
manager = StateManager()
StateManagerProxy.set(manager)
return manager
return StateManagerProxy.get_or_create(StateManager)


StateManagerDep = Annotated[StateManager, Depends(get_state_manager)]
2 changes: 1 addition & 1 deletion i18n/zh-TW/docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ async def my_dashboard(user: CurrentUser, response: Response):

### 依路徑或模式 {#by-path-or-pattern}

清除用的方法位於後端上,可以透過 `CacheBackend` 依賴項注入,或以 `BackendProxy.get()` 取得:
清除用的方法位於後端上,可以透過 `CacheBackend` 依賴項注入,或以 `BackendProxy.get()` 取得。尚未設定後端時,`CacheBackend` 會註冊與 `@cache` 相同的 `MemoryBackend` 後備後端,因此在任何快取路由執行之前也能使用(0.3.8 之前在那之前會回應 `500`);`BackendProxy.get()` 則仍會引發 `BackendNotFoundError`。

```python
from fastapi_cachex import CacheBackend
Expand Down
53 changes: 53 additions & 0 deletions tests/state/test_proxy.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"""Tests for StateManagerProxy and get_state_manager dependency."""

import threading
import time
from concurrent.futures import ThreadPoolExecutor

import pytest

from fastapi_cachex.backends.memory import MemoryBackend
from fastapi_cachex.exceptions import BackendNotFoundError
from fastapi_cachex.proxy import BackendProxy
from fastapi_cachex.state import dependencies as state_dependencies
from fastapi_cachex.state.dependencies import get_state_manager
from fastapi_cachex.state.manager import StateManager
from fastapi_cachex.state.proxy import StateManagerProxy
Expand Down Expand Up @@ -57,3 +62,51 @@ def test_get_state_manager_reuses_existing_proxy_instance(
assert get_state_manager() is existing
finally:
StateManagerProxy.set(None)


def test_get_state_manager_concurrent_first_calls_share_one_instance(
memory_backend: MemoryBackend,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""FastAPI runs the sync dependency in worker threads; racers must agree.

Without a lock each first request built and registered its own
`StateManager`, and the later `set()` replaced the earlier one.
"""

class SlowStateManager(StateManager):
def __init__(self) -> None:
time.sleep(0.05) # widen the window between the check and the set
super().__init__()

monkeypatch.setattr(state_dependencies, "StateManager", SlowStateManager)
BackendProxy.set(memory_backend)
StateManagerProxy.set(None)
workers = 8
barrier = threading.Barrier(workers)

def first_call(_: int) -> StateManager:
barrier.wait()
return get_state_manager()

try:
with ThreadPoolExecutor(max_workers=workers) as pool:
managers = list(pool.map(first_call, range(workers)))
assert len({id(manager) for manager in managers}) == 1
assert StateManagerProxy.get() is managers[0]
finally:
StateManagerProxy.set(None)


def test_get_state_manager_without_a_backend_raises_and_registers_nothing() -> None:
"""OAuth states need a shared backend, so there is no memory fallback."""
BackendProxy.set(None)
StateManagerProxy.set(None)

with pytest.raises(BackendNotFoundError):
get_state_manager()

with pytest.raises(BackendNotFoundError):
StateManagerProxy.get()
with pytest.raises(BackendNotFoundError):
BackendProxy.get()
19 changes: 14 additions & 5 deletions tests/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from fastapi_cachex import CacheBackend
from fastapi_cachex.backends import MemoryBackend
from fastapi_cachex.dependencies import get_app_cache
from fastapi_cachex.exceptions import BackendNotFoundError
from fastapi_cachex.manager import CacheManager
from fastapi_cachex.manager_proxy import CacheManagerProxy

Expand All @@ -23,11 +22,21 @@ async def backend_endpoint(backend: CacheBackend):

# Actual test functions
@pytest.mark.asyncio
async def test_get_cache_backend_no_backend():
"""Test that get_cache_backend raises BackendNotFoundError when no backend is set."""
async def test_get_cache_backend_falls_back_to_memory_without_a_backend():
"""`CacheBackend` must work before any `@cache` route has run.

It used to answer 500 (`BackendNotFoundError`) until a `@cache` route had
installed the fallback, the order dependence `AppCache` no longer had.
"""
BackendProxy.set(None)
with pytest.raises(BackendNotFoundError):
client.get("/test-backend")

response = client.get("/test-backend")

assert response.status_code == 200
assert response.json() == {"backend_type": "MemoryBackend"}
# The fallback is registered, so `@cache` and `AppCache` share it.
assert isinstance(BackendProxy.get(), MemoryBackend)
assert get_app_cache().backend is BackendProxy.get()


@pytest.mark.asyncio
Expand Down
Loading
Loading