From d4c2fc27a4a58b997846a4df13811ae040e54ff3 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Fri, 25 Sep 2026 11:54:31 +0000 Subject: [PATCH] fix(backends): give ttl one meaning across backends Zero or negative TTLs meant 'never expire' on Memcached, raised on Redis and expired at once in memory. Reject them with ValueError through a shared validate_ttl() in every backend, the base fallbacks, CacheManager and StateManager. @cache(ttl=0) keeps sending max-age=0 but stores the entry like ttl=None, so the backend never sees 0; a negative @cache ttl raises CacheXError at decoration time. Closes #102 --- CHANGELOG.md | 13 ++++ docs/BACKENDS.md | 12 ++++ docs/CACHE_FLOW.md | 7 +- docs/HTTP_CACHING.md | 1 + fastapi_cachex/backends/base.py | 26 ++++++- fastapi_cachex/backends/memcached.py | 4 ++ fastapi_cachex/backends/memory.py | 4 ++ fastapi_cachex/backends/redis.py | 4 ++ fastapi_cachex/cache.py | 17 ++++- fastapi_cachex/manager.py | 15 +++- fastapi_cachex/state/manager.py | 9 +++ tests/backends/test_memcached.py | 2 +- tests/backends/test_redis.py | 37 ++++++++++ tests/backends/test_ttl_contract.py | 103 +++++++++++++++++++++++++++ tests/test_cache.py | 30 +++++--- 15 files changed, 265 insertions(+), 19 deletions(-) create mode 100644 tests/backends/test_ttl_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f28bf..6a1e705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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_*` diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index a48307b..7178b16 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -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). diff --git a/docs/CACHE_FLOW.md b/docs/CACHE_FLOW.md index 158e043..fe8fd76 100644 --- a/docs/CACHE_FLOW.md +++ b/docs/CACHE_FLOW.md @@ -113,9 +113,10 @@ The header value is built once per decorated route: | anything else | in order: `public` or `private`, `max-age=`, `must-revalidate`, `stale-while-revalidate=` or `stale-if-error=`, `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 diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index 3f8b3a3..3d88131 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -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 diff --git a/fastapi_cachex/backends/base.py b/fastapi_cachex/backends/base.py index 5ef3488..3565e9a 100644 --- a/fastapi_cachex/backends/base.py +++ b/fastapi_cachex/backends/base.py @@ -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.""" @@ -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: @@ -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) @@ -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) diff --git a/fastapi_cachex/backends/memcached.py b/fastapi_cachex/backends/memcached.py index 8de4e18..1cf4d76 100644 --- a/fastapi_cachex/backends/memcached.py +++ b/fastapi_cachex/backends/memcached.py @@ -12,6 +12,7 @@ from fastapi_cachex.types import CacheEntry from .base import BaseCacheBackend +from .base import validate_ttl logger = logging.getLogger(__name__) @@ -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) ) @@ -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), @@ -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) diff --git a/fastapi_cachex/backends/memory.py b/fastapi_cachex/backends/memory.py index ee05a9b..4aa4e53 100644 --- a/fastapi_cachex/backends/memory.py +++ b/fastapi_cachex/backends/memory.py @@ -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__) @@ -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: @@ -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: @@ -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: diff --git a/fastapi_cachex/backends/redis.py b/fastapi_cachex/backends/redis.py index adae57e..e8d05c0 100644 --- a/fastapi_cachex/backends/redis.py +++ b/fastapi_cachex/backends/redis.py @@ -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: @@ -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) @@ -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 ) @@ -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: diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index 681f513..c5e30bb 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -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 @@ -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) @@ -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: @@ -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, @@ -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) diff --git a/fastapi_cachex/manager.py b/fastapi_cachex/manager.py index 03cc4b8..ac9398a 100644 --- a/fastapi_cachex/manager.py +++ b/fastapi_cachex/manager.py @@ -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 @@ -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}" @@ -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) @@ -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( @@ -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: diff --git a/fastapi_cachex/state/manager.py b/fastapi_cachex/state/manager.py index f9d3e9c..4885c19 100644 --- a/fastapi_cachex/state/manager.py +++ b/fastapi_cachex/state/manager.py @@ -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 @@ -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 @@ -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``. """ @@ -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( diff --git a/tests/backends/test_memcached.py b/tests/backends/test_memcached.py index 0b69799..a8483cc 100644 --- a/tests/backends/test_memcached.py +++ b/tests/backends/test_memcached.py @@ -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() diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index fe5c26d..3a1b6af 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -884,3 +884,40 @@ async def test_redis_lock_release_after_expiry_keeps_the_new_holder( assert await async_redis_backend.delete_if_equals("slot", owner_a) is False assert await async_redis_backend.get("slot") == owner_b + + +@requires_redis +def test_cached_route_with_ttl_zero_is_served() -> None: + """End to end: `@cache(ttl=0)` used to send `SET ... EX 0` and answer 500.""" + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from fastapi_cachex.cache import cache + from fastapi_cachex.proxy import BackendProxy + + backend = AsyncRedisCacheBackend( + host=REDIS_HOST, port=REDIS_PORT, key_prefix="cachex_ttl0_test:" + ) + previous = BackendProxy.get() + BackendProxy.set(backend) + try: + app = FastAPI() + + @app.get("/ttl-zero") + @cache(ttl=0) + async def ttl_zero() -> dict[str, str]: + return {"ok": "yes"} + + with TestClient(app) as client: + first = client.get("/ttl-zero") + revalidated = client.get( + "/ttl-zero", headers={"If-None-Match": first.headers["ETag"]} + ) + # Clean up inside the client's event loop, which owns the pool. + client.portal.call(backend.clear) # type: ignore[union-attr] + + assert first.status_code == 200 + assert first.headers["Cache-Control"] == "max-age=0" + assert revalidated.status_code == 304 + finally: + BackendProxy.set(previous) diff --git a/tests/backends/test_ttl_contract.py b/tests/backends/test_ttl_contract.py new file mode 100644 index 0000000..35964c9 --- /dev/null +++ b/tests/backends/test_ttl_contract.py @@ -0,0 +1,103 @@ +"""`ttl` means the same thing on every backend (#102). + +Memcached read an exptime of 0 as "never expire", Redis rejected `EX 0`, and +the memory backend expired the entry at once. Now `None` is the only way to +say "no expiry" and zero or negative values raise `ValueError` before any +backend I/O. +""" + +from collections.abc import Awaitable +from collections.abc import Callable +from unittest.mock import MagicMock + +import pytest + +from fastapi_cachex.backends import AsyncRedisCacheBackend +from fastapi_cachex.backends import MemcachedBackend +from fastapi_cachex.backends.base import BaseCacheBackend +from fastapi_cachex.backends.base import validate_ttl +from fastapi_cachex.backends.memory import MemoryBackend +from fastapi_cachex.manager import CacheManager +from fastapi_cachex.state import StateManager +from fastapi_cachex.types import CacheEntry +from tests.backends.test_base import DictBackend +from tests.live_servers import UNCONNECTED_PORT + +ENTRY = CacheEntry(fingerprint="e", content=b"v") +BAD_TTLS = [0, -1] + + +def make_backends() -> list[BaseCacheBackend]: + # None of these touch a server before validating: Redis points at a port + # nothing listens on and Memcached's client is a stub, so reaching I/O + # would fail with a different error (or record a call). + memcached = MemcachedBackend([f"127.0.0.1:{UNCONNECTED_PORT}"]) + memcached.client = MagicMock() + return [ + MemoryBackend(), + AsyncRedisCacheBackend(host="127.0.0.1", port=UNCONNECTED_PORT), + memcached, + DictBackend(), + ] + + +OPERATIONS: dict[str, Callable[[BaseCacheBackend, int], Awaitable[object]]] = { + "set": lambda backend, ttl: backend.set("k", ENTRY, ttl=ttl), + "set_if_absent": lambda backend, ttl: backend.set_if_absent("k", ENTRY, ttl=ttl), + "increment": lambda backend, ttl: backend.increment("n", ttl=ttl), +} + + +@pytest.mark.parametrize("ttl", BAD_TTLS) +@pytest.mark.parametrize("operation", ["set", "set_if_absent", "increment"]) +@pytest.mark.asyncio +async def test_backends_reject_non_positive_ttl(operation: str, ttl: int) -> None: + for backend in make_backends(): + if isinstance(backend, DictBackend) and operation == "set": + continue # a third-party set() is its own; the fallbacks are ours + with pytest.raises(ValueError, match="ttl must be a positive"): + await OPERATIONS[operation](backend, ttl) + if isinstance(backend, MemcachedBackend): + assert isinstance(backend.client, MagicMock) + assert backend.client.method_calls == [] + if isinstance(backend, MemoryBackend): + assert backend.cache == {} + backend.stop_cleanup() + + +@pytest.mark.parametrize("ttl", [None, 1, 3600]) +def test_validate_ttl_passes_none_and_positive(ttl: int | None) -> None: + assert validate_ttl(ttl) == ttl + + +@pytest.mark.parametrize("ttl", BAD_TTLS) +@pytest.mark.asyncio +async def test_cache_manager_rejects_non_positive_ttl(ttl: int) -> None: + backend = DictBackend() + with pytest.raises(ValueError, match="ttl must be a positive"): + CacheManager(backend, default_ttl=ttl) + + manager = CacheManager(backend) + factory = MagicMock(return_value=1) + with pytest.raises(ValueError, match="ttl must be a positive"): + await manager.set("k", 1, ttl=ttl) + with pytest.raises(ValueError, match="ttl must be a positive"): + await manager.add("k", 1, ttl=ttl) + with pytest.raises(ValueError, match="ttl must be a positive"): + await manager.get_or_set("k", factory, ttl=ttl) + # The ttl is checked before the factory runs. + factory.assert_not_called() + assert backend.store == {} + + +@pytest.mark.parametrize("ttl", BAD_TTLS) +@pytest.mark.asyncio +async def test_state_manager_rejects_non_positive_ttl(ttl: int) -> None: + backend = DictBackend() + with pytest.raises(ValueError, match="ttl must be a positive"): + StateManager(backend, default_ttl=ttl) + + manager = StateManager(backend) + with pytest.raises(ValueError, match="ttl must be a positive"): + await manager.create_state(ttl=ttl) + assert backend.store == {} diff --git a/tests/test_cache.py b/tests/test_cache.py index 2e5a9c5..6bf5d18 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -639,8 +639,8 @@ async def no_store_no_cache_endpoint(): assert "no-cache" not in cc -def test_ttl_zero_entry_expires_immediately(): - """ttl=0 causes the entry to expire immediately; subsequent requests re-execute handler.""" +def test_ttl_zero_sends_max_age_zero_and_only_revalidates(): + """ttl=0 is `max-age=0`: the body is never replayed, a matching ETag gets 304.""" call_count = {"n": 0} ttl0_app = FastAPI() ttl0_backend = MemoryBackend() @@ -656,17 +656,31 @@ async def ttl_zero_endpoint(): r1 = ttl0_client.get("/ttl-zero") assert r1.status_code == 200 - assert "ETag" in r1.headers - assert call_count["n"] == 1 + assert r1.headers["Cache-Control"] == "max-age=0" + etag = r1.headers["ETag"] - # Remove the (immediately-expired) entry directly from the backend dict - # so the next request hits a fresh cache miss and re-executes the handler. - ttl0_backend.cache.clear() + # The entry is kept without an expiry, like ttl=None; the backend is never + # handed a zero TTL, which every backend used to read differently. + (item,) = ttl0_backend.cache.values() + assert item.expiry is None - # Second request — no cached entry, handler called again + # Without a validator the handler runs again: nothing is served directly. r2 = ttl0_client.get("/ttl-zero") assert r2.status_code == 200 + assert r2.content == b"hello" + assert call_count["n"] == 2 + + # A matching validator is answered from the stored ETag. + r3 = ttl0_client.get("/ttl-zero", headers={"If-None-Match": etag}) + assert r3.status_code == 304 + assert r3.headers["Cache-Control"] == "max-age=0" assert call_count["n"] == 2 + ttl0_backend.stop_cleanup() + + +def test_negative_ttl_is_rejected_at_decoration(): + with pytest.raises(CacheXError, match="ttl must not be negative"): + cache(ttl=-1)(lambda: None) def test_stale_client_etag_with_changed_cache():