fastapi-cachex 0.4.0__tar.gz → 0.4.1__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.4.0 → fastapi_cachex-0.4.1}/PKG-INFO +1 -1
- fastapi_cachex-0.4.1/fastapi_cachex/_warnings.py +25 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/base.py +20 -2
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/memcached.py +67 -28
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/memory.py +15 -7
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/redis.py +7 -2
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/cache.py +402 -66
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/lock.py +48 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/pyproject.toml +1 -1
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/pyproject.toml.orig +1 -1
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/LICENSE +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/README.md +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/__init__.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/_deprecation.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/codec.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/cache_key.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/headers.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/manager.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/routes.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/__init__.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/config.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/manager.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/middleware.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/security.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/token_serializers.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/dependencies.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/manager.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/models.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/proxy.py +0 -0
- {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Point a warning at the application's line rather than the library's (#333)."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def caller_stacklevel() -> int:
|
|
7
|
+
"""Return the ``stacklevel`` of the first frame outside fastapi_cachex.
|
|
8
|
+
|
|
9
|
+
For a ``warnings.warn`` in the function that calls this. The depth of a
|
|
10
|
+
library call varies (a backend method called directly, through
|
|
11
|
+
``CacheManager`` or through a base-class fallback), so a fixed
|
|
12
|
+
``stacklevel`` names a library file on some paths.
|
|
13
|
+
"""
|
|
14
|
+
frame = inspect.currentframe()
|
|
15
|
+
if frame is None or frame.f_back is None: # no frame support
|
|
16
|
+
return 2
|
|
17
|
+
# Level 1 is the function that warns; start at its caller.
|
|
18
|
+
level, frame = 2, frame.f_back.f_back
|
|
19
|
+
while frame is not None:
|
|
20
|
+
module = frame.f_globals.get("__name__", "")
|
|
21
|
+
if module != "fastapi_cachex" and not module.startswith("fastapi_cachex."):
|
|
22
|
+
break
|
|
23
|
+
frame = frame.f_back
|
|
24
|
+
level += 1
|
|
25
|
+
return level
|
|
@@ -8,6 +8,8 @@ from types import TracebackType
|
|
|
8
8
|
from typing import TYPE_CHECKING
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
from fastapi_cachex._warnings import caller_stacklevel
|
|
12
|
+
from fastapi_cachex.exceptions import CacheXError
|
|
11
13
|
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
12
14
|
from fastapi_cachex.types import HTTP_KEY_FORMAT_TAG
|
|
13
15
|
from fastapi_cachex.types import CacheEntry
|
|
@@ -97,6 +99,18 @@ def validate_delta(delta: int) -> int:
|
|
|
97
99
|
return delta
|
|
98
100
|
|
|
99
101
|
|
|
102
|
+
def check_counter_range(value: int) -> int:
|
|
103
|
+
"""Return ``value`` if a signed 64-bit counter can hold it.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
CacheXError: If ``value`` is outside the signed 64-bit range
|
|
107
|
+
"""
|
|
108
|
+
if not -(2**63) <= value < 2**63:
|
|
109
|
+
msg = "Counter increment would overflow a signed 64-bit integer"
|
|
110
|
+
raise CacheXError(msg)
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
100
114
|
class BaseCacheBackend(ABC):
|
|
101
115
|
"""Base class for all cache backends.
|
|
102
116
|
|
|
@@ -179,7 +193,7 @@ class BaseCacheBackend(ABC):
|
|
|
179
193
|
"it as False. See https://fastapi-cachex.readthedocs.io/en/"
|
|
180
194
|
"stable/MIGRATING_0_4/#backend-delete",
|
|
181
195
|
FutureWarning,
|
|
182
|
-
stacklevel=
|
|
196
|
+
stacklevel=caller_stacklevel(),
|
|
183
197
|
)
|
|
184
198
|
return True
|
|
185
199
|
return bool(result)
|
|
@@ -340,7 +354,10 @@ class BaseCacheBackend(ABC):
|
|
|
340
354
|
The counter value after the increment
|
|
341
355
|
|
|
342
356
|
Raises:
|
|
343
|
-
CacheXError: If ``key`` holds a cached response instead of a
|
|
357
|
+
CacheXError: If ``key`` holds a cached response instead of a
|
|
358
|
+
counter, or the result would leave the counter's range: signed
|
|
359
|
+
64-bit, except on Memcached, whose counters are unsigned 64-bit.
|
|
360
|
+
The counter is left unchanged.
|
|
344
361
|
TypeError: If ``delta`` or ``ttl`` is not an ``int``
|
|
345
362
|
ValueError: If ``ttl`` is out of range, or ``delta`` does not fit
|
|
346
363
|
in a signed 64-bit integer
|
|
@@ -349,6 +366,7 @@ class BaseCacheBackend(ABC):
|
|
|
349
366
|
validate_ttl(ttl)
|
|
350
367
|
current = await self.get(key)
|
|
351
368
|
value = delta if current is None else counter_value(current) + delta
|
|
369
|
+
check_counter_range(value)
|
|
352
370
|
await self.set(key, counter_entry(value), ttl=ttl)
|
|
353
371
|
return value
|
|
354
372
|
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import hashlib
|
|
5
|
-
import inspect
|
|
6
5
|
import logging
|
|
7
6
|
import time
|
|
8
7
|
import warnings
|
|
9
8
|
from collections.abc import Iterable
|
|
10
9
|
|
|
10
|
+
from fastapi_cachex._warnings import caller_stacklevel
|
|
11
11
|
from fastapi_cachex.backends.codec import decode_entry
|
|
12
12
|
from fastapi_cachex.backends.codec import encode_entry
|
|
13
13
|
from fastapi_cachex.exceptions import CacheXError
|
|
@@ -27,6 +27,41 @@ DEFAULT_MEMCACHE_PREFIX = "fastapi_cachex:"
|
|
|
27
27
|
# the printable ASCII range with the space removed.
|
|
28
28
|
_MAX_KEY_BYTES = 250
|
|
29
29
|
_LEGAL_KEY_BYTES = frozenset(range(0x21, 0x7F))
|
|
30
|
+
# A key Memcached refuses becomes the prefix plus a 64-character digest, so a
|
|
31
|
+
# longer prefix leaves even the hashed key too long.
|
|
32
|
+
_MAX_PREFIX_BYTES = _MAX_KEY_BYTES - 64
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate_key_prefix(key_prefix: str) -> None:
|
|
36
|
+
"""Reject a ``key_prefix`` that would make keys Memcached refuses (#239).
|
|
37
|
+
|
|
38
|
+
The prefix stays in front of the digest of a hashed key, so a prefix with
|
|
39
|
+
whitespace, control characters or non-ASCII, or one that leaves no room
|
|
40
|
+
for a key, makes every call raise ``MemcacheIllegalInputError``. A prefix
|
|
41
|
+
that leaves room for short keys but not for a digest works until the
|
|
42
|
+
first key that needs hashing, so it warns instead of raising.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If no key with this prefix is legal
|
|
46
|
+
"""
|
|
47
|
+
encoded = key_prefix.encode("utf-8")
|
|
48
|
+
if not _LEGAL_KEY_BYTES.issuperset(encoded):
|
|
49
|
+
msg = (
|
|
50
|
+
"MemcachedBackend key_prefix must be printable ASCII without "
|
|
51
|
+
f"whitespace, got {key_prefix!r}"
|
|
52
|
+
)
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
if len(encoded) >= _MAX_KEY_BYTES:
|
|
55
|
+
msg = f"MemcachedBackend key_prefix must be shorter than {_MAX_KEY_BYTES} bytes"
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
if len(encoded) > _MAX_PREFIX_BYTES:
|
|
58
|
+
warnings.warn(
|
|
59
|
+
f"MemcachedBackend key_prefix is {len(encoded)} bytes; keys that "
|
|
60
|
+
f"need hashing fail above {_MAX_PREFIX_BYTES} bytes",
|
|
61
|
+
UserWarning,
|
|
62
|
+
stacklevel=caller_stacklevel(),
|
|
63
|
+
)
|
|
64
|
+
|
|
30
65
|
|
|
31
66
|
# An exptime above 30 days is read by Memcached as an absolute Unix timestamp,
|
|
32
67
|
# not as a duration, so a longer TTL has to be converted before it is sent.
|
|
@@ -69,26 +104,6 @@ def _expiry(ttl: int | None) -> int:
|
|
|
69
104
|
return ttl
|
|
70
105
|
|
|
71
106
|
|
|
72
|
-
def _caller_stacklevel() -> int:
|
|
73
|
-
"""Return the ``stacklevel`` of the first frame outside fastapi_cachex.
|
|
74
|
-
|
|
75
|
-
For a ``warnings.warn`` in the method that calls this, so a warning raised
|
|
76
|
-
through ``CacheManager`` names the application's line, not manager.py.
|
|
77
|
-
"""
|
|
78
|
-
frame = inspect.currentframe()
|
|
79
|
-
if frame is None or frame.f_back is None: # no frame support
|
|
80
|
-
return 2
|
|
81
|
-
# Level 1 is the method that warns; start at its caller.
|
|
82
|
-
level, frame = 2, frame.f_back.f_back
|
|
83
|
-
while frame is not None:
|
|
84
|
-
module = frame.f_globals.get("__name__", "")
|
|
85
|
-
if module != "fastapi_cachex" and not module.startswith("fastapi_cachex."):
|
|
86
|
-
break
|
|
87
|
-
frame = frame.f_back
|
|
88
|
-
level += 1
|
|
89
|
-
return level
|
|
90
|
-
|
|
91
|
-
|
|
92
107
|
class MemcachedBackend(BaseCacheBackend):
|
|
93
108
|
"""Memcached backend implementation.
|
|
94
109
|
|
|
@@ -121,7 +136,10 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
121
136
|
|
|
122
137
|
Raises:
|
|
123
138
|
CacheXError: If pymemcache is not installed
|
|
139
|
+
ValueError: If ``key_prefix`` holds whitespace, control
|
|
140
|
+
characters or non-ASCII, or is 250 bytes or longer
|
|
124
141
|
"""
|
|
142
|
+
_validate_key_prefix(key_prefix)
|
|
125
143
|
try:
|
|
126
144
|
from pymemcache import HashClient
|
|
127
145
|
except ImportError as exc:
|
|
@@ -376,12 +394,32 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
376
394
|
return bool(stored)
|
|
377
395
|
|
|
378
396
|
def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
|
|
379
|
-
"""Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist.
|
|
397
|
+
"""Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist.
|
|
398
|
+
|
|
399
|
+
Memcached's INCR wraps around past 2**64 - 1. A wrapped result is
|
|
400
|
+
smaller than ``delta``, which no other result can be, so the wrap is
|
|
401
|
+
undone with an INCR of ``2**64 - delta`` (addition modulo 2**64 also
|
|
402
|
+
keeps any increment that landed in between) and reported. The two
|
|
403
|
+
INCRs are not atomic: a concurrent INCR between them can return the
|
|
404
|
+
wrapped value, and a DECR that clamps at 0 between them makes the undo
|
|
405
|
+
land on a different value. Making it exact would take a CAS loop on
|
|
406
|
+
every increment.
|
|
407
|
+
|
|
408
|
+
Raises:
|
|
409
|
+
CacheXError: If the INCR wrapped the counter around.
|
|
410
|
+
"""
|
|
380
411
|
if delta < 0:
|
|
381
412
|
result = self.client.decr(prefixed_key, -delta, noreply=False)
|
|
382
413
|
else:
|
|
383
414
|
result = self.client.incr(prefixed_key, delta, noreply=False)
|
|
384
|
-
|
|
415
|
+
if result is None:
|
|
416
|
+
return None
|
|
417
|
+
value = int(result)
|
|
418
|
+
if delta > 0 and value < delta:
|
|
419
|
+
self.client.incr(prefixed_key, 2**64 - delta, noreply=False)
|
|
420
|
+
msg = "Counter increment would overflow an unsigned 64-bit integer"
|
|
421
|
+
raise CacheXError(msg)
|
|
422
|
+
return value
|
|
385
423
|
|
|
386
424
|
def _increment(self, prefixed_key: str, delta: int, exptime: int) -> int | None:
|
|
387
425
|
"""Run ``increment``'s INCR, ADD and retried INCR in one worker thread.
|
|
@@ -418,7 +456,8 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
418
456
|
pair is retried, starting a new window at ``delta``.
|
|
419
457
|
|
|
420
458
|
Raises:
|
|
421
|
-
CacheXError: If the key holds a value that is not a counter,
|
|
459
|
+
CacheXError: If the key holds a value that is not a counter, the
|
|
460
|
+
result would pass 2**64 - 1 (the counter is restored), or
|
|
422
461
|
the counter vanished after every ADD + INCR attempt.
|
|
423
462
|
"""
|
|
424
463
|
validate_delta(delta)
|
|
@@ -497,7 +536,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
497
536
|
"this namespace cannot be cleared on its own; delete known keys "
|
|
498
537
|
"with delete() or delete_many() instead.",
|
|
499
538
|
RuntimeWarning,
|
|
500
|
-
stacklevel=
|
|
539
|
+
stacklevel=caller_stacklevel(),
|
|
501
540
|
)
|
|
502
541
|
await asyncio.to_thread(self.client.flush_all)
|
|
503
542
|
logger.debug("Memcached CLEAR; flush_all issued")
|
|
@@ -527,7 +566,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
527
566
|
"exactly as the path, and include_params has no effect. Use "
|
|
528
567
|
"invalidate(request) to drop a cached route's entry.",
|
|
529
568
|
RuntimeWarning,
|
|
530
|
-
stacklevel=
|
|
569
|
+
stacklevel=caller_stacklevel(),
|
|
531
570
|
)
|
|
532
571
|
|
|
533
572
|
# Try to delete the prefixed key (exact match only)
|
|
@@ -558,7 +597,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
558
597
|
"Consider using Redis backend for pattern support, "
|
|
559
598
|
"or track keys manually in your application logic.",
|
|
560
599
|
RuntimeWarning,
|
|
561
|
-
stacklevel=
|
|
600
|
+
stacklevel=caller_stacklevel(),
|
|
562
601
|
)
|
|
563
602
|
logger.debug("Memcached CLEAR_PATTERN unsupported; pattern=%s", pattern)
|
|
564
603
|
return 0
|
|
@@ -580,7 +619,7 @@ class MemcachedBackend(BaseCacheBackend):
|
|
|
580
619
|
"Consider using Redis backend if you need cache monitoring, "
|
|
581
620
|
"or track keys manually in your application.",
|
|
582
621
|
RuntimeWarning,
|
|
583
|
-
stacklevel=
|
|
622
|
+
stacklevel=caller_stacklevel(),
|
|
584
623
|
)
|
|
585
624
|
logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
|
|
586
625
|
return []
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""In-memory cache backend implementation."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import copy
|
|
4
5
|
import fnmatch
|
|
5
6
|
import logging
|
|
6
7
|
import time
|
|
@@ -14,6 +15,7 @@ from fastapi_cachex.types import counter_entry
|
|
|
14
15
|
from fastapi_cachex.types import counter_value
|
|
15
16
|
|
|
16
17
|
from .base import BaseCacheBackend
|
|
18
|
+
from .base import check_counter_range
|
|
17
19
|
from .base import validate_delta
|
|
18
20
|
from .base import validate_ttl
|
|
19
21
|
from .base import warn_if_path_shaped
|
|
@@ -143,7 +145,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
143
145
|
logger.debug("Memory cache EXPIRED; key=%s removed", key)
|
|
144
146
|
return None
|
|
145
147
|
logger.debug("Memory cache HIT; key=%s", key)
|
|
146
|
-
return cached_item.value
|
|
148
|
+
return copy.copy(cached_item.value)
|
|
147
149
|
|
|
148
150
|
async def set(self, key: str, value: CacheEntry, ttl: int | None = None) -> None:
|
|
149
151
|
"""Store a response in the cache.
|
|
@@ -163,7 +165,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
163
165
|
|
|
164
166
|
async with self.lock:
|
|
165
167
|
expiry = time.time() + ttl if ttl is not None else None
|
|
166
|
-
self.cache[key] = CacheItem(value=value, expiry=expiry)
|
|
168
|
+
self.cache[key] = CacheItem(value=copy.copy(value), expiry=expiry)
|
|
167
169
|
logger.debug("Memory cache SET; key=%s ttl=%s", key, ttl)
|
|
168
170
|
|
|
169
171
|
async def delete(self, key: str) -> bool:
|
|
@@ -209,7 +211,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
209
211
|
logger.debug("Memory cache SET_IF_ABSENT EXISTS; key=%s", key)
|
|
210
212
|
return False
|
|
211
213
|
expiry = now + ttl if ttl is not None else None
|
|
212
|
-
self.cache[key] = CacheItem(value=value, expiry=expiry)
|
|
214
|
+
self.cache[key] = CacheItem(value=copy.copy(value), expiry=expiry)
|
|
213
215
|
logger.debug("Memory cache SET_IF_ABSENT STORED; key=%s ttl=%s", key, ttl)
|
|
214
216
|
return True
|
|
215
217
|
|
|
@@ -261,7 +263,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
261
263
|
logger.debug("Memory cache SET_IF_EQUALS MISMATCH; key=%s", key)
|
|
262
264
|
return False
|
|
263
265
|
expiry = now + ttl if ttl is not None else None
|
|
264
|
-
self.cache[key] = CacheItem(value=value, expiry=expiry)
|
|
266
|
+
self.cache[key] = CacheItem(value=copy.copy(value), expiry=expiry)
|
|
265
267
|
logger.debug("Memory cache SET_IF_EQUALS HIT; key=%s ttl=%s", key, ttl)
|
|
266
268
|
return True
|
|
267
269
|
|
|
@@ -269,7 +271,13 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
269
271
|
"""Atomically add ``delta`` to the counter at ``key`` (see base class).
|
|
270
272
|
|
|
271
273
|
The read-modify-write happens under the backend lock, so concurrent
|
|
272
|
-
callers on the same event loop never lose an increment.
|
|
274
|
+
callers on the same event loop never lose an increment. Counters are
|
|
275
|
+
signed 64-bit, as on Redis.
|
|
276
|
+
|
|
277
|
+
Raises:
|
|
278
|
+
CacheXError: If the key holds a value that is not a counter, or
|
|
279
|
+
the result would leave the signed 64-bit range (the counter
|
|
280
|
+
is left unchanged).
|
|
273
281
|
"""
|
|
274
282
|
validate_delta(delta)
|
|
275
283
|
validate_ttl(ttl)
|
|
@@ -279,7 +287,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
279
287
|
now = time.time()
|
|
280
288
|
item = self.cache.get(key)
|
|
281
289
|
if item is not None and _is_live(item, now):
|
|
282
|
-
value = counter_value(item.value) + delta
|
|
290
|
+
value = check_counter_range(counter_value(item.value) + delta)
|
|
283
291
|
item.value = counter_entry(value)
|
|
284
292
|
else:
|
|
285
293
|
value = delta
|
|
@@ -378,7 +386,7 @@ class MemoryBackend(BaseCacheBackend):
|
|
|
378
386
|
async with self.lock:
|
|
379
387
|
now = time.time()
|
|
380
388
|
return {
|
|
381
|
-
key: (item.value, item.expiry)
|
|
389
|
+
key: (copy.copy(item.value), item.expiry)
|
|
382
390
|
for key, item in self.cache.items()
|
|
383
391
|
if _is_live(item, now)
|
|
384
392
|
}
|
|
@@ -35,13 +35,15 @@ _BATCH_SIZE = 100
|
|
|
35
35
|
|
|
36
36
|
# INCRBY that attaches a TTL only when it creates the key, so a counter lives in
|
|
37
37
|
# a fixed window. KEYS[1] = key, ARGV[1] = delta, ARGV[2] = ttl (0 = none).
|
|
38
|
+
# The value is returned as the stored string: Lua turns an integer reply into
|
|
39
|
+
# a double, which rounds anything past 2**53 (#364).
|
|
38
40
|
_INCREMENT_SCRIPT = """
|
|
39
41
|
local created = redis.call('EXISTS', KEYS[1]) == 0
|
|
40
|
-
|
|
42
|
+
redis.call('INCRBY', KEYS[1], ARGV[1])
|
|
41
43
|
if created and tonumber(ARGV[2]) > 0 then
|
|
42
44
|
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
|
43
45
|
end
|
|
44
|
-
return
|
|
46
|
+
return redis.call('GET', KEYS[1])
|
|
45
47
|
"""
|
|
46
48
|
|
|
47
49
|
# DEL that only fires while the key still holds the exact bytes the caller read,
|
|
@@ -436,6 +438,9 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
|
|
|
436
438
|
keys=[self._make_key(key)], args=[delta, ttl or 0]
|
|
437
439
|
)
|
|
438
440
|
except ResponseError as e:
|
|
441
|
+
if "would overflow" in str(e):
|
|
442
|
+
msg = "Counter increment would overflow a signed 64-bit integer"
|
|
443
|
+
raise CacheXError(msg) from e
|
|
439
444
|
if "not an integer" not in str(e):
|
|
440
445
|
raise
|
|
441
446
|
msg = "Cache key holds a value that is not a counter"
|
|
@@ -6,6 +6,7 @@ import logging
|
|
|
6
6
|
import threading
|
|
7
7
|
import time
|
|
8
8
|
import warnings
|
|
9
|
+
from collections import Counter
|
|
9
10
|
from collections.abc import Awaitable
|
|
10
11
|
from collections.abc import Callable
|
|
11
12
|
from collections.abc import Iterable
|
|
@@ -28,6 +29,7 @@ from typing import get_type_hints
|
|
|
28
29
|
from fastapi import Request
|
|
29
30
|
from fastapi import Response
|
|
30
31
|
from fastapi.encoders import jsonable_encoder
|
|
32
|
+
from fastapi.params import Depends as DependsParam
|
|
31
33
|
from fastapi.utils import is_body_allowed_for_status_code
|
|
32
34
|
from pydantic import TypeAdapter
|
|
33
35
|
from starlette.concurrency import run_in_threadpool
|
|
@@ -259,6 +261,14 @@ _COOKIE_VARY_WARNING = (
|
|
|
259
261
|
)
|
|
260
262
|
|
|
261
263
|
|
|
264
|
+
def _no_store_ignored_warning(ignored: list[str]) -> str:
|
|
265
|
+
return (
|
|
266
|
+
f"cache no_store ignores {', '.join(ignored)}: @cache(no_store=True) "
|
|
267
|
+
"stores nothing and sends only Cache-Control: no-store, so the other "
|
|
268
|
+
"arguments have no effect. Remove them, or drop no_store."
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
262
272
|
# How the one-time bypass warning names each `_request_credential` result.
|
|
263
273
|
_CREDENTIAL_DESCRIPTIONS = {
|
|
264
274
|
"Authorization header": "an Authorization header",
|
|
@@ -653,15 +663,52 @@ def _is_request_annotation(annotation: Any) -> bool:
|
|
|
653
663
|
return isinstance(annotation, type) and issubclass(annotation, Request)
|
|
654
664
|
|
|
655
665
|
|
|
666
|
+
def _is_response_annotation(annotation: Any) -> bool:
|
|
667
|
+
"""Whether an annotation asks for the sub-``Response`` FastAPI injects.
|
|
668
|
+
|
|
669
|
+
``Annotated[Response, Depends(...)]`` is a dependency that returns a
|
|
670
|
+
``Response``, not the sub-response.
|
|
671
|
+
"""
|
|
672
|
+
if get_origin(annotation) is Annotated:
|
|
673
|
+
annotation, *metadata = get_args(annotation)
|
|
674
|
+
if any(isinstance(item, DependsParam) for item in metadata):
|
|
675
|
+
return False
|
|
676
|
+
return isinstance(annotation, type) and issubclass(annotation, Response)
|
|
677
|
+
|
|
678
|
+
|
|
656
679
|
def _find_request_param(
|
|
657
680
|
func: HandlerCallable, params: list[Parameter]
|
|
658
681
|
) -> Parameter | None:
|
|
659
|
-
"""The handler's own ``Request`` parameter, if it declares one.
|
|
682
|
+
"""The handler's own ``Request`` parameter, if it declares one (see ``_find_param``)."""
|
|
683
|
+
return _find_param(func, params, _is_request_annotation)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _find_response_param(
|
|
687
|
+
func: HandlerCallable, params: list[Parameter]
|
|
688
|
+
) -> Parameter | None:
|
|
689
|
+
"""The handler's own ``Response`` parameter, if it declares one (see ``_find_param``).
|
|
690
|
+
|
|
691
|
+
A parameter defaulting to ``Depends(...)`` is a dependency's result, not
|
|
692
|
+
the sub-response, whatever its annotation.
|
|
693
|
+
"""
|
|
694
|
+
return _find_param(
|
|
695
|
+
func,
|
|
696
|
+
[param for param in params if not isinstance(param.default, DependsParam)],
|
|
697
|
+
_is_response_annotation,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _find_param(
|
|
702
|
+
func: HandlerCallable,
|
|
703
|
+
params: list[Parameter],
|
|
704
|
+
matches: Callable[[Any], bool],
|
|
705
|
+
) -> Parameter | None:
|
|
706
|
+
"""The handler's first parameter whose annotation ``matches``.
|
|
660
707
|
|
|
661
708
|
Annotations are resolved first, so a handler under ``from __future__ import
|
|
662
709
|
annotations`` (where the annotation is the string ``"Request"``),
|
|
663
710
|
``Annotated[Request, ...]``, or a ``Request`` subclass is recognised
|
|
664
|
-
instead of being given a second, unused
|
|
711
|
+
instead of being given a second, unused parameter. Resolution can
|
|
665
712
|
fail on a forward reference that does not resolve in the handler's module,
|
|
666
713
|
which must not break decoration: the raw annotations are used instead.
|
|
667
714
|
"""
|
|
@@ -671,15 +718,107 @@ def _find_request_param(
|
|
|
671
718
|
hints = {}
|
|
672
719
|
|
|
673
720
|
return next(
|
|
674
|
-
(
|
|
675
|
-
param
|
|
676
|
-
for param in params
|
|
677
|
-
if _is_request_annotation(hints.get(param.name, param.annotation))
|
|
678
|
-
),
|
|
721
|
+
(param for param in params if matches(hints.get(param.name, param.annotation))),
|
|
679
722
|
None,
|
|
680
723
|
)
|
|
681
724
|
|
|
682
725
|
|
|
726
|
+
def _split_header_lines(
|
|
727
|
+
lines: Iterable[tuple[bytes, bytes]], before: Iterable[tuple[bytes, bytes]]
|
|
728
|
+
) -> tuple[list[tuple[bytes, bytes]], list[tuple[bytes, bytes]]]:
|
|
729
|
+
"""Split a sub-response's header lines into those of ``before`` and the rest.
|
|
730
|
+
|
|
731
|
+
FastAPI resolves the dependencies before it calls the handler, so the
|
|
732
|
+
sub-response's lines when the wrapper starts (``before``) are the
|
|
733
|
+
dependencies' and the lines added since are the handler's. Lines are
|
|
734
|
+
matched as a multiset, so a repeated line is counted once per copy.
|
|
735
|
+
"""
|
|
736
|
+
remaining = Counter(before)
|
|
737
|
+
kept: list[tuple[bytes, bytes]] = []
|
|
738
|
+
added: list[tuple[bytes, bytes]] = []
|
|
739
|
+
for line in lines:
|
|
740
|
+
if remaining[line] > 0:
|
|
741
|
+
remaining[line] -= 1
|
|
742
|
+
kept.append(line)
|
|
743
|
+
else:
|
|
744
|
+
added.append(line)
|
|
745
|
+
return kept, added
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _sets_cookie(lines: Iterable[tuple[bytes, bytes]]) -> bool:
|
|
749
|
+
return any(name.lower() == b"set-cookie" for name, _ in lines)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _cache_control_values(lines: Iterable[tuple[bytes, bytes]]) -> list[str]:
|
|
753
|
+
return [
|
|
754
|
+
value.decode("latin-1")
|
|
755
|
+
for name, value in lines
|
|
756
|
+
if name.lower() == b"cache-control"
|
|
757
|
+
]
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _dependency_unshareable_reason(
|
|
761
|
+
dependency_lines: Iterable[tuple[bytes, bytes]],
|
|
762
|
+
) -> str | None:
|
|
763
|
+
"""Why the dependencies' lines keep a response out of the backend, if they do."""
|
|
764
|
+
lines = list(dependency_lines)
|
|
765
|
+
if _has_unshareable_directive(_cache_control_values(lines)):
|
|
766
|
+
return "a dependency's Cache-Control is private or no-store"
|
|
767
|
+
if _sets_cookie(lines):
|
|
768
|
+
return "a dependency sets a cookie"
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _with_dependency_headers(
|
|
773
|
+
response: Response,
|
|
774
|
+
sub_response: Response | None,
|
|
775
|
+
dependency_lines: Sequence[tuple[bytes, bytes]],
|
|
776
|
+
private_cache_control: str,
|
|
777
|
+
*,
|
|
778
|
+
cacheable_get: bool,
|
|
779
|
+
) -> Response:
|
|
780
|
+
"""Add the header lines the dependencies set on this request (#233).
|
|
781
|
+
|
|
782
|
+
FastAPI merges the sub-response into the response only when the handler
|
|
783
|
+
returns plain data, and the wrapper always returns a ``Response``, so it
|
|
784
|
+
merges them itself: on a miss, a hit and a 304 alike, with the values of
|
|
785
|
+
this request rather than those stored with the entry.
|
|
786
|
+
|
|
787
|
+
On a cacheable GET response, ``Cache-Control`` is decided as for the
|
|
788
|
+
handler's own lines: a handler's ``private`` or ``no-store`` header (or
|
|
789
|
+
``no_store=True``) is kept; otherwise a dependency's ``private`` or
|
|
790
|
+
``no-store`` header replaces the decorator's, and a ``Set-Cookie`` makes it
|
|
791
|
+
``private``. Any header the response already carries (other than
|
|
792
|
+
``Set-Cookie``) is not added, the decorator's ``Cache-Control`` included:
|
|
793
|
+
the handler set it over the dependency's, which on a hit is the stored
|
|
794
|
+
value.
|
|
795
|
+
Any other response gets every line, as FastAPI would send them.
|
|
796
|
+
"""
|
|
797
|
+
if sub_response is None or not dependency_lines:
|
|
798
|
+
return response
|
|
799
|
+
current, _ = _split_header_lines(sub_response.headers.raw, dependency_lines)
|
|
800
|
+
if not cacheable_get or not (
|
|
801
|
+
response.status_code == HTTP_304_NOT_MODIFIED
|
|
802
|
+
or _is_cacheable_status(response.status_code)
|
|
803
|
+
):
|
|
804
|
+
response.headers.raw.extend(current)
|
|
805
|
+
return response
|
|
806
|
+
own = {name.lower() for name, _ in response.headers.raw}
|
|
807
|
+
response.headers.raw.extend(
|
|
808
|
+
(name, value)
|
|
809
|
+
for name, value in current
|
|
810
|
+
if name.lower() == b"set-cookie" or name.lower() not in own
|
|
811
|
+
)
|
|
812
|
+
if _marked_unshareable(response):
|
|
813
|
+
return response
|
|
814
|
+
dependency_cache_control = _cache_control_values(current)
|
|
815
|
+
if _has_unshareable_directive(dependency_cache_control):
|
|
816
|
+
response.headers["Cache-Control"] = ", ".join(dependency_cache_control)
|
|
817
|
+
elif _sets_cookie(current):
|
|
818
|
+
response.headers["Cache-Control"] = private_cache_control
|
|
819
|
+
return response
|
|
820
|
+
|
|
821
|
+
|
|
683
822
|
def _get_response_body(response: Response) -> bytes | None:
|
|
684
823
|
"""Return response body bytes, or None for streaming/file responses."""
|
|
685
824
|
return getattr(response, "body", None)
|
|
@@ -739,11 +878,34 @@ def _not_modified(
|
|
|
739
878
|
response = Response(status_code=HTTP_304_NOT_MODIFIED)
|
|
740
879
|
_append_headers(response, _revalidation_headers(headers))
|
|
741
880
|
response.headers["ETag"] = etag
|
|
742
|
-
|
|
881
|
+
if cache_control:
|
|
882
|
+
response.headers["Cache-Control"] = cache_control
|
|
743
883
|
response.headers.update(age or {})
|
|
744
884
|
return response
|
|
745
885
|
|
|
746
886
|
|
|
887
|
+
def _fresh_not_modified(
|
|
888
|
+
response: Response, etag: str, client_etag: str | None, cache_control: str
|
|
889
|
+
) -> Response | None:
|
|
890
|
+
"""The 304 for a response the handler just rendered, or None to send it.
|
|
891
|
+
|
|
892
|
+
None unless ``client_etag`` matches. The handler ran, so what it did
|
|
893
|
+
beyond the body still reaches the client: its ``Set-Cookie`` lines are
|
|
894
|
+
repeated on the 304 (such a response is never stored, and
|
|
895
|
+
``_cache_control_for`` marks it ``private``), and its ``background`` task
|
|
896
|
+
moves onto the 304 (#233).
|
|
897
|
+
"""
|
|
898
|
+
if not _etag_matches(client_etag, etag):
|
|
899
|
+
return None
|
|
900
|
+
not_modified = _not_modified(etag, cache_control, response.headers.items())
|
|
901
|
+
_append_headers(
|
|
902
|
+
not_modified,
|
|
903
|
+
[(k, v) for k, v in response.headers.items() if k.lower() == "set-cookie"],
|
|
904
|
+
)
|
|
905
|
+
not_modified.background = response.background
|
|
906
|
+
return not_modified
|
|
907
|
+
|
|
908
|
+
|
|
747
909
|
# Response directives by which the handler says its response belongs to one
|
|
748
910
|
# caller (``private``) or must not be kept at all (``no-store``).
|
|
749
911
|
_UNSHAREABLE_DIRECTIVES = frozenset(
|
|
@@ -757,9 +919,14 @@ def _marked_unshareable(response: Response) -> bool:
|
|
|
757
919
|
Directive names are matched as whole tokens, case-insensitively, across
|
|
758
920
|
every ``Cache-Control`` field the response carries.
|
|
759
921
|
"""
|
|
922
|
+
return _has_unshareable_directive(response.headers.getlist("cache-control"))
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def _has_unshareable_directive(cache_control: Iterable[str]) -> bool:
|
|
926
|
+
"""Whether any of these ``Cache-Control`` values has ``private`` or ``no-store``."""
|
|
760
927
|
return any(
|
|
761
928
|
directive.split("=", 1)[0].strip().lower() in _UNSHAREABLE_DIRECTIVES
|
|
762
|
-
for value in
|
|
929
|
+
for value in cache_control
|
|
763
930
|
for directive in value.split(",")
|
|
764
931
|
)
|
|
765
932
|
|
|
@@ -781,12 +948,16 @@ def _cache_control_for(
|
|
|
781
948
|
A handler that marked its response ``private`` or ``no-store`` keeps its own
|
|
782
949
|
header; the decorator's would widen what the handler allowed. A response
|
|
783
950
|
that sets a cookie gets ``private_cache_control``, so a shared cache in
|
|
784
|
-
front of the app does not store it either.
|
|
951
|
+
front of the app does not store it either. When the decorator has no
|
|
952
|
+
directive to send (a bare ``@cache()``), the handler's own header is kept,
|
|
953
|
+
or none is sent (#363); a cookie still gets ``private_cache_control``.
|
|
785
954
|
"""
|
|
786
955
|
if _marked_unshareable(response):
|
|
787
956
|
return ", ".join(response.headers.getlist("cache-control"))
|
|
788
957
|
if "set-cookie" in response.headers:
|
|
789
958
|
return private_cache_control
|
|
959
|
+
if not cache_control:
|
|
960
|
+
return ", ".join(response.headers.getlist("cache-control"))
|
|
790
961
|
return cache_control
|
|
791
962
|
|
|
792
963
|
|
|
@@ -794,17 +965,30 @@ def _with_cache_control(
|
|
|
794
965
|
response: Response, cache_control: str, private_cache_control: str
|
|
795
966
|
) -> Response:
|
|
796
967
|
if not _marked_unshareable(response):
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
968
|
+
value = _cache_control_for(response, cache_control, private_cache_control)
|
|
969
|
+
if value:
|
|
970
|
+
response.headers["Cache-Control"] = value
|
|
800
971
|
return response
|
|
801
972
|
|
|
802
973
|
|
|
803
974
|
async def _render(
|
|
804
|
-
func: HandlerCallable,
|
|
975
|
+
func: HandlerCallable,
|
|
976
|
+
request: Request,
|
|
977
|
+
args: tuple[Any, ...],
|
|
978
|
+
kwargs: dict[str, Any],
|
|
979
|
+
*,
|
|
980
|
+
sub_response: Response | None,
|
|
981
|
+
dependency_lines: Sequence[tuple[bytes, bytes]],
|
|
805
982
|
) -> tuple[Response, bytes | None, str | None]:
|
|
806
983
|
"""Run the handler; the body and ETag are None for streaming/file responses."""
|
|
807
|
-
response = await
|
|
984
|
+
response = await _respond(
|
|
985
|
+
func,
|
|
986
|
+
request,
|
|
987
|
+
args,
|
|
988
|
+
kwargs,
|
|
989
|
+
sub_response=sub_response,
|
|
990
|
+
dependency_lines=dependency_lines,
|
|
991
|
+
)
|
|
808
992
|
body = _get_response_body(response)
|
|
809
993
|
return response, body, None if body is None else _etag_for(body)
|
|
810
994
|
|
|
@@ -865,12 +1049,37 @@ async def get_response(
|
|
|
865
1049
|
|
|
866
1050
|
Coroutine handlers are awaited. Sync handlers run in the threadpool, as
|
|
867
1051
|
FastAPI would run them without the (async) cache wrapper, so blocking I/O
|
|
868
|
-
in a ``def`` handler does not stall the event loop.
|
|
1052
|
+
in a ``def`` handler does not stall the event loop. Every header line of a
|
|
1053
|
+
``Response`` among ``kwargs`` is carried over onto a plain-data result.
|
|
1054
|
+
"""
|
|
1055
|
+
sub_response = next(
|
|
1056
|
+
(value for value in kwargs.values() if isinstance(value, Response)), None
|
|
1057
|
+
)
|
|
1058
|
+
return await _respond(
|
|
1059
|
+
__func, __request, args, kwargs, sub_response=sub_response, dependency_lines=()
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
async def _respond(
|
|
1064
|
+
func: HandlerCallable,
|
|
1065
|
+
request: Request,
|
|
1066
|
+
args: tuple[Any, ...],
|
|
1067
|
+
kwargs: dict[str, Any],
|
|
1068
|
+
*,
|
|
1069
|
+
sub_response: Response | None,
|
|
1070
|
+
dependency_lines: Sequence[tuple[bytes, bytes]],
|
|
1071
|
+
) -> Response:
|
|
1072
|
+
"""Run the handler and build its response, as ``get_response`` describes.
|
|
1073
|
+
|
|
1074
|
+
Only the lines added to ``sub_response`` beyond ``dependency_lines`` are
|
|
1075
|
+
carried over here: the dependencies' own lines are added to every
|
|
1076
|
+
response the wrapper sends (``_with_dependency_headers``), and must not be
|
|
1077
|
+
stored with the entry.
|
|
869
1078
|
"""
|
|
870
|
-
if _is_coroutine_callable(
|
|
871
|
-
result = await cast("Callable[..., Awaitable[object]]",
|
|
1079
|
+
if _is_coroutine_callable(func):
|
|
1080
|
+
result = await cast("Callable[..., Awaitable[object]]", func)(*args, **kwargs)
|
|
872
1081
|
else:
|
|
873
|
-
result = await run_in_threadpool(
|
|
1082
|
+
result = await run_in_threadpool(func, *args, **kwargs)
|
|
874
1083
|
# A sync callable can still hand back an awaitable (a lambda wrapping a
|
|
875
1084
|
# coroutine function, say); await it rather than try to encode it.
|
|
876
1085
|
if inspect.isawaitable(result):
|
|
@@ -881,7 +1090,7 @@ async def get_response(
|
|
|
881
1090
|
return result
|
|
882
1091
|
|
|
883
1092
|
# Get response_class from route if available
|
|
884
|
-
route: APIRoute | None =
|
|
1093
|
+
route: APIRoute | None = request.scope.get("route")
|
|
885
1094
|
if route is None:
|
|
886
1095
|
msg = "Route not found in request scope"
|
|
887
1096
|
raise CacheXError(msg)
|
|
@@ -889,7 +1098,7 @@ async def get_response(
|
|
|
889
1098
|
# A placeholder means this route uses the application default. Unwrap the
|
|
890
1099
|
# application value instead of calling the route's DefaultPlaceholder.
|
|
891
1100
|
route_response_class = route.response_class
|
|
892
|
-
application_default_response_class =
|
|
1101
|
+
application_default_response_class = request.app.router.default_response_class
|
|
893
1102
|
response_class: type[Response] = cast(
|
|
894
1103
|
"type[Response]",
|
|
895
1104
|
(
|
|
@@ -905,10 +1114,7 @@ async def get_response(
|
|
|
905
1114
|
|
|
906
1115
|
# Build the response the way FastAPI would have without the cache wrapper:
|
|
907
1116
|
# serialize through the response model, apply the route's status code and
|
|
908
|
-
# carry over what the handler set on
|
|
909
|
-
sub_response = next(
|
|
910
|
-
(value for value in kwargs.values() if isinstance(value, Response)), None
|
|
911
|
-
)
|
|
1117
|
+
# carry over what the handler set on the injected `response: Response`.
|
|
912
1118
|
status_code = route.status_code
|
|
913
1119
|
if sub_response is not None and sub_response.status_code:
|
|
914
1120
|
status_code = sub_response.status_code
|
|
@@ -920,7 +1126,8 @@ async def get_response(
|
|
|
920
1126
|
if not is_body_allowed_for_status_code(response.status_code):
|
|
921
1127
|
response.body = b""
|
|
922
1128
|
if sub_response is not None:
|
|
923
|
-
|
|
1129
|
+
_, added = _split_header_lines(sub_response.headers.raw, dependency_lines)
|
|
1130
|
+
response.headers.raw.extend(added)
|
|
924
1131
|
return response
|
|
925
1132
|
|
|
926
1133
|
|
|
@@ -1062,6 +1269,17 @@ def cache(
|
|
|
1062
1269
|
``key_builder``, or if ``key_builder`` is an ``async`` callable.
|
|
1063
1270
|
At request time, if
|
|
1064
1271
|
``key_builder`` returns anything but a ``str``.
|
|
1272
|
+
|
|
1273
|
+
Without ``ttl`` or any directive (a bare ``@cache()``), nothing is stored
|
|
1274
|
+
and the decorator has no ``Cache-Control`` of its own: it adds an ETag,
|
|
1275
|
+
answers a matching ``If-None-Match`` with 304 and keeps the handler's own
|
|
1276
|
+
``Cache-Control``, except that a response setting a cookie or answering a
|
|
1277
|
+
request with credentials is still sent with ``private``.
|
|
1278
|
+
|
|
1279
|
+
Warns:
|
|
1280
|
+
UserWarning: When the decorator is applied, if ``vary`` lists
|
|
1281
|
+
``Cookie``, or if ``no_store`` is combined with another caching
|
|
1282
|
+
argument it overrides.
|
|
1065
1283
|
"""
|
|
1066
1284
|
|
|
1067
1285
|
def decorator(func: HandlerCallable) -> AsyncResponseCallable:
|
|
@@ -1092,6 +1310,27 @@ def cache(
|
|
|
1092
1310
|
# stacklevel=2: the caller applying the decorator, i.e. the line
|
|
1093
1311
|
# of the user's @cache(...).
|
|
1094
1312
|
warnings.warn(_COOKIE_VARY_WARNING, UserWarning, stacklevel=2)
|
|
1313
|
+
# Arguments no_store overrides are accepted but warned about (#328);
|
|
1314
|
+
# rejecting them would break routes that work today.
|
|
1315
|
+
if no_store:
|
|
1316
|
+
ignored = [
|
|
1317
|
+
name
|
|
1318
|
+
for name, value in (
|
|
1319
|
+
("ttl", ttl),
|
|
1320
|
+
("stale", stale),
|
|
1321
|
+
("stale_ttl", stale_ttl),
|
|
1322
|
+
("no_cache", no_cache),
|
|
1323
|
+
("public", public),
|
|
1324
|
+
("private", private),
|
|
1325
|
+
("immutable", immutable),
|
|
1326
|
+
("must_revalidate", must_revalidate),
|
|
1327
|
+
)
|
|
1328
|
+
if value is not None and value is not False
|
|
1329
|
+
]
|
|
1330
|
+
if ignored:
|
|
1331
|
+
warnings.warn(
|
|
1332
|
+
_no_store_ignored_warning(ignored), UserWarning, stacklevel=2
|
|
1333
|
+
)
|
|
1095
1334
|
|
|
1096
1335
|
# Analyze the original function's signature
|
|
1097
1336
|
sig: Signature = inspect.signature(func)
|
|
@@ -1100,16 +1339,32 @@ def cache(
|
|
|
1100
1339
|
# Check if Request is already in the parameters
|
|
1101
1340
|
found_request: Parameter | None = _find_request_param(func, params)
|
|
1102
1341
|
|
|
1103
|
-
#
|
|
1342
|
+
# FastAPI's sub-response: the wrapper needs it even when the handler
|
|
1343
|
+
# does not ask for it, to send what the dependencies set on it (#233).
|
|
1344
|
+
found_response: Parameter | None = _find_response_param(func, params)
|
|
1345
|
+
|
|
1346
|
+
# Add the Request and Response parameters the handler does not declare
|
|
1347
|
+
injected: list[Parameter] = []
|
|
1104
1348
|
if not found_request:
|
|
1105
1349
|
request_name: str = "__cachex_request"
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1350
|
+
injected.append(
|
|
1351
|
+
inspect.Parameter(
|
|
1352
|
+
request_name, inspect.Parameter.KEYWORD_ONLY, annotation=Request
|
|
1353
|
+
)
|
|
1354
|
+
)
|
|
1355
|
+
else:
|
|
1356
|
+
request_name = found_request.name
|
|
1357
|
+
if not found_response:
|
|
1358
|
+
response_name: str = "__cachex_response"
|
|
1359
|
+
injected.append(
|
|
1360
|
+
inspect.Parameter(
|
|
1361
|
+
response_name, inspect.Parameter.KEYWORD_ONLY, annotation=Response
|
|
1362
|
+
)
|
|
1111
1363
|
)
|
|
1364
|
+
else:
|
|
1365
|
+
response_name = found_response.name
|
|
1112
1366
|
|
|
1367
|
+
if injected:
|
|
1113
1368
|
# A keyword-only parameter must precede **kwargs; appending it
|
|
1114
1369
|
# after one makes `Signature.replace` raise at decoration time,
|
|
1115
1370
|
# so a handler taking **kwargs could not be cached at all.
|
|
@@ -1122,16 +1377,9 @@ def cache(
|
|
|
1122
1377
|
len(params),
|
|
1123
1378
|
)
|
|
1124
1379
|
sig = sig.replace(
|
|
1125
|
-
parameters=[
|
|
1126
|
-
*params[:insert_at],
|
|
1127
|
-
request_param,
|
|
1128
|
-
*params[insert_at:],
|
|
1129
|
-
]
|
|
1380
|
+
parameters=[*params[:insert_at], *injected, *params[insert_at:]]
|
|
1130
1381
|
)
|
|
1131
1382
|
|
|
1132
|
-
else:
|
|
1133
|
-
request_name = found_request.name
|
|
1134
|
-
|
|
1135
1383
|
# The header only depends on the decorator arguments, so build it once.
|
|
1136
1384
|
cache_control = _build_cache_control(
|
|
1137
1385
|
ttl=ttl,
|
|
@@ -1171,8 +1419,14 @@ def cache(
|
|
|
1171
1419
|
f"{getattr(func, '__module__', '?')}.{getattr(func, '__qualname__', '?')}"
|
|
1172
1420
|
)
|
|
1173
1421
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1422
|
+
async def respond(
|
|
1423
|
+
sub_response: Response | None,
|
|
1424
|
+
dependency_lines: Sequence[tuple[bytes, bytes]],
|
|
1425
|
+
dependency_status: int | None,
|
|
1426
|
+
/,
|
|
1427
|
+
*args: Any,
|
|
1428
|
+
**kwargs: Any,
|
|
1429
|
+
) -> Response:
|
|
1176
1430
|
# Resolve backend on every request to support lifespan-configured backends
|
|
1177
1431
|
cache_backend = get_backend_or_fallback()
|
|
1178
1432
|
|
|
@@ -1191,11 +1445,25 @@ def cache(
|
|
|
1191
1445
|
logger.debug(
|
|
1192
1446
|
"Non-GET request; bypassing cache for method=%s", req.method
|
|
1193
1447
|
)
|
|
1194
|
-
return await
|
|
1448
|
+
return await _respond(
|
|
1449
|
+
func,
|
|
1450
|
+
req,
|
|
1451
|
+
args,
|
|
1452
|
+
kwargs,
|
|
1453
|
+
sub_response=sub_response,
|
|
1454
|
+
dependency_lines=dependency_lines,
|
|
1455
|
+
)
|
|
1195
1456
|
|
|
1196
1457
|
# Handle special case: no-store (highest priority)
|
|
1197
1458
|
if no_store:
|
|
1198
|
-
response = await
|
|
1459
|
+
response = await _respond(
|
|
1460
|
+
func,
|
|
1461
|
+
req,
|
|
1462
|
+
args,
|
|
1463
|
+
kwargs,
|
|
1464
|
+
sub_response=sub_response,
|
|
1465
|
+
dependency_lines=dependency_lines,
|
|
1466
|
+
)
|
|
1199
1467
|
logger.debug(
|
|
1200
1468
|
"no-store active; bypassed cache for path=%s", req.url.path
|
|
1201
1469
|
)
|
|
@@ -1245,7 +1513,14 @@ def cache(
|
|
|
1245
1513
|
# cache reuse the answer to an `Authorization` request under
|
|
1246
1514
|
# `must-revalidate`, and nothing at all stops one reusing the
|
|
1247
1515
|
# answer to a cookie.
|
|
1248
|
-
response, _, etag = await _render(
|
|
1516
|
+
response, _, etag = await _render(
|
|
1517
|
+
func,
|
|
1518
|
+
req,
|
|
1519
|
+
args,
|
|
1520
|
+
kwargs,
|
|
1521
|
+
sub_response=sub_response,
|
|
1522
|
+
dependency_lines=dependency_lines,
|
|
1523
|
+
)
|
|
1249
1524
|
if not _is_cacheable_status(response.status_code):
|
|
1250
1525
|
return response
|
|
1251
1526
|
if etag is None:
|
|
@@ -1253,15 +1528,17 @@ def cache(
|
|
|
1253
1528
|
return _with_cache_control(
|
|
1254
1529
|
response, response_cache_control, private_cache_control
|
|
1255
1530
|
)
|
|
1256
|
-
|
|
1531
|
+
not_modified = _fresh_not_modified(
|
|
1532
|
+
response,
|
|
1533
|
+
etag,
|
|
1534
|
+
client_etag,
|
|
1535
|
+
_cache_control_for(
|
|
1536
|
+
response, response_cache_control, private_cache_control
|
|
1537
|
+
),
|
|
1538
|
+
)
|
|
1539
|
+
if not_modified is not None:
|
|
1257
1540
|
logger.debug("304 Not Modified (uncached); path=%s", req.url.path)
|
|
1258
|
-
return
|
|
1259
|
-
etag,
|
|
1260
|
-
_cache_control_for(
|
|
1261
|
-
response, response_cache_control, private_cache_control
|
|
1262
|
-
),
|
|
1263
|
-
response.headers.items(),
|
|
1264
|
-
)
|
|
1541
|
+
return not_modified
|
|
1265
1542
|
response.headers["ETag"] = etag
|
|
1266
1543
|
logger.debug("Bypassed the backend; path=%s", req.url.path)
|
|
1267
1544
|
return _with_cache_control(
|
|
@@ -1290,7 +1567,12 @@ def cache(
|
|
|
1290
1567
|
if no_cache:
|
|
1291
1568
|
# Get fresh response first if using no-cache
|
|
1292
1569
|
current_response, current_body, current_etag = await _render(
|
|
1293
|
-
func,
|
|
1570
|
+
func,
|
|
1571
|
+
req,
|
|
1572
|
+
args,
|
|
1573
|
+
kwargs,
|
|
1574
|
+
sub_response=sub_response,
|
|
1575
|
+
dependency_lines=dependency_lines,
|
|
1294
1576
|
)
|
|
1295
1577
|
if not _is_cacheable_status(current_response.status_code):
|
|
1296
1578
|
# Error responses carry no validator: never answer 304.
|
|
@@ -1309,18 +1591,20 @@ def cache(
|
|
|
1309
1591
|
private_cache_control,
|
|
1310
1592
|
)
|
|
1311
1593
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1594
|
+
# For no-cache, compare fresh data with client's ETag
|
|
1595
|
+
not_modified = _fresh_not_modified(
|
|
1596
|
+
current_response,
|
|
1597
|
+
current_etag,
|
|
1598
|
+
client_etag,
|
|
1599
|
+
_cache_control_for(
|
|
1600
|
+
current_response,
|
|
1601
|
+
response_cache_control,
|
|
1602
|
+
private_cache_control,
|
|
1603
|
+
),
|
|
1604
|
+
)
|
|
1605
|
+
if not_modified is not None:
|
|
1314
1606
|
logger.debug("304 Not Modified via no-cache; key=%s", cache_key)
|
|
1315
|
-
return
|
|
1316
|
-
current_etag,
|
|
1317
|
-
_cache_control_for(
|
|
1318
|
-
current_response,
|
|
1319
|
-
response_cache_control,
|
|
1320
|
-
private_cache_control,
|
|
1321
|
-
),
|
|
1322
|
-
current_response.headers.items(),
|
|
1323
|
-
)
|
|
1607
|
+
return not_modified
|
|
1324
1608
|
|
|
1325
1609
|
# Compare with cached ETag - if match, return 304
|
|
1326
1610
|
elif cached_data and _etag_matches(
|
|
@@ -1366,7 +1650,12 @@ def cache(
|
|
|
1366
1650
|
if current_response is None or current_etag is None:
|
|
1367
1651
|
# Retrieve the current response if not already done
|
|
1368
1652
|
current_response, current_body, current_etag = await _render(
|
|
1369
|
-
func,
|
|
1653
|
+
func,
|
|
1654
|
+
req,
|
|
1655
|
+
args,
|
|
1656
|
+
kwargs,
|
|
1657
|
+
sub_response=sub_response,
|
|
1658
|
+
dependency_lines=dependency_lines,
|
|
1370
1659
|
)
|
|
1371
1660
|
if not _is_cacheable_status(current_response.status_code):
|
|
1372
1661
|
# Leave any existing entry alone: a transient error must not
|
|
@@ -1391,6 +1680,11 @@ def cache(
|
|
|
1391
1680
|
# stored. An entry already under this key is left alone, as for an
|
|
1392
1681
|
# error status: it came from a response that was shareable.
|
|
1393
1682
|
skip_reason = _unshareable_reason(current_response)
|
|
1683
|
+
if skip_reason is None:
|
|
1684
|
+
skip_reason = _dependency_unshareable_reason(dependency_lines)
|
|
1685
|
+
if skip_reason is None and dependency_status is not None:
|
|
1686
|
+
# It may hold for this request only, and a hit would replay it.
|
|
1687
|
+
skip_reason = "a dependency set the status code"
|
|
1394
1688
|
if skip_reason is not None:
|
|
1395
1689
|
logger.debug("Not storing key=%s: %s", cache_key, skip_reason)
|
|
1396
1690
|
|
|
@@ -1421,10 +1715,52 @@ def cache(
|
|
|
1421
1715
|
else:
|
|
1422
1716
|
logger.debug("Updated cache entry; key=%s ttl=%s", cache_key, ttl)
|
|
1423
1717
|
|
|
1718
|
+
# A client revalidating a copy that the fresh render reproduces gets
|
|
1719
|
+
# a 304, as on the bypass and no_cache paths: the entry may have
|
|
1720
|
+
# expired, been cleared or evicted, or never been stored in this
|
|
1721
|
+
# worker, while the client's ETag still matches (#237).
|
|
1722
|
+
not_modified = _fresh_not_modified(
|
|
1723
|
+
current_response,
|
|
1724
|
+
current_etag,
|
|
1725
|
+
client_etag,
|
|
1726
|
+
_cache_control_for(
|
|
1727
|
+
current_response, response_cache_control, private_cache_control
|
|
1728
|
+
),
|
|
1729
|
+
)
|
|
1730
|
+
if not_modified is not None:
|
|
1731
|
+
logger.debug("304 Not Modified (fresh render); key=%s", cache_key)
|
|
1732
|
+
return not_modified
|
|
1733
|
+
|
|
1424
1734
|
return _with_cache_control(
|
|
1425
1735
|
current_response, response_cache_control, private_cache_control
|
|
1426
1736
|
)
|
|
1427
1737
|
|
|
1738
|
+
@wraps(func)
|
|
1739
|
+
async def serve(*args: Any, **kwargs: Any) -> Response:
|
|
1740
|
+
if found_response:
|
|
1741
|
+
sub_response: Response | None = kwargs.get(response_name)
|
|
1742
|
+
else:
|
|
1743
|
+
sub_response = kwargs.pop(response_name, None)
|
|
1744
|
+
# The dependencies have run; the handler has not (see
|
|
1745
|
+
# `_split_header_lines`).
|
|
1746
|
+
dependency_lines = (
|
|
1747
|
+
[] if sub_response is None else list(sub_response.headers.raw)
|
|
1748
|
+
)
|
|
1749
|
+
dependency_status = (
|
|
1750
|
+
None if sub_response is None else sub_response.status_code
|
|
1751
|
+
)
|
|
1752
|
+
req: Request | None = kwargs.get(request_name)
|
|
1753
|
+
response = await respond(
|
|
1754
|
+
sub_response, dependency_lines, dependency_status, *args, **kwargs
|
|
1755
|
+
)
|
|
1756
|
+
return _with_dependency_headers(
|
|
1757
|
+
response,
|
|
1758
|
+
sub_response,
|
|
1759
|
+
dependency_lines,
|
|
1760
|
+
private_cache_control,
|
|
1761
|
+
cacheable_get=req is not None and req.method == "GET",
|
|
1762
|
+
)
|
|
1763
|
+
|
|
1428
1764
|
wrapper: AsyncResponseCallable = serve
|
|
1429
1765
|
if vary_names:
|
|
1430
1766
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Distributed lock helper for FastAPI-CacheX."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
4
5
|
import logging
|
|
5
6
|
import secrets
|
|
6
7
|
import time
|
|
@@ -16,6 +17,10 @@ logger = logging.getLogger(__name__)
|
|
|
16
17
|
# Fingerprint every backend reports for a key that holds a lock entry
|
|
17
18
|
_LOCK_FINGERPRINT = "lock"
|
|
18
19
|
|
|
20
|
+
# Withdrawals still running after a second cancellation; the event loop keeps
|
|
21
|
+
# only a weak reference to a task.
|
|
22
|
+
_pending_withdrawals: set["asyncio.Task[bool]"] = set()
|
|
23
|
+
|
|
19
24
|
|
|
20
25
|
def _lock_entry(token: str) -> CacheEntry:
|
|
21
26
|
"""Wrap a lock token string in the entry model shared by every backend."""
|
|
@@ -107,6 +112,10 @@ class CacheLock:
|
|
|
107
112
|
raise RuntimeError(msg)
|
|
108
113
|
|
|
109
114
|
self._is_held = True
|
|
115
|
+
# True while a claim is on its way to the backend: if acquire() is
|
|
116
|
+
# interrupted then, the claim may be stored with nobody to release it.
|
|
117
|
+
claim_in_flight = False
|
|
118
|
+
backend: BaseCacheBackend | None = None
|
|
110
119
|
try:
|
|
111
120
|
is_blocking = self.blocking if blocking is None else blocking
|
|
112
121
|
effective_timeout = self.timeout if timeout is None else timeout
|
|
@@ -116,19 +125,23 @@ class CacheLock:
|
|
|
116
125
|
backend = self._get_backend()
|
|
117
126
|
|
|
118
127
|
if not is_blocking:
|
|
128
|
+
claim_in_flight = True
|
|
119
129
|
acquired = await backend.set_if_absent(
|
|
120
130
|
self.key, self._entry, ttl=effective_ttl
|
|
121
131
|
)
|
|
132
|
+
claim_in_flight = False
|
|
122
133
|
if not acquired:
|
|
123
134
|
self._is_held = False
|
|
124
135
|
return acquired
|
|
125
136
|
|
|
126
137
|
start = time.monotonic()
|
|
127
138
|
while True:
|
|
139
|
+
claim_in_flight = True
|
|
128
140
|
if await backend.set_if_absent(
|
|
129
141
|
self.key, self._entry, ttl=effective_ttl
|
|
130
142
|
):
|
|
131
143
|
return True
|
|
144
|
+
claim_in_flight = False
|
|
132
145
|
if effective_timeout is not None:
|
|
133
146
|
elapsed = time.monotonic() - start
|
|
134
147
|
if elapsed >= effective_timeout:
|
|
@@ -141,8 +154,43 @@ class CacheLock:
|
|
|
141
154
|
await asyncio.sleep(sleep_time)
|
|
142
155
|
except BaseException:
|
|
143
156
|
self._is_held = False
|
|
157
|
+
if claim_in_flight and backend is not None:
|
|
158
|
+
await self._withdraw_claim(backend)
|
|
144
159
|
raise
|
|
145
160
|
|
|
161
|
+
async def _withdraw_claim(self, backend: BaseCacheBackend) -> None:
|
|
162
|
+
"""Remove a claim that may have reached the backend before an error (#234).
|
|
163
|
+
|
|
164
|
+
A cancellation (a request timeout, a client disconnect) or a transport
|
|
165
|
+
error can interrupt ``set_if_absent`` after the backend stored the
|
|
166
|
+
claim, which would leave the key held by nobody until its TTL ran
|
|
167
|
+
out. The token is unique to this instance, so deleting it only if it
|
|
168
|
+
is still there cannot release anyone else's lock. Shielded, so a
|
|
169
|
+
second cancellation does not stop the delete; best effort, so a
|
|
170
|
+
failure is logged and the original exception still propagates. The
|
|
171
|
+
failure is logged from the task itself, so it is logged even when a
|
|
172
|
+
second cancellation stopped this method from waiting for the delete.
|
|
173
|
+
"""
|
|
174
|
+
withdrawal = asyncio.ensure_future(
|
|
175
|
+
backend.delete_if_equals(self.key, self._entry)
|
|
176
|
+
)
|
|
177
|
+
_pending_withdrawals.add(withdrawal)
|
|
178
|
+
withdrawal.add_done_callback(self._log_failed_withdrawal)
|
|
179
|
+
# A failure is logged by _log_failed_withdrawal.
|
|
180
|
+
with contextlib.suppress(Exception):
|
|
181
|
+
await asyncio.shield(withdrawal)
|
|
182
|
+
|
|
183
|
+
def _log_failed_withdrawal(self, withdrawal: "asyncio.Task[bool]") -> None:
|
|
184
|
+
"""Log a withdrawal that raised; reading the exception also retrieves it."""
|
|
185
|
+
_pending_withdrawals.discard(withdrawal)
|
|
186
|
+
if withdrawal.cancelled() or withdrawal.exception() is None:
|
|
187
|
+
return
|
|
188
|
+
logger.warning(
|
|
189
|
+
"CacheLock could not withdraw an interrupted claim; key=%s",
|
|
190
|
+
self.key,
|
|
191
|
+
exc_info=withdrawal.exception(),
|
|
192
|
+
)
|
|
193
|
+
|
|
146
194
|
async def release(self) -> bool:
|
|
147
195
|
"""Release the lock if still held by this instance.
|
|
148
196
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|