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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.

### Fixed

- `ttl` means the same thing on every backend. Zero or negative TTLs now raise
`ValueError` from `set`, `set_if_absent` and `increment` on all built-in
backends, from the base-class fallbacks, and from `CacheManager` and
`StateManager` (defaults included). Before, Memcached stored the entry
forever, Redis failed with `invalid expire time`, and the memory backend
expired it at once. `None` remains the way to say "no expiry", and
`validate_ttl()` in `fastapi_cachex.backends.base` lets third-party backends
apply the same rule. `@cache(ttl=0)` stays valid: it sends `max-age=0` and
keeps the entry only for ETag revalidation, like `ttl=None`, instead of
answering 500 on Redis or replaying the first response forever on
Memcached. A negative `@cache` ttl raises `CacheXError` at decoration time.
([#102](https://github.com/allen0099/FastAPI-CacheX/issues/102))

- A `@cache` handler that returns plain data instead of a `Response` is
rendered the way FastAPI renders it. The result goes through the route's
response model (validation, field filtering and the `response_model_*`
Expand Down
12 changes: 12 additions & 0 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,18 @@ All four have a non-atomic fallback on `BaseCacheBackend`, so a third-party back
that only implements the abstract methods keeps working; override them to get
real atomicity.

## TTL values

Every `ttl` argument (`set`, `set_if_absent`, `increment`, and the `CacheManager`
and `StateManager` methods and defaults built on them) is either `None`, meaning
the entry never expires, or a positive number of seconds. Zero and negative
values raise `ValueError`. The underlying stores disagree on what they mean:
Memcached reads an exptime of `0` as "never expire", Redis rejects `EX 0`, and
an in-process dict would expire the entry at once. A third-party backend should
call `fastapi_cachex.backends.base.validate_ttl(ttl)` in its `set` to follow
the same rule. (`@cache(ttl=0)` is separate: it sends `max-age=0` and never
passes `0` to the backend; see [HTTP caching](HTTP_CACHING.md).)

How each backend stores entries is described in
[Cache flow](CACHE_FLOW.md#backend-storage-formats); the classes themselves are in
the [API reference](api/backends.md).
7 changes: 4 additions & 3 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ The header value is built once per decorated route:
| anything else | in order: `public` or `private`, `max-age=<ttl>`, `must-revalidate`, `stale-while-revalidate=<n>` or `stale-if-error=<n>`, `immutable` |

> [!NOTE]
> Without `ttl`, an entry is still written (with no expiry) but is never served
> directly: it is only used to answer a matching `If-None-Match` with `304`. Set
> `ttl` to have the server replay cached responses.
> Without `ttl` (or with `ttl=0`, which sends `max-age=0`), an entry is still
> written (with no expiry) but is never served directly: it is only used to
> answer a matching `If-None-Match` with `304`. Set a positive `ttl` to have the
> server replay cached responses.

> [!WARNING]
> **The default cache key does not include the user's identity**, and the backend
Expand Down
1 change: 1 addition & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ When a cached entry is valid (within TTL):
- **With `no-cache` directive**: Forces revalidation with fresh content before deciding on 304
- **With `private=True`**: Nothing is read from or written to the shared backend; the handler runs every time and only `If-None-Match` revalidation applies
- **Without `ttl`** (`ttl=None`): The cached body is never served directly; the handler runs on every request except one whose `If-None-Match` matches the stored ETag, which gets a 304
- **With `ttl=0`**: Sends `max-age=0` and otherwise behaves like `ttl=None`. A negative `ttl` is rejected with `CacheXError` when the decorator is applied

Only successful responses are stored. A response the handler *returns* with a
non-2xx status (for example `Response(..., status_code=404)`) is passed straight
Expand Down
26 changes: 25 additions & 1 deletion fastapi_cachex/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ def warn_if_path_shaped(pattern: str, cleared: int) -> None:
)


def validate_ttl(ttl: int | None) -> int | None:
"""Return ``ttl`` if it is ``None`` or a positive number of seconds.

Every backend reads ``0`` or a negative TTL differently (Memcached treats
``0`` as "never expires", Redis rejects it, the memory backend expires the
entry at once), so the library refuses them instead of letting the
meaning depend on the backend. ``None`` is the way to say "no expiry".

Raises:
ValueError: If ``ttl`` is zero or negative
"""
if ttl is not None and ttl <= 0:
msg = f"ttl must be a positive number of seconds or None, got {ttl!r}"
raise ValueError(msg)
return ttl


class BaseCacheBackend(ABC):
"""Base class for all cache backends."""

Expand All @@ -44,7 +61,12 @@ async def get(self, key: str) -> CacheEntry | None:

@abstractmethod
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
"""Store a response in the cache."""
"""Store a response in the cache.

``ttl`` is ``None`` (never expires) or a positive number of seconds;
implementations should pass it through ``validate_ttl`` so zero and
negative values are rejected the same way on every backend.
"""

@abstractmethod
async def delete(self, key: str) -> None:
Expand Down Expand Up @@ -106,6 +128,7 @@ async def set_if_absent(
Returns:
Whether ``value`` was stored
"""
validate_ttl(ttl)
if await self.get(key) is not None:
return False
await self.set(key, value, ttl=ttl)
Expand Down Expand Up @@ -161,6 +184,7 @@ async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> i
Raises:
CacheXError: If ``key`` holds a cached response instead of a counter
"""
validate_ttl(ttl)
current = await self.get(key)
value = delta if current is None else counter_value(current) + delta
await self.set(key, counter_entry(value), ttl=ttl)
Expand Down
4 changes: 4 additions & 0 deletions fastapi_cachex/backends/memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from fastapi_cachex.types import CacheEntry

from .base import BaseCacheBackend
from .base import validate_ttl

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -141,6 +142,7 @@ async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None
value: CacheEntry instance to store
ttl: Time to live in seconds
"""
validate_ttl(ttl)
await asyncio.to_thread(
self.client.set, self._make_key(key), encode_entry(value), _expiry(ttl)
)
Expand Down Expand Up @@ -174,6 +176,7 @@ async def set_if_absent(

Memcached's ``ADD`` is exactly this operation.
"""
validate_ttl(ttl)
stored = await asyncio.to_thread(
self.client.add,
self._make_key(key),
Expand Down Expand Up @@ -226,6 +229,7 @@ async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> i
Memcached counters are unsigned, so a negative ``delta`` uses DECR,
which stops at 0 instead of going negative.
"""
validate_ttl(ttl)
from pymemcache.exceptions import MemcacheClientError

prefixed_key = self._make_key(key)
Expand Down
4 changes: 4 additions & 0 deletions fastapi_cachex/backends/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from fastapi_cachex.types import counter_value

from .base import BaseCacheBackend
from .base import validate_ttl
from .base import warn_if_path_shaped

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -123,6 +124,7 @@ async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None
value: Content to cache
ttl: Time to live in seconds (None = never expires)
"""
validate_ttl(ttl)
self._ensure_cleanup_started()

async with self.lock:
Expand Down Expand Up @@ -161,6 +163,7 @@ async def set_if_absent(
self, key: str, value: CacheEntry, ttl: int | None = None
) -> bool:
"""Atomically store ``value`` unless ``key`` exists (see base class)."""
validate_ttl(ttl)
self._ensure_cleanup_started()

async with self.lock:
Expand Down Expand Up @@ -194,6 +197,7 @@ async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> i
The read-modify-write happens under the backend lock, so concurrent
callers on the same event loop never lose an increment.
"""
validate_ttl(ttl)
self._ensure_cleanup_started()

async with self.lock:
Expand Down
4 changes: 4 additions & 0 deletions fastapi_cachex/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from fastapi_cachex.types import CacheEntry

from .base import BaseCacheBackend
from .base import validate_ttl
from .base import warn_if_path_shaped

if TYPE_CHECKING:
Expand Down Expand Up @@ -183,6 +184,7 @@ async def get(self, key: str) -> CacheEntry | None:

async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
"""Store a response in the cache."""
validate_ttl(ttl)
await self.client.set(self._make_key(key), encode_entry(value), ex=ttl)
logger.debug("Redis SET; key=%s ttl=%s", key, ttl)

Expand Down Expand Up @@ -213,6 +215,7 @@ async def set_if_absent(

A single ``SET ... NX EX``.
"""
validate_ttl(ttl)
stored = await self.client.set(
self._make_key(key), encode_entry(value), ex=ttl, nx=True
)
Expand Down Expand Up @@ -248,6 +251,7 @@ async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> i
A short Lua script makes the increment and the expiry one server-side
operation; the key is stored as a plain Redis integer.
"""
validate_ttl(ttl)
from redis.exceptions import ResponseError

try:
Expand Down
17 changes: 14 additions & 3 deletions fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,11 @@ def cache(
"""Cache decorator for FastAPI route handlers.

Args:
ttl: Time-to-live in seconds for cache entries
ttl: Time-to-live in seconds for cache entries, 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
Expand All @@ -464,6 +468,9 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable:
if public and private:
msg = "public and private are mutually exclusive"
raise CacheXError(msg)
if ttl is not None and ttl < 0:
msg = "ttl must not be negative"
raise CacheXError(msg)

# Analyze the original function's signature
sig: Signature = inspect.signature(func)
Expand Down Expand Up @@ -541,6 +548,10 @@ def build_cache_control() -> str:
# The header only depends on the decorator arguments, so build it once.
cache_control = build_cache_control()
builder = key_builder or default_key_builder
# `max-age=0` is a legal header, but backends disagree on what a zero
# TTL means, so such an entry is stored like `ttl=None`: kept only to
# answer ETag revalidation, never served directly.
store_ttl = ttl or None

@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Response:
Expand Down Expand Up @@ -638,7 +649,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response:

# If we don't have If-None-Match header, check if we have a valid cached copy
# and can serve it directly (cache hit without ETag comparison)
if cached_data and not no_cache and ttl is not None:
if cached_data and not no_cache and store_ttl is not None:
logger.debug("Cache HIT (TTL valid); key=%s", cache_key)
return Response(
content=cached_data.content,
Expand Down Expand Up @@ -687,7 +698,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Response:
status_code=current_response.status_code,
headers=_cacheable_headers(current_response),
),
ttl=ttl,
ttl=store_ttl,
)
logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl)

Expand Down
15 changes: 12 additions & 3 deletions fastapi_cachex/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Any

from .backends.base import BaseCacheBackend
from .backends.base import validate_ttl
from .proxy import BackendProxy
from .types import CacheEntry

Expand Down Expand Up @@ -39,10 +40,13 @@ def __init__(
key_prefix: Prefix prepended to all logical keys in the cache backend.
default_ttl: Default TTL (seconds) applied when set() is called
without an explicit ttl. None means no expiry by default.

Raises:
ValueError: If ``default_ttl`` is zero or negative.
"""
self.backend = backend if backend is not None else BackendProxy.get()
self.key_prefix = key_prefix
self.default_ttl = default_ttl
self.default_ttl = validate_ttl(default_ttl)

def _cache_key(self, key: str) -> str:
return f"{self.key_prefix}{key}"
Expand Down Expand Up @@ -85,8 +89,9 @@ async def set(self, key: str, value: Any, ttl: int | None = None) -> None:

Raises:
TypeError: If ``value`` is not JSON-serializable.
ValueError: If ``ttl`` is zero or negative.
"""
effective_ttl = ttl if ttl is not None else self.default_ttl
effective_ttl = validate_ttl(ttl if ttl is not None else self.default_ttl)
entry = self._encode(value)

await self.backend.set(self._cache_key(key), entry, ttl=effective_ttl)
Expand Down Expand Up @@ -115,8 +120,9 @@ async def add(self, key: str, value: Any, ttl: int | None = None) -> bool:

Raises:
TypeError: If ``value`` is not JSON-serializable.
ValueError: If ``ttl`` is zero or negative.
"""
effective_ttl = ttl if ttl is not None else self.default_ttl
effective_ttl = validate_ttl(ttl if ttl is not None else self.default_ttl)
entry = self._encode(value)

added = await self.backend.set_if_absent(
Expand Down Expand Up @@ -177,7 +183,10 @@ async def get_or_set(

Raises:
TypeError: If the value produced by ``factory`` is not JSON-serializable.
ValueError: If ``ttl`` is zero or negative.
"""
# Reject a bad ttl before the factory does any (possibly costly) work.
validate_ttl(ttl)
sentinel = object()
cached = await self.get(key, default=sentinel)
if cached is not sentinel:
Expand Down
9 changes: 9 additions & 0 deletions fastapi_cachex/state/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any

from fastapi_cachex.backends.base import BaseCacheBackend
from fastapi_cachex.backends.base import validate_ttl
from fastapi_cachex.proxy import BackendProxy
from fastapi_cachex.types import CacheEntry

Expand Down Expand Up @@ -43,7 +44,11 @@ def __init__(
backend: Cache backend instance. If None, uses BackendProxy.get().
key_prefix: Prefix for state keys in cache backend
default_ttl: Default time-to-live in seconds for state

Raises:
ValueError: If ``default_ttl`` is zero or negative.
"""
validate_ttl(default_ttl)
self.backend = backend if backend is not None else BackendProxy.get()
self.key_prefix = key_prefix
self.default_ttl = default_ttl
Expand Down Expand Up @@ -118,6 +123,9 @@ async def create_state(
Returns:
The generated state string

Raises:
ValueError: If ``ttl`` is zero or negative.

Backend errors (for example a Redis connection error) propagate
unchanged; they are not wrapped in ``StateDataError``.
"""
Expand All @@ -126,6 +134,7 @@ async def create_state(

# Use provided TTL or default
effective_ttl = ttl if ttl is not None else self.default_ttl
validate_ttl(effective_ttl)

# Create state data model
state_data = StateData(
Expand Down
2 changes: 1 addition & 1 deletion tests/backends/test_memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ async def test_ttl_beyond_thirty_days_is_sent_as_an_absolute_timestamp() -> None


@pytest.mark.asyncio
@pytest.mark.parametrize(("ttl", "expected"), [(None, 0), (0, 0), (60, 60)])
@pytest.mark.parametrize(("ttl", "expected"), [(None, 0), (60, 60)])
async def test_short_ttls_stay_relative(ttl: int | None, expected: int) -> None:
"""Durations inside the boundary are passed straight through."""
backend = stubbed_backend()
Expand Down
Loading
Loading