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.
Files changed (44) hide show
  1. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/PKG-INFO +1 -1
  2. fastapi_cachex-0.4.1/fastapi_cachex/_warnings.py +25 -0
  3. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/base.py +20 -2
  4. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/memcached.py +67 -28
  5. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/memory.py +15 -7
  6. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/redis.py +7 -2
  7. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/cache.py +402 -66
  8. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/lock.py +48 -0
  9. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/pyproject.toml +1 -1
  10. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/pyproject.toml.orig +1 -1
  11. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/LICENSE +0 -0
  12. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/README.md +0 -0
  13. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/__init__.py +0 -0
  14. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/_deprecation.py +0 -0
  15. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/__init__.py +0 -0
  16. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/codec.py +0 -0
  17. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/backends/config.py +0 -0
  18. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/cache_key.py +0 -0
  19. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/dependencies.py +0 -0
  20. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/directives.py +0 -0
  21. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/exceptions.py +0 -0
  22. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/headers.py +0 -0
  23. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/manager.py +0 -0
  24. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/manager_proxy.py +0 -0
  25. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/proxy.py +0 -0
  26. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/py.typed +0 -0
  27. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/routes.py +0 -0
  28. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/__init__.py +0 -0
  29. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/config.py +0 -0
  30. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/dependencies.py +0 -0
  31. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/exceptions.py +0 -0
  32. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/manager.py +0 -0
  33. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/middleware.py +0 -0
  34. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/models.py +0 -0
  35. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/proxy.py +0 -0
  36. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/security.py +0 -0
  37. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/session/token_serializers.py +0 -0
  38. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/__init__.py +0 -0
  39. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/dependencies.py +0 -0
  40. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/exceptions.py +0 -0
  41. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/manager.py +0 -0
  42. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/models.py +0 -0
  43. {fastapi_cachex-0.4.0 → fastapi_cachex-0.4.1}/fastapi_cachex/state/proxy.py +0 -0
  44. {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.0
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=3,
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 counter
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
- return None if result is None else int(result)
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, or
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=_caller_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=_caller_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=_caller_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=_caller_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
- local value = redis.call('INCRBY', KEYS[1], ARGV[1])
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 value
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 request parameter. Resolution can
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
- response.headers["Cache-Control"] = cache_control
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 response.headers.getlist("cache-control")
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
- response.headers["Cache-Control"] = _cache_control_for(
798
- response, cache_control, private_cache_control
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, request: Request, /, *args: Any, **kwargs: Any
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 get_response(func, request, *args, **kwargs)
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(__func):
871
- result = await cast("Callable[..., Awaitable[object]]", __func)(*args, **kwargs)
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(__func, *args, **kwargs)
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 = __request.scope.get("route")
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 = __request.app.router.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 an injected `response: Response`.
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
- response.headers.raw.extend(sub_response.headers.raw)
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
- # Add Request parameter if it's not present
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
- request_param = inspect.Parameter(
1108
- request_name,
1109
- inspect.Parameter.KEYWORD_ONLY,
1110
- annotation=Request,
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
- @wraps(func)
1175
- async def serve(*args: Any, **kwargs: Any) -> Response:
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 get_response(func, req, *args, **kwargs)
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 get_response(func, req, *args, **kwargs)
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(func, req, *args, **kwargs)
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
- if _etag_matches(client_etag, etag):
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 _not_modified(
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, req, *args, **kwargs
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
- if _etag_matches(client_etag, current_etag):
1313
- # For no-cache, compare fresh data with client's ETag
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 _not_modified(
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, req, *args, **kwargs
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-cachex"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-cachex"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes
File without changes