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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,18 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.
`del` or `pop()`, e.g. a flash message; such a session is now saved with
empty data. An emptied anonymous session is still deleted.
([#227](https://github.com/allen0099/FastAPI-CacheX/issues/227))
- **A `ttl` must be an `int` up to `MAX_TTL`, and `delta` an `int` in 64-bit
range.** On Redis, `increment(key, ttl=1.5)` created the counter and then
failed at `EXPIRE`, leaving a counter that never expired (a permanent
lockout for a rate limiter) behind an error saying the key was "not a
counter". `validate_ttl` now raises `TypeError` for `float`, `bool` and
other types, and `ValueError` above `MAX_TTL` (2**31 - 1 seconds), before
any backend I/O. A float TTL used to work on the memory backend only.
`increment` checks `delta` the same way. Memcached now raises `ValueError`
for a `ttl` whose expiry falls after 2038-01-19, which it used to accept and
then drop at once, and reports only non-numeric values as "not a counter".
`@cache` rejects such a `ttl` when the decorator is applied.
([#229](https://github.com/allen0099/FastAPI-CacheX/issues/229))

## [0.3.7] - 2026-09-25

Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ Four non-abstract atomic primitives live on the base class with non-atomic fallb
- `set_if_absent(key, value, ttl=None) -> bool`: claim-if-free for locks/slots. Redis `SET NX EX`, Memcached `ADD`, memory under its lock.
- `delete_if_equals(key, expected) -> bool`: release only while the key still holds `expected` (compared as decoded `CacheEntry`). Redis compares in Python then deletes via a Lua script that re-checks the raw bytes; Memcached uses `GETS` + `CAS` with exptime `-1` (immediate expiry), since classic `DELETE` has no CAS.

`validate_ttl` (in `backends/base.py`) accepts `None` or an `int` from 1 to `MAX_TTL` (2**31 - 1) and raises `TypeError` for floats/bools; `validate_delta` requires an `int` in signed 64-bit range. Both run before any I/O. Memcached's `_expiry` also rejects expiries after 2038-01-19.

`delete_many(keys) -> int` is the fifth non-abstract base method: a per-key loop by default, one batched operation on Redis (`DEL`) and Memory (single lock).

`backends/codec.py` holds the JSON `CacheEntry` codec shared by Redis and Memcached; `decode_entry` maps a bare integer to a counter entry and every malformed value to `None`.
Expand Down
28 changes: 21 additions & 7 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ BackendProxy.set(backend)
- `clear()` issues `flush_all`, which wipes the whole Memcached server, not just this namespace
- A key Memcached would reject (over 250 bytes, whitespace, non-ASCII) is stored
under its SHA-256 digest
- A `ttl` whose expiry falls after 2038-01-19 raises `ValueError` (see
[TTL values](#ttl-values))
- Values larger than the server's item size limit (1 MB by default, `memcached -I`)
are rejected with an error. `@cache` logs it and serves the response unstored
(see [When the backend fails](HTTP_CACHING.md#when-the-backend-fails)); other
Expand Down Expand Up @@ -198,7 +200,9 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300):
and the monitoring routes treat it like any other entry. Incrementing a key
that holds anything else raises `CacheXError` on every backend, even a cached
response whose body is a number. A counter written with
`set(key, counter_entry(n))` can be incremented on every backend.
`set(key, counter_entry(n))` can be incremented on every backend. `delta`
must be an `int` within the signed 64-bit range; anything else raises
`TypeError` or `ValueError` before the backend is touched.
- `get_and_delete(key) -> CacheEntry | None` — Memory pops under its lock, Redis
uses `GETDEL` (server 6.2+) and Memcached uses `GETS` + a `CAS` write with
`exptime=-1` (retrying if another writer replaced the value in between). If
Expand Down Expand Up @@ -231,12 +235,22 @@ real atomicity.

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
the entry never expires, or an `int` number of seconds from 1 up to `MAX_TTL`
(2**31 - 1, about 68 years). The checks run before any backend I/O:

- Zero, negative and larger values raise `ValueError`. The underlying stores
disagree on what `0` means: Memcached reads it as "never expire", Redis
rejects `EX 0`, and an in-process dict would expire the entry at once.
- A `float`, a `bool` or any other type raises `TypeError`. A float worked
only on the memory backend, and `True` was taken as one second. Convert a
`timedelta` with `int(td.total_seconds())`.
- Memcached cannot store an expiry after 2038-01-19 (its exptime is a signed
32-bit timestamp), so the Memcached backend raises `ValueError` for a `ttl`
that reaches past it instead of accepting a write it would drop at once.

A third-party backend should call `fastapi_cachex.backends.base.validate_ttl(ttl)`
in its `set` to follow the same rules, and `validate_delta(delta)` in
`increment`. (`@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
Expand Down
2 changes: 1 addition & 1 deletion docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,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`): Nothing is read from or written to the backend, as with `private=True`. The handler runs on every request, and `If-None-Match` gets a 304 only when it matches the freshly rendered response, so an old ETag never gets a 304 once the content has changed
- **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
- **With `ttl=0`**: Sends `max-age=0` and otherwise behaves like `ttl=None`. A negative `ttl`, a non-`int` one (such as `1.5` or `True`) and one above `MAX_TTL` (see [TTL values](BACKENDS.md#ttl-values)) are 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
43 changes: 41 additions & 2 deletions fastapi_cachex/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ def warn_if_path_shaped(pattern: str, cleared: int) -> None:
)


# The largest TTL accepted anywhere: 2**31 - 1 seconds, about 68 years.
MAX_TTL = 2**31 - 1


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

Expand All @@ -43,15 +47,47 @@ def validate_ttl(ttl: int | None) -> int | None:
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".

Only an ``int`` is a TTL. A ``float`` worked on the memory backend and
failed on Redis and Memcached, and ``True`` passed as one second.

Raises:
ValueError: If ``ttl`` is zero or negative
TypeError: If ``ttl`` is not an ``int`` (``bool`` included)
ValueError: If ``ttl`` is zero, negative or larger than ``MAX_TTL``
"""
if ttl is not None and ttl <= 0:
if ttl is None:
return None
if isinstance(ttl, bool) or not isinstance(ttl, int):
msg = f"ttl must be an int number of seconds or None, got {type(ttl).__name__}"
raise TypeError(msg)
if ttl <= 0:
msg = f"ttl must be a positive number of seconds or None, got {ttl!r}"
raise ValueError(msg)
if ttl > MAX_TTL:
msg = f"ttl must be at most {MAX_TTL} seconds (about 68 years)"
raise ValueError(msg)
return ttl


def validate_delta(delta: int) -> int:
"""Return ``delta`` if it is an ``int`` a counter can be changed by.

Redis counters are signed 64-bit integers and Memcached's are unsigned, so
a delta outside the signed 64-bit range fails on both, where it used to be
reported as the key not holding a counter.

Raises:
TypeError: If ``delta`` is not an ``int`` (``bool`` included)
ValueError: If ``delta`` does not fit in a signed 64-bit integer
"""
if isinstance(delta, bool) or not isinstance(delta, int):
msg = f"delta must be an int, got {type(delta).__name__}"
raise TypeError(msg)
if not -(2**63) <= delta < 2**63:
msg = "delta must fit in a signed 64-bit integer"
raise ValueError(msg)
return delta


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

Expand Down Expand Up @@ -210,7 +246,10 @@ 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
TypeError: If ``delta`` or ``ttl`` is not an ``int``
ValueError: If ``ttl`` is out of range
"""
validate_delta(delta)
validate_ttl(ttl)
current = await self.get(key)
value = delta if current is None else counter_value(current) + delta
Expand Down
25 changes: 23 additions & 2 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_delta
from .base import validate_ttl

logger = logging.getLogger(__name__)
Expand All @@ -29,6 +30,10 @@
# not as a duration, so a longer TTL has to be converted before it is sent.
_MAX_RELATIVE_TTL = 30 * 24 * 60 * 60

# Memcached parses exptime as a signed 32-bit integer. An absolute timestamp
# past this one (2038-01-19) wraps around and the item is dropped at once.
_MAX_ABSOLUTE_EXPTIME = 2**31 - 1

# Seconds a failed server stays out of rotation before HashClient tries it
# again. Until then every call to it raises.
_DEAD_TIMEOUT = 1
Expand All @@ -43,11 +48,22 @@ def _expiry(ttl: int | None) -> int:
Anything past the 30-day boundary is sent as an absolute timestamp;
passing it through as a duration would have Memcached read it as a moment
in 1970 and expire the entry immediately. ``None`` means no expiry.

Raises:
ValueError: If the expiry would fall after 2038-01-19, which Memcached
cannot represent: it would accept the write and drop the item.
"""
if ttl is None:
return 0
if ttl > _MAX_RELATIVE_TTL:
return int(time.time()) + ttl
expires_at = int(time.time()) + ttl
if expires_at > _MAX_ABSOLUTE_EXPTIME:
msg = (
f"ttl {ttl!r} expires after 2038-01-19, which Memcached cannot "
"store; use a shorter ttl or None"
)
raise ValueError(msg)
return expires_at
return ttl


Expand Down Expand Up @@ -281,20 +297,25 @@ 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_delta(delta)
validate_ttl(ttl)
from pymemcache.exceptions import MemcacheClientError

prefixed_key = self._make_key(key)
# Converted up front so a ttl Memcached cannot store fails before I/O.
exptime = _expiry(ttl)
try:
value = await asyncio.to_thread(self._add_delta, prefixed_key, delta)
if value is None:
# No counter yet: ADD is atomic and a no-op when a concurrent
# call created it first, so the retry always finds a counter.
await asyncio.to_thread(
self.client.add, prefixed_key, b"0", _expiry(ttl), noreply=False
self.client.add, prefixed_key, b"0", exptime, noreply=False
)
value = await asyncio.to_thread(self._add_delta, prefixed_key, delta)
except MemcacheClientError as e:
if "non-numeric" not in str(e):
raise
msg = "Cache key holds a value that is not a counter"
raise CacheXError(msg) from e
if value is None:
Expand Down
2 changes: 2 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_delta
from .base import validate_ttl
from .base import warn_if_path_shaped

Expand Down Expand Up @@ -262,6 +263,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_delta(delta)
validate_ttl(ttl)
self._ensure_cleanup_started()

Expand Down
2 changes: 2 additions & 0 deletions fastapi_cachex/backends/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from fastapi_cachex.types import CacheEntry

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

Expand Down Expand Up @@ -356,6 +357,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_delta(delta)
validate_ttl(ttl)
from redis.exceptions import ResponseError

Expand Down
12 changes: 11 additions & 1 deletion fastapi_cachex/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from starlette.status import HTTP_300_MULTIPLE_CHOICES
from starlette.status import HTTP_304_NOT_MODIFIED

from .backends.base import MAX_TTL
from .directives import DirectiveType
from .exceptions import BackendNotFoundError
from .exceptions import CacheXError
Expand Down Expand Up @@ -481,7 +482,8 @@ def cache(
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.
``private`` are both set, or if ``ttl`` is not an ``int``, is
negative or is larger than ``MAX_TTL``.
"""

def decorator(func: HandlerCallable) -> AsyncResponseCallable:
Expand All @@ -495,9 +497,17 @@ 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 (isinstance(ttl, bool) or not isinstance(ttl, int)):
# Checked here: at request time the backend would reject it, and
# failing open would hide that the route never caches.
msg = f"ttl must be an int number of seconds, got {type(ttl).__name__}"
raise CacheXError(msg)
if ttl is not None and ttl < 0:
msg = "ttl must not be negative"
raise CacheXError(msg)
if ttl is not None and ttl > MAX_TTL:
msg = f"ttl must be at most {MAX_TTL} seconds"
raise CacheXError(msg)

# Analyze the original function's signature
sig: Signature = inspect.signature(func)
Expand Down
Loading
Loading