fastapi-cachex 0.3.6__tar.gz → 0.3.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/PKG-INFO +4 -4
  2. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/README.md +3 -3
  3. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/base.py +28 -3
  4. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/memcached.py +18 -10
  5. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/memory.py +4 -0
  6. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/redis.py +57 -17
  7. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/cache.py +125 -18
  8. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/manager.py +62 -14
  9. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/routes.py +35 -17
  10. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/__init__.py +4 -0
  11. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/config.py +66 -3
  12. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/dependencies.py +46 -5
  13. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/manager.py +30 -12
  14. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/middleware.py +56 -15
  15. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/manager.py +54 -15
  16. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/pyproject.toml +2 -2
  17. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/pyproject.toml.orig +2 -2
  18. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/__init__.py +0 -0
  19. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/__init__.py +0 -0
  20. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/codec.py +0 -0
  21. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/config.py +0 -0
  22. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/dependencies.py +0 -0
  23. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/directives.py +0 -0
  24. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/exceptions.py +0 -0
  25. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/manager_proxy.py +0 -0
  26. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/proxy.py +0 -0
  27. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/py.typed +0 -0
  28. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/exceptions.py +0 -0
  29. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/models.py +0 -0
  30. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/proxy.py +0 -0
  31. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/security.py +0 -0
  32. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/token_serializers.py +0 -0
  33. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/__init__.py +0 -0
  34. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/dependencies.py +0 -0
  35. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/exceptions.py +0 -0
  36. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/models.py +0 -0
  37. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/proxy.py +0 -0
  38. {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-cachex
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
5
5
  Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
6
6
  Author: allen0099
@@ -50,9 +50,9 @@ Description-Content-Type: text/markdown
50
50
  [![PyPI version](https://img.shields.io/pypi/v/fastapi-cachex.svg?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/fastapi-cachex)
51
51
  [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/fastapi-cachex/)
52
52
 
53
- [English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/docs/README.zh-TW.md)
53
+ [English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/i18n/zh-TW/docs/index.md)
54
54
 
55
- A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
55
+ A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, application-level caching, and optional session management.
56
56
 
57
57
  **Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
58
58
 
@@ -61,7 +61,7 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
61
61
  - **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
62
62
  `ETag` / `If-None-Match` (304) and per-route invalidation.
63
63
  - **Application cache** — `CacheManager` for caching arbitrary JSON values in
64
- your own code, with compute-on-miss `get_or_set()`.
64
+ your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`.
65
65
  - **Backends** — in-memory, Redis and Memcached, with atomic counters,
66
66
  one-shot values and locks.
67
67
  - **Sessions (optional)** — HMAC-signed or JWT session tokens over headers,
@@ -12,9 +12,9 @@
12
12
  [![PyPI version](https://img.shields.io/pypi/v/fastapi-cachex.svg?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/fastapi-cachex)
13
13
  [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/fastapi-cachex/)
14
14
 
15
- [English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/docs/README.zh-TW.md)
15
+ [English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/i18n/zh-TW/docs/index.md)
16
16
 
17
- A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
17
+ A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, application-level caching, and optional session management.
18
18
 
19
19
  **Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
20
20
 
@@ -23,7 +23,7 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
23
23
  - **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
24
24
  `ETag` / `If-None-Match` (304) and per-route invalidation.
25
25
  - **Application cache** — `CacheManager` for caching arbitrary JSON values in
26
- your own code, with compute-on-miss `get_or_set()`.
26
+ your own code, with compute-on-miss `get_or_set()` and atomic store-if-absent `add()`.
27
27
  - **Backends** — in-memory, Redis and Memcached, with atomic counters,
28
28
  one-shot values and locks.
29
29
  - **Sessions (optional)** — HMAC-signed or JWT session tokens over headers,
@@ -35,6 +35,23 @@ def warn_if_path_shaped(pattern: str, cleared: int) -> None:
35
35
  )
36
36
 
37
37
 
38
+ def validate_ttl(ttl: int | None) -> int | None:
39
+ """Return ``ttl`` if it is ``None`` or a positive number of seconds.
40
+
41
+ Every backend reads ``0`` or a negative TTL differently (Memcached treats
42
+ ``0`` as "never expires", Redis rejects it, the memory backend expires the
43
+ entry at once), so the library refuses them instead of letting the
44
+ meaning depend on the backend. ``None`` is the way to say "no expiry".
45
+
46
+ Raises:
47
+ ValueError: If ``ttl`` is zero or negative
48
+ """
49
+ if ttl is not None and ttl <= 0:
50
+ msg = f"ttl must be a positive number of seconds or None, got {ttl!r}"
51
+ raise ValueError(msg)
52
+ return ttl
53
+
54
+
38
55
  class BaseCacheBackend(ABC):
39
56
  """Base class for all cache backends."""
40
57
 
@@ -44,7 +61,12 @@ class BaseCacheBackend(ABC):
44
61
 
45
62
  @abstractmethod
46
63
  async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
47
- """Store a response in the cache."""
64
+ """Store a response in the cache.
65
+
66
+ ``ttl`` is ``None`` (never expires) or a positive number of seconds;
67
+ implementations should pass it through ``validate_ttl`` so zero and
68
+ negative values are rejected the same way on every backend.
69
+ """
48
70
 
49
71
  @abstractmethod
50
72
  async def delete(self, key: str) -> None:
@@ -55,8 +77,9 @@ class BaseCacheBackend(ABC):
55
77
 
56
78
  The base implementation deletes one key at a time and reports how
57
79
  many were attempted, since ``delete`` does not say whether the key
58
- existed. The built-in backends override it with a single batched
59
- operation that counts what was actually removed.
80
+ existed. The memory and Redis backends override it with a single
81
+ batched operation that counts what was actually removed; Memcached
82
+ keeps this per-key loop.
60
83
  """
61
84
  count = 0
62
85
  for key in keys:
@@ -106,6 +129,7 @@ class BaseCacheBackend(ABC):
106
129
  Returns:
107
130
  Whether ``value`` was stored
108
131
  """
132
+ validate_ttl(ttl)
109
133
  if await self.get(key) is not None:
110
134
  return False
111
135
  await self.set(key, value, ttl=ttl)
@@ -161,6 +185,7 @@ class BaseCacheBackend(ABC):
161
185
  Raises:
162
186
  CacheXError: If ``key`` holds a cached response instead of a counter
163
187
  """
188
+ validate_ttl(ttl)
164
189
  current = await self.get(key)
165
190
  value = delta if current is None else counter_value(current) + delta
166
191
  await self.set(key, counter_entry(value), ttl=ttl)
@@ -12,6 +12,7 @@ from fastapi_cachex.exceptions import CacheXError
12
12
  from fastapi_cachex.types import CacheEntry
13
13
 
14
14
  from .base import BaseCacheBackend
15
+ from .base import validate_ttl
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -141,6 +142,7 @@ class MemcachedBackend(BaseCacheBackend):
141
142
  value: CacheEntry instance to store
142
143
  ttl: Time to live in seconds
143
144
  """
145
+ validate_ttl(ttl)
144
146
  await asyncio.to_thread(
145
147
  self.client.set, self._make_key(key), encode_entry(value), _expiry(ttl)
146
148
  )
@@ -174,6 +176,7 @@ class MemcachedBackend(BaseCacheBackend):
174
176
 
175
177
  Memcached's ``ADD`` is exactly this operation.
176
178
  """
179
+ validate_ttl(ttl)
177
180
  stored = await asyncio.to_thread(
178
181
  self.client.add,
179
182
  self._make_key(key),
@@ -226,6 +229,7 @@ class MemcachedBackend(BaseCacheBackend):
226
229
  Memcached counters are unsigned, so a negative ``delta`` uses DECR,
227
230
  which stops at 0 instead of going negative.
228
231
  """
232
+ validate_ttl(ttl)
229
233
  from pymemcache.exceptions import MemcacheClientError
230
234
 
231
235
  prefixed_key = self._make_key(key)
@@ -260,13 +264,16 @@ class MemcachedBackend(BaseCacheBackend):
260
264
  async def clear(self) -> None:
261
265
  """Clear all values from cache.
262
266
 
263
- Note: Memcached's flush_all affects the entire server.
264
- Consider using clear_path() with your specific keys instead.
267
+ Note: Memcached's flush_all affects the entire server, including
268
+ other applications' keys. Memcached cannot enumerate keys, so there
269
+ is no way to clear only this namespace; delete keys you know by name
270
+ with ``delete()``/``delete_many()`` instead.
265
271
  """
266
272
  warnings.warn(
267
273
  "Memcached.clear() flushes ALL cached data from the server, "
268
- "affecting other applications. Consider using clear_path() instead "
269
- "to selectively remove only this namespace's keys.",
274
+ "affecting other applications. Memcached cannot enumerate keys, so "
275
+ "this namespace cannot be cleared on its own; delete known keys "
276
+ "with delete() or delete_many() instead.",
270
277
  RuntimeWarning,
271
278
  stacklevel=2,
272
279
  )
@@ -276,14 +283,15 @@ class MemcachedBackend(BaseCacheBackend):
276
283
  async def clear_path(self, path: str, include_params: bool = False) -> int:
277
284
  """Clear cached responses for a specific path.
278
285
 
279
- Note: Memcached does not support pattern-based queries.
280
- This method can only delete keys if the exact key is provided,
281
- or will try to match keys in memory if include_params=True.
282
- For better pattern support, consider using Redis backend.
286
+ Note: Memcached does not support pattern-based queries, so this
287
+ only deletes the key that is exactly ``path``. HTTP route keys
288
+ (``method|||host|||path|||query``) are not matched. For path-based
289
+ clearing, use the Redis or memory backend.
283
290
 
284
291
  Args:
285
- path: The path to clear cache for
286
- include_params: Currently unsupported (Memcached limitation)
292
+ path: The exact key to delete
293
+ include_params: Unsupported; emits a ``RuntimeWarning`` and is
294
+ otherwise ignored
287
295
 
288
296
  Returns:
289
297
  Number of cache entries cleared (0 or 1 for exact match only)
@@ -14,6 +14,7 @@ from fastapi_cachex.types import counter_entry
14
14
  from fastapi_cachex.types import counter_value
15
15
 
16
16
  from .base import BaseCacheBackend
17
+ from .base import validate_ttl
17
18
  from .base import warn_if_path_shaped
18
19
 
19
20
  logger = logging.getLogger(__name__)
@@ -123,6 +124,7 @@ class MemoryBackend(BaseCacheBackend):
123
124
  value: Content to cache
124
125
  ttl: Time to live in seconds (None = never expires)
125
126
  """
127
+ validate_ttl(ttl)
126
128
  self._ensure_cleanup_started()
127
129
 
128
130
  async with self.lock:
@@ -161,6 +163,7 @@ class MemoryBackend(BaseCacheBackend):
161
163
  self, key: str, value: CacheEntry, ttl: int | None = None
162
164
  ) -> bool:
163
165
  """Atomically store ``value`` unless ``key`` exists (see base class)."""
166
+ validate_ttl(ttl)
164
167
  self._ensure_cleanup_started()
165
168
 
166
169
  async with self.lock:
@@ -194,6 +197,7 @@ class MemoryBackend(BaseCacheBackend):
194
197
  The read-modify-write happens under the backend lock, so concurrent
195
198
  callers on the same event loop never lose an increment.
196
199
  """
200
+ validate_ttl(ttl)
197
201
  self._ensure_cleanup_started()
198
202
 
199
203
  async with self.lock:
@@ -1,6 +1,7 @@
1
1
  """Redis cache backend implementation."""
2
2
 
3
3
  import logging
4
+ import time
4
5
  from collections.abc import Iterable
5
6
  from typing import TYPE_CHECKING
6
7
  from typing import Any
@@ -15,6 +16,7 @@ from fastapi_cachex.types import CACHE_KEY_SEPARATOR
15
16
  from fastapi_cachex.types import CacheEntry
16
17
 
17
18
  from .base import BaseCacheBackend
19
+ from .base import validate_ttl
18
20
  from .base import warn_if_path_shaped
19
21
 
20
22
  if TYPE_CHECKING:
@@ -22,9 +24,22 @@ if TYPE_CHECKING:
22
24
 
23
25
  logger = logging.getLogger(__name__)
24
26
 
27
+ # PTTL replies that are not a remaining lifetime.
28
+ _PTTL_NO_EXPIRY = -1
29
+ _PTTL_MISSING = -2
30
+
25
31
  # SCAN page size and DEL batch size; keeps individual commands small.
26
32
  _BATCH_SIZE = 100
27
33
 
34
+ # Characters that are live in a Redis glob pattern.
35
+ _GLOB_SPECIAL = frozenset("*?[]\\")
36
+
37
+
38
+ def _escape_glob(text: str) -> str:
39
+ """Backslash-escape ``text`` so a Redis glob pattern matches it literally."""
40
+ return "".join(f"\\{ch}" if ch in _GLOB_SPECIAL else ch for ch in text)
41
+
42
+
28
43
  # INCRBY that attaches a TTL only when it creates the key, so a counter lives in
29
44
  # a fixed window. KEYS[1] = key, ARGV[1] = delta, ARGV[2] = ttl (0 = none).
30
45
  _INCREMENT_SCRIPT = """
@@ -148,6 +163,11 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
148
163
  """Add prefix to cache key."""
149
164
  return f"{self.key_prefix}{key}"
150
165
 
166
+ @property
167
+ def _prefix_pattern(self) -> str:
168
+ """The key prefix as a literal glob, so ``*``/``?``/``[`` in it stay inert."""
169
+ return _escape_glob(self.key_prefix)
170
+
151
171
  async def _scan_keys(self, pattern: str) -> list[str]:
152
172
  """Collect every key matching ``pattern`` (a full, prefixed glob).
153
173
 
@@ -178,6 +198,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
178
198
 
179
199
  async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
180
200
  """Store a response in the cache."""
201
+ validate_ttl(ttl)
181
202
  await self.client.set(self._make_key(key), encode_entry(value), ex=ttl)
182
203
  logger.debug("Redis SET; key=%s ttl=%s", key, ttl)
183
204
 
@@ -208,6 +229,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
208
229
 
209
230
  A single ``SET ... NX EX``.
210
231
  """
232
+ validate_ttl(ttl)
211
233
  stored = await self.client.set(
212
234
  self._make_key(key), encode_entry(value), ex=ttl, nx=True
213
235
  )
@@ -243,6 +265,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
243
265
  A short Lua script makes the increment and the expiry one server-side
244
266
  operation; the key is stored as a plain Redis integer.
245
267
  """
268
+ validate_ttl(ttl)
246
269
  from redis.exceptions import ResponseError
247
270
 
248
271
  try:
@@ -262,7 +285,9 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
262
285
 
263
286
  Only deletes keys within this backend's prefix.
264
287
  """
265
- removed = await self._delete_keys(await self._scan_keys(f"{self.key_prefix}*"))
288
+ removed = await self._delete_keys(
289
+ await self._scan_keys(f"{self._prefix_pattern}*")
290
+ )
266
291
  logger.debug("Redis CLEAR; removed=%s", removed)
267
292
 
268
293
  async def clear_path(self, path: str, include_params: bool = False) -> int:
@@ -277,9 +302,13 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
277
302
  """
278
303
  # Keys are method|||host|||path|||query. Without include_params only the
279
304
  # exact path is matched: default_key_builder always appends a separator
280
- # after the path, so keys with no query params end with "|||".
305
+ # after the path, so keys with no query params end with "|||". The
306
+ # path is a literal, not a glob: "/files/[draft]" means those brackets.
281
307
  suffix = "*" if include_params else ""
282
- pattern = f"{self.key_prefix}*{CACHE_KEY_SEPARATOR}{path}{CACHE_KEY_SEPARATOR}{suffix}"
308
+ pattern = (
309
+ f"{self._prefix_pattern}*{CACHE_KEY_SEPARATOR}"
310
+ f"{_escape_glob(path)}{CACHE_KEY_SEPARATOR}{suffix}"
311
+ )
283
312
  keys = await self._scan_keys(pattern)
284
313
 
285
314
  # Also match direct keys (custom key formats without separators)
@@ -300,15 +329,16 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
300
329
  async def clear_pattern(self, pattern: str) -> int:
301
330
  """Clear cached responses matching a pattern.
302
331
 
332
+ Only ``pattern`` is a live glob; the backend's key prefix is matched
333
+ literally, whether or not ``pattern`` repeats it.
334
+
303
335
  Args:
304
336
  pattern: A glob pattern to match cache keys against
305
337
 
306
338
  Returns:
307
339
  Number of cache entries cleared
308
340
  """
309
- full_pattern = (
310
- pattern if pattern.startswith(self.key_prefix) else self._make_key(pattern)
311
- )
341
+ full_pattern = self._prefix_pattern + pattern.removeprefix(self.key_prefix)
312
342
  cleared_count = await self._delete_keys(await self._scan_keys(full_pattern))
313
343
  warn_if_path_shaped(pattern, cleared_count)
314
344
  logger.debug(
@@ -322,7 +352,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
322
352
  Returns:
323
353
  List of logical cache keys (without the backend key prefix)
324
354
  """
325
- keys = await self._scan_keys(f"{self.key_prefix}*")
355
+ keys = await self._scan_keys(f"{self._prefix_pattern}*")
326
356
  logical_keys = [k.removeprefix(self.key_prefix) for k in keys]
327
357
  logger.debug("Redis GET_ALL_KEYS; count=%s", len(logical_keys))
328
358
  return logical_keys
@@ -331,9 +361,10 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
331
361
  """Get all cache data with expiry information.
332
362
 
333
363
  Returns:
334
- Dictionary mapping cache keys to (CacheEntry, expiry) tuples.
335
- Note: Redis stores TTL but not absolute expiry time, so this
336
- returns None for expiry (no expiry tracking in Redis backend).
364
+ Dictionary mapping cache keys to (CacheEntry, expiry) tuples, where
365
+ expiry is an absolute ``time.time()`` timestamp like the memory
366
+ backend reports, or None for a key without a TTL. It is derived
367
+ from each key's ``PTTL``, so it is accurate to the round-trip.
337
368
  """
338
369
  all_keys = await self.get_all_keys()
339
370
  cache_data: dict[str, tuple[CacheEntry, float | None]] = {}
@@ -341,16 +372,25 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
341
372
  if not all_keys:
342
373
  return cache_data
343
374
 
344
- # Fetch all values in a single pipeline round-trip instead of N+1 GETs
375
+ # Fetch every value and its remaining lifetime in a single pipeline
376
+ # round-trip instead of 2N commands.
345
377
  pipe = self.client.pipeline()
346
378
  for key in all_keys:
347
- pipe.get(self._make_key(key))
348
- raw_values: list[str | None] = await pipe.execute()
349
-
350
- for key, raw in zip(all_keys, raw_values, strict=False):
379
+ redis_key = self._make_key(key)
380
+ pipe.get(redis_key)
381
+ pipe.pttl(redis_key)
382
+ replies: list[Any] = await pipe.execute()
383
+ now = time.time()
384
+
385
+ for key, raw, pttl in zip(all_keys, replies[::2], replies[1::2], strict=True):
386
+ # -2: the key expired or was deleted between SCAN and this fetch.
387
+ if pttl == _PTTL_MISSING:
388
+ continue
351
389
  value = decode_entry(raw)
352
- if value is not None:
353
- cache_data[key] = (value, None)
390
+ if value is None:
391
+ continue
392
+ expiry = None if pttl == _PTTL_NO_EXPIRY else now + pttl / 1000
393
+ cache_data[key] = (value, expiry)
354
394
 
355
395
  logger.debug("Redis GET_CACHE_DATA; keys=%s", len(cache_data))
356
396
  return cache_data
@@ -21,6 +21,10 @@ from typing import get_type_hints
21
21
 
22
22
  from fastapi import Request
23
23
  from fastapi import Response
24
+ from fastapi.encoders import jsonable_encoder
25
+ from fastapi.utils import is_body_allowed_for_status_code
26
+ from pydantic import TypeAdapter
27
+ from starlette.concurrency import run_in_threadpool
24
28
  from starlette.status import HTTP_200_OK
25
29
  from starlette.status import HTTP_206_PARTIAL_CONTENT
26
30
  from starlette.status import HTTP_300_MULTIPLE_CHOICES
@@ -303,6 +307,51 @@ async def _render(
303
307
  return response, body, None if body is None else _etag_for(body)
304
308
 
305
309
 
310
+ # Attribute a route's response-model TypeAdapter is kept under, so it is built
311
+ # once per route rather than on every cache miss.
312
+ _ADAPTER_ATTR = "_cachex_response_adapter"
313
+
314
+
315
+ def _serialize_result(route: "APIRoute", result: object) -> object:
316
+ """JSON-compatible content for a handler's non-Response return value.
317
+
318
+ With a response model (declared, or inferred from the return annotation)
319
+ the result is validated against it and dumped with the route's
320
+ ``response_model_*`` options, so fields the model leaves out are dropped.
321
+ Otherwise it goes through ``jsonable_encoder``, as FastAPI does.
322
+ """
323
+ if route.response_model is None:
324
+ return jsonable_encoder(result)
325
+ adapter: TypeAdapter[Any] | None = getattr(route, _ADAPTER_ATTR, None)
326
+ if adapter is None:
327
+ adapter = TypeAdapter(route.response_model)
328
+ setattr(route, _ADAPTER_ATTR, adapter)
329
+ validated = adapter.validate_python(result, from_attributes=True)
330
+ return adapter.dump_python(
331
+ validated,
332
+ mode="json",
333
+ include=route.response_model_include,
334
+ exclude=route.response_model_exclude,
335
+ by_alias=route.response_model_by_alias,
336
+ exclude_unset=route.response_model_exclude_unset,
337
+ exclude_defaults=route.response_model_exclude_defaults,
338
+ exclude_none=route.response_model_exclude_none,
339
+ )
340
+
341
+
342
+ def _is_coroutine_callable(func: HandlerCallable) -> bool:
343
+ """Report whether calling `func` returns a coroutine.
344
+
345
+ `inspect.iscoroutinefunction` already sees through `functools.partial`;
346
+ an instance with an ``async def __call__`` needs its method checked. The
347
+ method is looked up on the type, as the call itself does, so a class
348
+ (whose type is ``type``) counts as sync.
349
+ """
350
+ return inspect.iscoroutinefunction(func) or inspect.iscoroutinefunction(
351
+ type(func).__call__
352
+ )
353
+
354
+
306
355
  async def get_response(
307
356
  __func: HandlerCallable,
308
357
  __request: Request,
@@ -310,11 +359,20 @@ async def get_response(
310
359
  *args: Any,
311
360
  **kwargs: Any,
312
361
  ) -> Response:
313
- """Get the response from the function."""
314
- if inspect.iscoroutinefunction(__func):
315
- result = await __func(*args, **kwargs)
362
+ """Get the response from the function.
363
+
364
+ Coroutine handlers are awaited. Sync handlers run in the threadpool, as
365
+ FastAPI would run them without the (async) cache wrapper, so blocking I/O
366
+ in a ``def`` handler does not stall the event loop.
367
+ """
368
+ if _is_coroutine_callable(__func):
369
+ result = await cast("Callable[..., Awaitable[object]]", __func)(*args, **kwargs)
316
370
  else:
317
- result = __func(*args, **kwargs)
371
+ result = await run_in_threadpool(__func, *args, **kwargs)
372
+ # A sync callable can still hand back an awaitable (a lambda wrapping a
373
+ # coroutine function, say); await it rather than try to encode it.
374
+ if inspect.isawaitable(result):
375
+ result = await result
318
376
 
319
377
  # If already a Response object, return it directly
320
378
  if isinstance(result, Response):
@@ -343,8 +401,25 @@ async def get_response(
343
401
  ),
344
402
  )
345
403
 
346
- # Convert non-Response result to Response using appropriate response_class
347
- return response_class(content=result)
404
+ # Build the response the way FastAPI would have without the cache wrapper:
405
+ # serialize through the response model, apply the route's status code and
406
+ # carry over what the handler set on an injected `response: Response`.
407
+ sub_response = next(
408
+ (value for value in kwargs.values() if isinstance(value, Response)), None
409
+ )
410
+ status_code = route.status_code
411
+ if sub_response is not None and sub_response.status_code:
412
+ status_code = sub_response.status_code
413
+ response_args: dict[str, Any] = {}
414
+ if status_code is not None:
415
+ response_args["status_code"] = status_code
416
+
417
+ response = response_class(_serialize_result(route, result), **response_args)
418
+ if not is_body_allowed_for_status_code(response.status_code):
419
+ response.body = b""
420
+ if sub_response is not None:
421
+ response.headers.raw.extend(sub_response.headers.raw)
422
+ return response
348
423
 
349
424
 
350
425
  def cache(
@@ -362,20 +437,45 @@ def cache(
362
437
  ) -> Callable[[HandlerCallable], AsyncResponseCallable]:
363
438
  """Cache decorator for FastAPI route handlers.
364
439
 
440
+ Only GET requests go through the cache; other methods run the handler
441
+ unchanged.
442
+
365
443
  Args:
366
- ttl: Time-to-live in seconds for cache entries
367
- stale_ttl: Additional time-to-live for stale cache entries
368
- stale: Stale response handling strategy ('error' or 'revalidate')
369
- no_cache: Whether to disable caching
370
- no_store: Whether to prevent storing responses
371
- public: Whether responses can be cached by shared caches
372
- private: Whether responses are for single user only
373
- immutable: Whether cached responses never change
374
- must_revalidate: Whether to force revalidation when stale
375
- key_builder: Custom function to build cache keys. If None, uses default_key_builder
444
+ ttl: How long, in seconds, a stored response may be served without
445
+ running the handler. The same value is sent as ``max-age``.
446
+ ``ttl=0`` sends ``max-age=0`` and, like ``None``, keeps the entry
447
+ only for ETag revalidation: the body is never served from the
448
+ cache, but a matching ``If-None-Match`` still gets a 304. Negative
449
+ values are rejected.
450
+ stale_ttl: Seconds sent with the directive chosen by ``stale``. It only
451
+ shapes the ``Cache-Control`` header; the backend entry still
452
+ expires after ``ttl``. Must be given together with ``stale``.
453
+ stale: ``"revalidate"`` sends ``stale-while-revalidate=<stale_ttl>``,
454
+ ``"error"`` sends ``stale-if-error=<stale_ttl>``.
455
+ no_cache: Run the handler on every request and send ``no-cache``. The
456
+ response is still stored and ``If-None-Match`` still gets a 304
457
+ when it matches the fresh ETag. The header then carries only
458
+ ``no-cache`` (plus ``must-revalidate`` when set); ``ttl``,
459
+ ``stale``, ``public``/``private`` and ``immutable`` are left out.
460
+ no_store: Run the handler, store nothing, and send ``no-store``. Takes
461
+ precedence over every other option.
462
+ public: Send ``public``. Mutually exclusive with ``private``.
463
+ private: Send ``private`` and bypass the shared backend entirely: the
464
+ handler runs on every request and nothing is read or stored. ETag
465
+ revalidation still works against the freshly rendered response.
466
+ Mutually exclusive with ``public``.
467
+ immutable: Send ``immutable``.
468
+ must_revalidate: Send ``must-revalidate``.
469
+ key_builder: Custom function to build cache keys. If None, uses
470
+ ``default_key_builder``.
376
471
 
377
472
  Returns:
378
473
  Decorator function that wraps route handlers with caching logic
474
+
475
+ Raises:
476
+ CacheXError: When the decorator is applied, if ``stale`` and
477
+ ``stale_ttl`` are not given together, if ``public`` and
478
+ ``private`` are both set, or if ``ttl`` is negative.
379
479
  """
380
480
 
381
481
  def decorator(func: HandlerCallable) -> AsyncResponseCallable:
@@ -389,6 +489,9 @@ def cache(
389
489
  if public and private:
390
490
  msg = "public and private are mutually exclusive"
391
491
  raise CacheXError(msg)
492
+ if ttl is not None and ttl < 0:
493
+ msg = "ttl must not be negative"
494
+ raise CacheXError(msg)
392
495
 
393
496
  # Analyze the original function's signature
394
497
  sig: Signature = inspect.signature(func)
@@ -466,6 +569,10 @@ def cache(
466
569
  # The header only depends on the decorator arguments, so build it once.
467
570
  cache_control = build_cache_control()
468
571
  builder = key_builder or default_key_builder
572
+ # `max-age=0` is a legal header, but backends disagree on what a zero
573
+ # TTL means, so such an entry is stored like `ttl=None`: kept only to
574
+ # answer ETag revalidation, never served directly.
575
+ store_ttl = ttl or None
469
576
 
470
577
  @wraps(func)
471
578
  async def wrapper(*args: Any, **kwargs: Any) -> Response:
@@ -563,7 +670,7 @@ def cache(
563
670
 
564
671
  # If we don't have If-None-Match header, check if we have a valid cached copy
565
672
  # and can serve it directly (cache hit without ETag comparison)
566
- if cached_data and not no_cache and ttl is not None:
673
+ if cached_data and not no_cache and store_ttl is not None:
567
674
  logger.debug("Cache HIT (TTL valid); key=%s", cache_key)
568
675
  return Response(
569
676
  content=cached_data.content,
@@ -612,7 +719,7 @@ def cache(
612
719
  status_code=current_response.status_code,
613
720
  headers=_cacheable_headers(current_response),
614
721
  ),
615
- ttl=ttl,
722
+ ttl=store_ttl,
616
723
  )
617
724
  logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl)
618
725