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.
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/PKG-INFO +4 -4
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/README.md +3 -3
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/base.py +28 -3
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/memcached.py +18 -10
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/memory.py +4 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/redis.py +57 -17
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/cache.py +125 -18
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/manager.py +62 -14
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/routes.py +35 -17
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/__init__.py +4 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/config.py +66 -3
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/dependencies.py +46 -5
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/manager.py +30 -12
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/middleware.py +56 -15
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/manager.py +54 -15
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/pyproject.toml +2 -2
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/pyproject.toml.orig +2 -2
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/__init__.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/codec.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/security.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/session/token_serializers.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/dependencies.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/models.py +0 -0
- {fastapi_cachex-0.3.6 → fastapi_cachex-0.3.7}/fastapi_cachex/state/proxy.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/fastapi-cachex)
|
|
51
51
|
[](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/
|
|
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
|
|
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
|
[](https://pypi.org/project/fastapi-cachex)
|
|
13
13
|
[](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/
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
269
|
-
"
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
286
|
-
include_params:
|
|
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(
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
336
|
-
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
353
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
|
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
|
-
#
|
|
347
|
-
|
|
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:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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=
|
|
722
|
+
ttl=store_ttl,
|
|
616
723
|
)
|
|
617
724
|
logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl)
|
|
618
725
|
|