fastapi-cachex 0.5.0__tar.gz → 0.5.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 (37) hide show
  1. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/PKG-INFO +1 -1
  2. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/__init__.py +8 -0
  3. fastapi_cachex-0.5.1/fastapi_cachex/backends/_unavailable.py +100 -0
  4. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/base.py +12 -9
  5. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/memcached.py +43 -3
  6. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/redis.py +31 -0
  7. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/cache.py +12 -7
  8. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/cached.py +6 -5
  9. fastapi_cachex-0.5.1/fastapi_cachex/exceptions.py +64 -0
  10. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/lock.py +4 -3
  11. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/routes.py +43 -11
  12. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/pyproject.toml +1 -1
  13. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/pyproject.toml.orig +1 -1
  14. fastapi_cachex-0.5.0/fastapi_cachex/exceptions.py +0 -26
  15. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/LICENSE +0 -0
  16. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/README.md +0 -0
  17. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/_cache_control.py +0 -0
  18. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/_callables.py +0 -0
  19. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/_coalesce.py +0 -0
  20. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/_key_builders.py +0 -0
  21. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/_rendering.py +0 -0
  22. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/_stored_response.py +0 -0
  23. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/_vary.py +0 -0
  24. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/_warnings.py +0 -0
  25. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/__init__.py +0 -0
  26. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/codec.py +0 -0
  27. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/config.py +0 -0
  28. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/backends/memory.py +0 -0
  29. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/cache_key.py +0 -0
  30. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/dependencies.py +0 -0
  31. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/directives.py +0 -0
  32. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/headers.py +0 -0
  33. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/manager.py +0 -0
  34. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/manager_proxy.py +0 -0
  35. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/proxy.py +0 -0
  36. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.1}/fastapi_cachex/py.typed +0 -0
  37. {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.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.5.0
3
+ Version: 0.5.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
@@ -16,10 +16,14 @@ from .dependencies import CacheBackend as CacheBackend
16
16
  from .dependencies import get_app_cache as get_app_cache
17
17
  from .dependencies import get_cache_backend as get_cache_backend
18
18
  from .exceptions import BackendNotFoundError as BackendNotFoundError
19
+ from .exceptions import BackendUnavailableError as BackendUnavailableError
19
20
  from .exceptions import CacheXError as CacheXError
21
+ from .exceptions import InvalidTTLError as InvalidTTLError
20
22
  from .exceptions import LockTimeoutError as LockTimeoutError
21
23
  from .exceptions import ProxyNotSetError as ProxyNotSetError
22
24
  from .exceptions import RequestNotFoundError as RequestNotFoundError
25
+ from .exceptions import TTLTypeError as TTLTypeError
26
+ from .exceptions import TTLValueError as TTLValueError
23
27
  from .lock import CacheLock as CacheLock
24
28
  from .manager import CacheManager as CacheManager
25
29
  from .manager_proxy import CacheManagerProxy as CacheManagerProxy
@@ -52,6 +56,7 @@ __all__ = [
52
56
  "AppCache",
53
57
  "BackendNotFoundError",
54
58
  "BackendProxy",
59
+ "BackendUnavailableError",
55
60
  "CacheBackend",
56
61
  "CacheKey",
57
62
  "CacheKeyBuilder",
@@ -60,9 +65,12 @@ __all__ = [
60
65
  "CacheManagerProxy",
61
66
  "CacheXError",
62
67
  "CachedFunction",
68
+ "InvalidTTLError",
63
69
  "LockTimeoutError",
64
70
  "ProxyNotSetError",
65
71
  "RequestNotFoundError",
72
+ "TTLTypeError",
73
+ "TTLValueError",
66
74
  "__version__",
67
75
  "add_routes",
68
76
  "build_cache_key",
@@ -0,0 +1,100 @@
1
+ """Turn a network backend's transport failures into ``BackendUnavailableError``.
2
+
3
+ The Redis and Memcached clients each raise their own exceptions when the
4
+ server cannot be reached: ``redis.exceptions.ConnectionError`` and
5
+ ``TimeoutError``, socket errors, pymemcache's "All servers seem to be down".
6
+ A caller that only wants to know "the cache is down" would have to know which
7
+ backend is configured and import that client's exceptions (#260).
8
+
9
+ The backends' methods are wrapped so that such an error is raised again as a
10
+ ``BackendUnavailableError`` chained to the original. The class raised is
11
+ built per original type and inherits from both, so it is also the original
12
+ type: an ``except redis.exceptions.ConnectionError`` or ``except OSError``
13
+ written before keeps catching it, and ``except BackendUnavailableError`` now
14
+ works on every backend.
15
+ """
16
+
17
+ import functools
18
+ from collections.abc import Callable
19
+ from collections.abc import Coroutine
20
+ from typing import Any
21
+ from typing import ParamSpec
22
+ from typing import TypeVar
23
+ from typing import cast
24
+
25
+ from fastapi_cachex.exceptions import BackendUnavailableError
26
+
27
+ P = ParamSpec("P")
28
+ R = TypeVar("R")
29
+
30
+ _SUBCLASSES: dict[type[Exception], type[BackendUnavailableError]] = {}
31
+
32
+
33
+ def _rebuild(error_type: type[Exception], args: tuple[Any, ...]) -> Exception:
34
+ """Unpickle a translated error: pickle cannot import a runtime class."""
35
+ return _subclass_for(error_type)(*args)
36
+
37
+
38
+ def _reduce(self: BaseException) -> tuple[Any, ...]:
39
+ return _rebuild, (type(self).__bases__[1], self.args), self.__dict__ or None
40
+
41
+
42
+ def _subclass_for(error_type: type[Exception]) -> type[BackendUnavailableError]:
43
+ """The ``BackendUnavailableError`` that is also an ``error_type``."""
44
+ subclass = _SUBCLASSES.get(error_type)
45
+ if subclass is None:
46
+ # Named like its base, so a traceback reads BackendUnavailableError;
47
+ # the chained original below it names the client's own exception.
48
+ subclass = cast(
49
+ "type[BackendUnavailableError]",
50
+ type(
51
+ "BackendUnavailableError",
52
+ (BackendUnavailableError, error_type),
53
+ {
54
+ "__module__": BackendUnavailableError.__module__,
55
+ "__qualname__": BackendUnavailableError.__qualname__,
56
+ "__doc__": BackendUnavailableError.__doc__,
57
+ "__reduce__": _reduce,
58
+ },
59
+ ),
60
+ )
61
+ _SUBCLASSES[error_type] = subclass
62
+ return subclass
63
+
64
+
65
+ def as_unavailable(error: Exception) -> BackendUnavailableError:
66
+ """``error`` as a ``BackendUnavailableError`` that is also its own type."""
67
+ subclass = _subclass_for(type(error))
68
+ try:
69
+ translated = subclass(*error.args)
70
+ except Exception: # noqa: BLE001 - a constructor that wants other arguments
71
+ translated = subclass(str(error))
72
+ translated.__dict__.update(error.__dict__)
73
+ return translated
74
+
75
+
76
+ def translate_unavailable(
77
+ is_unavailable: Callable[[Exception], bool],
78
+ ) -> Callable[
79
+ [Callable[P, Coroutine[Any, Any, R]]], Callable[P, Coroutine[Any, Any, R]]
80
+ ]:
81
+ """A decorator raising the errors ``is_unavailable`` accepts as unavailable."""
82
+
83
+ def decorator(
84
+ func: Callable[P, Coroutine[Any, Any, R]],
85
+ ) -> Callable[P, Coroutine[Any, Any, R]]:
86
+ @functools.wraps(func)
87
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
88
+ try:
89
+ return await func(*args, **kwargs)
90
+ except BackendUnavailableError:
91
+ # Already translated by a wrapped method this one called.
92
+ raise
93
+ except Exception as e:
94
+ if not is_unavailable(e):
95
+ raise
96
+ raise as_unavailable(e) from e
97
+
98
+ return wrapper
99
+
100
+ return decorator
@@ -11,6 +11,8 @@ from typing import Any
11
11
  from typing import overload
12
12
 
13
13
  from fastapi_cachex.exceptions import CacheXError
14
+ from fastapi_cachex.exceptions import TTLTypeError
15
+ from fastapi_cachex.exceptions import TTLValueError
14
16
  from fastapi_cachex.types import CACHE_KEY_SEPARATOR
15
17
  from fastapi_cachex.types import HTTP_KEY_FORMAT_TAG
16
18
  from fastapi_cachex.types import CacheEntry
@@ -75,10 +77,11 @@ def validate_ttl(ttl: int | timedelta | None) -> int | None:
75
77
  backend stores.
76
78
 
77
79
  Raises:
78
- TypeError: If ``ttl`` is not an ``int`` or a ``timedelta`` (``bool``
79
- included)
80
- ValueError: If ``ttl`` is zero, negative, larger than ``MAX_TTL`` or
81
- a ``timedelta`` that is not a whole number of seconds
80
+ TTLTypeError: If ``ttl`` is not an ``int`` or a ``timedelta``
81
+ (``bool`` included); also a ``TypeError``
82
+ TTLValueError: If ``ttl`` is zero, negative, larger than ``MAX_TTL``
83
+ or a ``timedelta`` that is not a whole number of seconds; also a
84
+ ``ValueError``
82
85
  """
83
86
  if ttl is None:
84
87
  return None
@@ -89,13 +92,13 @@ def validate_ttl(ttl: int | timedelta | None) -> int | None:
89
92
  "ttl must be an int number of seconds, a timedelta or None, "
90
93
  f"got {type(ttl).__name__}"
91
94
  )
92
- raise TypeError(msg)
95
+ raise TTLTypeError(msg)
93
96
  if ttl <= 0:
94
97
  msg = f"ttl must be a positive number of seconds or None, got {ttl!r}"
95
- raise ValueError(msg)
98
+ raise TTLValueError(msg)
96
99
  if ttl > MAX_TTL:
97
100
  msg = f"ttl must be at most {MAX_TTL} seconds (about 68 years)"
98
- raise ValueError(msg)
101
+ raise TTLValueError(msg)
99
102
  return ttl
100
103
 
101
104
 
@@ -103,11 +106,11 @@ def _whole_seconds(value: timedelta, name: str) -> int:
103
106
  """The whole number of seconds in ``value``.
104
107
 
105
108
  Raises:
106
- ValueError: If ``value`` has a fraction of a second
109
+ TTLValueError: If ``value`` has a fraction of a second
107
110
  """
108
111
  if value.microseconds:
109
112
  msg = f"{name} must be a whole number of seconds, got {value!r}"
110
- raise ValueError(msg)
113
+ raise TTLValueError(msg)
111
114
  return int(value.total_seconds())
112
115
 
113
116
 
@@ -12,14 +12,38 @@ from fastapi_cachex._warnings import caller_stacklevel
12
12
  from fastapi_cachex.backends.codec import decode_entry
13
13
  from fastapi_cachex.backends.codec import encode_entry
14
14
  from fastapi_cachex.exceptions import CacheXError
15
+ from fastapi_cachex.exceptions import TTLValueError
15
16
  from fastapi_cachex.types import CacheEntry
16
17
 
18
+ from ._unavailable import translate_unavailable
17
19
  from .base import BaseCacheBackend
18
20
  from .base import validate_delta
19
21
  from .base import validate_ttl
20
22
 
21
23
  logger = logging.getLogger(__name__)
22
24
 
25
+ # What pymemcache's HashClient raises while every server is out of rotation.
26
+ _ALL_SERVERS_DOWN = "All servers seem to be down"
27
+
28
+
29
+ def _is_unavailable(error: Exception) -> bool:
30
+ """Whether ``error`` means no Memcached server could be reached.
31
+
32
+ A socket error (refused, reset, timed out), a connection the server
33
+ closed mid-reply, and pymemcache's "All servers seem to be down" while a
34
+ failed server is out of rotation. A ``MemcacheClientError`` (bad input,
35
+ an item over the size limit) is not about availability and is left as is.
36
+ """
37
+ from pymemcache.exceptions import MemcacheError
38
+ from pymemcache.exceptions import MemcacheUnexpectedCloseError
39
+
40
+ if isinstance(error, (OSError, MemcacheUnexpectedCloseError)):
41
+ return True
42
+ return type(error) is MemcacheError and _ALL_SERVERS_DOWN in str(error)
43
+
44
+
45
+ _unavailable = translate_unavailable(_is_unavailable)
46
+
23
47
  # Default Memcached key prefix for fastapi-cachex
24
48
  DEFAULT_MEMCACHE_PREFIX = "fastapi_cachex:"
25
49
 
@@ -88,8 +112,9 @@ def _expiry(ttl: int | None) -> int:
88
112
  in 1970 and expire the entry immediately. ``None`` means no expiry.
89
113
 
90
114
  Raises:
91
- ValueError: If the expiry would fall after 2038-01-19, which Memcached
92
- cannot represent: it would accept the write and drop the item.
115
+ TTLValueError: If the expiry would fall after 2038-01-19, which
116
+ Memcached cannot represent: it would accept the write and drop the
117
+ item. Also a ``ValueError``.
93
118
  """
94
119
  if ttl is None:
95
120
  return 0
@@ -100,7 +125,7 @@ def _expiry(ttl: int | None) -> int:
100
125
  f"ttl {ttl!r} expires after 2038-01-19, which Memcached cannot "
101
126
  "store; use a shorter ttl or None"
102
127
  )
103
- raise ValueError(msg)
128
+ raise TTLValueError(msg)
104
129
  return expires_at
105
130
  return ttl
106
131
 
@@ -200,6 +225,7 @@ class MemcachedBackend(BaseCacheBackend):
200
225
  logger.debug("Memcached key hashed; key=%s digest=%s", key, digest)
201
226
  return f"{self.key_prefix}{digest}"
202
227
 
228
+ @_unavailable
203
229
  async def get(self, key: str) -> CacheEntry | None:
204
230
  """Get value from cache.
205
231
 
@@ -219,6 +245,7 @@ class MemcachedBackend(BaseCacheBackend):
219
245
  logger.debug("Memcached HIT; key=%s", key)
220
246
  return value
221
247
 
248
+ @_unavailable
222
249
  async def set(
223
250
  self, key: str, value: CacheEntry, ttl: int | timedelta | None = None
224
251
  ) -> None:
@@ -235,6 +262,7 @@ class MemcachedBackend(BaseCacheBackend):
235
262
  )
236
263
  logger.debug("Memcached SET; key=%s ttl=%s", key, ttl)
237
264
 
265
+ @_unavailable
238
266
  async def get_and_delete(self, key: str) -> CacheEntry | None:
239
267
  """Atomically retrieve and remove a cached entry (see base class).
240
268
 
@@ -273,6 +301,7 @@ class MemcachedBackend(BaseCacheBackend):
273
301
  msg = f"Memcached get_and_delete exceeded {_CAS_MAX_RETRIES} attempts on key {key!r}"
274
302
  raise CacheXError(msg)
275
303
 
304
+ @_unavailable
276
305
  async def set_if_absent(
277
306
  self, key: str, value: CacheEntry, ttl: int | timedelta | None = None
278
307
  ) -> bool:
@@ -296,6 +325,7 @@ class MemcachedBackend(BaseCacheBackend):
296
325
  )
297
326
  return bool(stored)
298
327
 
328
+ @_unavailable
299
329
  async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
300
330
  """Atomically remove ``key`` while it holds ``expected`` (see base class).
301
331
 
@@ -321,6 +351,7 @@ class MemcachedBackend(BaseCacheBackend):
321
351
  )
322
352
  return bool(deleted)
323
353
 
354
+ @_unavailable
324
355
  async def expire_if_equals(
325
356
  self, key: str, expected: CacheEntry, ttl: int | timedelta
326
357
  ) -> bool:
@@ -354,6 +385,7 @@ class MemcachedBackend(BaseCacheBackend):
354
385
  )
355
386
  return bool(updated)
356
387
 
388
+ @_unavailable
357
389
  async def set_if_equals(
358
390
  self,
359
391
  key: str,
@@ -449,6 +481,7 @@ class MemcachedBackend(BaseCacheBackend):
449
481
  logger.debug("Memcached INCREMENT RETRY; key=%s", prefixed_key)
450
482
  return None
451
483
 
484
+ @_unavailable
452
485
  async def increment(
453
486
  self, key: str, delta: int = 1, ttl: int | timedelta | None = None
454
487
  ) -> int:
@@ -489,6 +522,7 @@ class MemcachedBackend(BaseCacheBackend):
489
522
  logger.debug("Memcached INCREMENT; key=%s value=%s ttl=%s", key, value, ttl)
490
523
  return value
491
524
 
525
+ @_unavailable
492
526
  async def delete(self, key: str) -> bool:
493
527
  """Delete value from cache.
494
528
 
@@ -505,6 +539,7 @@ class MemcachedBackend(BaseCacheBackend):
505
539
  logger.debug("Memcached DELETE; key=%s removed=%s", key, removed)
506
540
  return removed
507
541
 
542
+ @_unavailable
508
543
  async def delete_many(self, keys: Iterable[str]) -> int:
509
544
  """Remove every key in ``keys`` in one worker call; returns how many existed.
510
545
 
@@ -529,6 +564,7 @@ class MemcachedBackend(BaseCacheBackend):
529
564
  bool(self.client.delete(key, noreply=False)) for key in prefixed_keys
530
565
  )
531
566
 
567
+ @_unavailable
532
568
  async def clear(self) -> None:
533
569
  """Clear all values from cache.
534
570
 
@@ -548,6 +584,7 @@ class MemcachedBackend(BaseCacheBackend):
548
584
  await asyncio.to_thread(self.client.flush_all)
549
585
  logger.debug("Memcached CLEAR; flush_all issued")
550
586
 
587
+ @_unavailable
551
588
  async def clear_path(self, path: str, include_params: bool = False) -> int:
552
589
  """Delete the key that is exactly ``path``; warns on every call.
553
590
 
@@ -586,6 +623,7 @@ class MemcachedBackend(BaseCacheBackend):
586
623
  )
587
624
  return removed
588
625
 
626
+ @_unavailable
589
627
  async def clear_pattern(self, pattern: str) -> int:
590
628
  """Clear cached responses matching a pattern.
591
629
 
@@ -609,6 +647,7 @@ class MemcachedBackend(BaseCacheBackend):
609
647
  logger.debug("Memcached CLEAR_PATTERN unsupported; pattern=%s", pattern)
610
648
  return 0
611
649
 
650
+ @_unavailable
612
651
  async def get_all_keys(self) -> list[str]:
613
652
  """Get all cache keys in the backend.
614
653
 
@@ -631,6 +670,7 @@ class MemcachedBackend(BaseCacheBackend):
631
670
  logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list")
632
671
  return []
633
672
 
673
+ @_unavailable
634
674
  async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
635
675
  """Get all cache data with expiry information.
636
676
 
@@ -17,6 +17,7 @@ from fastapi_cachex.cache_key import escape_glob
17
17
  from fastapi_cachex.exceptions import CacheXError
18
18
  from fastapi_cachex.types import CacheEntry
19
19
 
20
+ from ._unavailable import translate_unavailable
20
21
  from .base import BaseCacheBackend
21
22
  from .base import validate_delta
22
23
  from .base import validate_ttl
@@ -27,6 +28,21 @@ if TYPE_CHECKING:
27
28
 
28
29
  logger = logging.getLogger(__name__)
29
30
 
31
+
32
+ def _is_unavailable(error: Exception) -> bool:
33
+ """Whether ``error`` means the Redis server could not be reached.
34
+
35
+ redis-py's ``ConnectionError`` (its subclasses included) and
36
+ ``TimeoutError``, and a socket error that reaches the caller unwrapped.
37
+ """
38
+ from redis.exceptions import ConnectionError as RedisConnectionError
39
+ from redis.exceptions import TimeoutError as RedisTimeoutError
40
+
41
+ return isinstance(error, (RedisConnectionError, RedisTimeoutError, OSError))
42
+
43
+
44
+ _unavailable = translate_unavailable(_is_unavailable)
45
+
30
46
  # PTTL replies that are not a remaining lifetime.
31
47
  _PTTL_NO_EXPIRY = -1
32
48
  _PTTL_MISSING = -2
@@ -300,12 +316,14 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
300
316
  deleted += await self.client.delete(*keys[i : i + _BATCH_SIZE])
301
317
  return deleted
302
318
 
319
+ @_unavailable
303
320
  async def get(self, key: str) -> CacheEntry | None:
304
321
  """Retrieve a cached response."""
305
322
  value = decode_entry(await self.client.get(self._make_key(key)))
306
323
  logger.debug("Redis %s; key=%s", "HIT" if value else "MISS", key)
307
324
  return value
308
325
 
326
+ @_unavailable
309
327
  async def set(
310
328
  self, key: str, value: CacheEntry, ttl: int | timedelta | None = None
311
329
  ) -> None:
@@ -314,18 +332,21 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
314
332
  await self.client.set(self._make_key(key), encode_entry(value), ex=ttl)
315
333
  logger.debug("Redis SET; key=%s ttl=%s", key, ttl)
316
334
 
335
+ @_unavailable
317
336
  async def delete(self, key: str) -> bool:
318
337
  """Remove a response from the cache; returns whether the key existed."""
319
338
  removed = bool(await self.client.delete(self._make_key(key)))
320
339
  logger.debug("Redis DELETE; key=%s removed=%s", key, removed)
321
340
  return removed
322
341
 
342
+ @_unavailable
323
343
  async def delete_many(self, keys: Iterable[str]) -> int:
324
344
  """Remove every key in ``keys`` with batched DELs; returns how many existed."""
325
345
  removed = await self._delete_keys([self._make_key(key) for key in keys])
326
346
  logger.debug("Redis DELETE_MANY; removed=%s", removed)
327
347
  return removed
328
348
 
349
+ @_unavailable
329
350
  async def get_and_delete(self, key: str) -> CacheEntry | None:
330
351
  """Atomically retrieve and remove a cached entry (see base class).
331
352
 
@@ -335,6 +356,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
335
356
  logger.debug("Redis GETDEL %s; key=%s", "HIT" if value else "MISS", key)
336
357
  return value
337
358
 
359
+ @_unavailable
338
360
  async def set_if_absent(
339
361
  self, key: str, value: CacheEntry, ttl: int | timedelta | None = None
340
362
  ) -> bool:
@@ -354,6 +376,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
354
376
  )
355
377
  return bool(stored)
356
378
 
379
+ @_unavailable
357
380
  async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
358
381
  """Atomically remove ``key`` while it holds ``expected`` (see base class).
359
382
 
@@ -372,6 +395,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
372
395
  )
373
396
  return bool(deleted)
374
397
 
398
+ @_unavailable
375
399
  async def expire_if_equals(
376
400
  self, key: str, expected: CacheEntry, ttl: int | timedelta
377
401
  ) -> bool:
@@ -398,6 +422,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
398
422
  )
399
423
  return bool(updated)
400
424
 
425
+ @_unavailable
401
426
  async def set_if_equals(
402
427
  self,
403
428
  key: str,
@@ -428,6 +453,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
428
453
  )
429
454
  return bool(stored)
430
455
 
456
+ @_unavailable
431
457
  async def increment(
432
458
  self, key: str, delta: int = 1, ttl: int | timedelta | None = None
433
459
  ) -> int:
@@ -455,6 +481,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
455
481
  logger.debug("Redis INCREMENT; key=%s value=%s ttl=%s", key, value, ttl)
456
482
  return int(value)
457
483
 
484
+ @_unavailable
458
485
  async def clear(self) -> None:
459
486
  """Clear all cached responses for this namespace.
460
487
 
@@ -463,6 +490,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
463
490
  removed = await self._delete_matching(f"{self._prefix_pattern}*")
464
491
  logger.debug("Redis CLEAR; removed=%s", removed)
465
492
 
493
+ @_unavailable
466
494
  async def clear_path(self, path: str, include_params: bool = False) -> int:
467
495
  """Clear cached responses for a specific path.
468
496
 
@@ -501,6 +529,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
501
529
  )
502
530
  return cleared_count
503
531
 
532
+ @_unavailable
504
533
  async def clear_pattern(self, pattern: str) -> int:
505
534
  """Clear cached responses matching a pattern.
506
535
 
@@ -524,6 +553,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
524
553
  )
525
554
  return cleared_count
526
555
 
556
+ @_unavailable
527
557
  async def get_all_keys(self) -> list[str]:
528
558
  """Get all cache keys in the backend.
529
559
 
@@ -535,6 +565,7 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
535
565
  logger.debug("Redis GET_ALL_KEYS; count=%s", len(logical_keys))
536
566
  return logical_keys
537
567
 
568
+ @_unavailable
538
569
  async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]:
539
570
  """Get all cache data with expiry information.
540
571
 
@@ -58,6 +58,8 @@ from .directives import DirectiveType
58
58
  from .exceptions import BackendNotFoundError
59
59
  from .exceptions import CacheXError
60
60
  from .exceptions import RequestNotFoundError
61
+ from .exceptions import TTLTypeError
62
+ from .exceptions import TTLValueError
61
63
  from .headers import add_vary
62
64
  from .proxy import BackendProxy
63
65
  from .proxy import get_backend_or_fallback
@@ -378,16 +380,19 @@ def _seconds(name: str, value: int | timedelta | None) -> int | None:
378
380
  does not use ``validate_ttl``; the type and range rules are the same.
379
381
 
380
382
  Raises:
381
- CacheXError: If ``value`` is not an ``int`` or a ``timedelta``, is
382
- negative, is larger than ``MAX_TTL`` or is a ``timedelta`` that
383
- is not a whole number of seconds
383
+ TTLTypeError: If ``value`` is not an ``int`` or a ``timedelta``
384
+ TTLValueError: If ``value`` is negative, is larger than ``MAX_TTL`` or
385
+ is a ``timedelta`` that is not a whole number of seconds
386
+
387
+ Both are ``CacheXError`` subclasses, which ``@cache`` raised before, and
388
+ the same classes the backends raise (#337).
384
389
  """
385
390
  if value is None:
386
391
  return None
387
392
  if isinstance(value, timedelta):
388
393
  if value.microseconds:
389
394
  msg = f"{name} must be a whole number of seconds, got {value!r}"
390
- raise CacheXError(msg)
395
+ raise TTLValueError(msg)
391
396
  value = int(value.total_seconds())
392
397
  elif isinstance(value, bool) or not isinstance(value, int):
393
398
  # Checked here: at request time the backend would reject it, and
@@ -396,13 +401,13 @@ def _seconds(name: str, value: int | timedelta | None) -> int | None:
396
401
  f"{name} must be an int number of seconds or a timedelta, "
397
402
  f"got {type(value).__name__}"
398
403
  )
399
- raise CacheXError(msg)
404
+ raise TTLTypeError(msg)
400
405
  if value < 0:
401
406
  msg = f"{name} must not be negative"
402
- raise CacheXError(msg)
407
+ raise TTLValueError(msg)
403
408
  if value > MAX_TTL:
404
409
  msg = f"{name} must be at most {MAX_TTL} seconds"
405
- raise CacheXError(msg)
410
+ raise TTLValueError(msg)
406
411
  return value
407
412
 
408
413
 
@@ -232,12 +232,13 @@ def cached(
232
232
  A decorator turning the function into a ``CachedFunction``
233
233
 
234
234
  Raises:
235
- TypeError: When the decorator is applied, if ``ttl`` is not an
236
- ``int``, a ``timedelta`` or ``None``, or ``lock`` is not a
237
- ``bool`` or ``None``
238
- ValueError: When the decorator is applied, if ``ttl`` is zero,
235
+ TTLTypeError: When the decorator is applied, if ``ttl`` is not an
236
+ ``int``, a ``timedelta`` or ``None`` (also a ``TypeError``)
237
+ TTLValueError: When the decorator is applied, if ``ttl`` is zero,
239
238
  negative, larger than ``MAX_TTL`` or not a whole number of
240
- seconds
239
+ seconds (also a ``ValueError``)
240
+ TypeError: When the decorator is applied, if ``lock`` is not a
241
+ ``bool`` or ``None``
241
242
  CacheXError: When the decorator is applied, if ``key`` is not a
242
243
  ``str``, a callable or ``None``; and when the function is
243
244
  called, if the key cannot be built (see ``cache_key``)
@@ -0,0 +1,64 @@
1
+ """Custom exception classes for FastAPI-CacheX."""
2
+
3
+
4
+ class CacheXError(Exception):
5
+ """Base class for all exceptions in FastAPI-CacheX."""
6
+
7
+
8
+ class BackendNotFoundError(CacheXError):
9
+ """Exception raised when a cache backend is not found."""
10
+
11
+
12
+ class ProxyNotSetError(BackendNotFoundError):
13
+ """Exception raised when a manager proxy has no instance set.
14
+
15
+ Raised by ``CacheManagerProxy``. It subclasses ``BackendNotFoundError``,
16
+ which the manager proxies raised before 0.3.8, so existing handlers keep
17
+ catching it.
18
+ """
19
+
20
+
21
+ class RequestNotFoundError(CacheXError):
22
+ """Exception raised when a request is not found."""
23
+
24
+
25
+ class BackendUnavailableError(CacheXError):
26
+ """The cache backend's server could not be reached.
27
+
28
+ Raised by the Redis and Memcached backends for a transport failure: a
29
+ refused or reset connection, a timeout, or a server out of rotation. It is
30
+ chained to the client's own exception (``raise ... from``). The instance
31
+ raised is also of that exception's type, so an ``except`` clause naming
32
+ the client's exception (``redis.exceptions.ConnectionError``,
33
+ ``OSError``) keeps catching it (#260). The memory backend never raises it.
34
+ """
35
+
36
+
37
+ class InvalidTTLError(CacheXError):
38
+ """An invalid ``ttl``, wherever it is passed.
39
+
40
+ Raised for a ``ttl`` (or ``stale_ttl``, ``default_ttl``, ``lock_ttl``)
41
+ that ``@cache``, ``@cached``, a backend, ``CacheManager`` or ``CacheLock``
42
+ refuses. Catch this class to handle all of them; the subclasses keep the
43
+ built-in exception each place raised before (#337).
44
+ """
45
+
46
+
47
+ class TTLTypeError(InvalidTTLError, TypeError):
48
+ """A ``ttl`` that is not an ``int`` or a ``timedelta`` (``bool`` included).
49
+
50
+ Also a ``TypeError``, which the backends, ``CacheManager`` and
51
+ ``CacheLock`` raised before 0.5.1.
52
+ """
53
+
54
+
55
+ class TTLValueError(InvalidTTLError, ValueError):
56
+ """A ``ttl`` out of range, or a ``timedelta`` with a fraction of a second.
57
+
58
+ Also a ``ValueError``, which the backends, ``CacheManager`` and
59
+ ``CacheLock`` raised before 0.5.1.
60
+ """
61
+
62
+
63
+ class LockTimeoutError(CacheXError, TimeoutError):
64
+ """Exception raised when acquiring a lock or waiting for stampede protection times out."""
@@ -48,9 +48,10 @@ class CacheLock:
48
48
  key_prefix: Prefix for lock keys (default: "lock:")
49
49
 
50
50
  Raises:
51
- TypeError: If ``ttl`` is not an ``int`` or a ``timedelta``
52
- ValueError: If ``ttl`` is zero, negative, larger than ``MAX_TTL`` or
53
- not a whole number of seconds
51
+ TTLTypeError: If ``ttl`` is not an ``int`` or a ``timedelta`` (also
52
+ a ``TypeError``)
53
+ TTLValueError: If ``ttl`` is zero, negative, larger than ``MAX_TTL``
54
+ or not a whole number of seconds (also a ``ValueError``)
54
55
  """
55
56
 
56
57
  def __init__(
@@ -5,8 +5,11 @@ from collections.abc import Sequence
5
5
  from dataclasses import dataclass
6
6
  from dataclasses import field
7
7
  from typing import TYPE_CHECKING
8
+ from typing import Annotated
8
9
  from typing import Any
9
10
 
11
+ from pydantic import Field
12
+
10
13
  from .cache_key import CacheKey
11
14
  from .exceptions import BackendNotFoundError
12
15
  from .proxy import BackendProxy
@@ -42,19 +45,42 @@ class CacheHitSummary:
42
45
  cached_paths: list[str]
43
46
 
44
47
 
48
+ # Why the ``*_hits`` fields are deprecated, shown in the OpenAPI schema.
49
+ _HITS_DEPRECATED = (
50
+ "Counts cache entries, not hits; use {0} instead. Deprecated in 0.5.1, "
51
+ "removed in 0.6.0 (#336)."
52
+ )
53
+
54
+
45
55
  @dataclass
46
56
  class CacheHitsResponse:
47
57
  """Response for the ``/cached-hits`` endpoint.
48
58
 
49
- The ``*_hits`` fields count cached entries, not requests served from them.
59
+ ``entries``, ``total_entries``, ``valid_entries`` and ``expired_entries``
60
+ list and count the cached route entries. The ``cached_hits`` and
61
+ ``*_hits`` fields hold the same values under names that suggested
62
+ requests served from the cache; they are deprecated (marked so in the
63
+ OpenAPI schema) and removed in 0.6.0 (#336).
50
64
  """
51
65
 
52
- cached_hits: list[CacheHitRecord]
53
- total_hits: int
54
- valid_hits: int
55
- expired_hits: int
66
+ entries: list[CacheHitRecord]
67
+ total_entries: int
68
+ valid_entries: int
69
+ expired_entries: int
56
70
  unique_routes: int
57
71
  summary: CacheHitSummary
72
+ cached_hits: Annotated[
73
+ list[CacheHitRecord], Field(deprecated=_HITS_DEPRECATED.format("entries"))
74
+ ]
75
+ total_hits: Annotated[
76
+ int, Field(deprecated=_HITS_DEPRECATED.format("total_entries"))
77
+ ]
78
+ valid_hits: Annotated[
79
+ int, Field(deprecated=_HITS_DEPRECATED.format("valid_entries"))
80
+ ]
81
+ expired_hits: Annotated[
82
+ int, Field(deprecated=_HITS_DEPRECATED.format("expired_entries"))
83
+ ]
58
84
 
59
85
 
60
86
  @dataclass
@@ -174,17 +200,23 @@ def _cached_hits(entries: list[_Entry]) -> CacheHitsResponse:
174
200
  valid_hits = [h for h in cached_hits if not h.is_expired]
175
201
  routes_hit = {h.path for h in valid_hits}
176
202
 
203
+ total = len(cached_hits)
204
+ valid = len(valid_hits)
177
205
  return CacheHitsResponse(
178
- cached_hits=cached_hits,
179
- total_hits=len(cached_hits),
180
- valid_hits=len(valid_hits),
181
- expired_hits=len(cached_hits) - len(valid_hits),
206
+ entries=cached_hits,
207
+ total_entries=total,
208
+ valid_entries=valid,
209
+ expired_entries=total - valid,
182
210
  unique_routes=len(routes_hit),
183
211
  summary=CacheHitSummary(
184
- total_cached_entries=len(cached_hits),
185
- active_entries=len(valid_hits),
212
+ total_cached_entries=total,
213
+ active_entries=valid,
186
214
  cached_paths=sorted(routes_hit),
187
215
  ),
216
+ cached_hits=cached_hits,
217
+ total_hits=total,
218
+ valid_hits=valid,
219
+ expired_hits=total - valid,
188
220
  )
189
221
 
190
222
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-cachex"
3
- version = "0.5.0"
3
+ version = "0.5.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.5.0"
3
+ version = "0.5.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,26 +0,0 @@
1
- """Custom exception classes for FastAPI-CacheX."""
2
-
3
-
4
- class CacheXError(Exception):
5
- """Base class for all exceptions in FastAPI-CacheX."""
6
-
7
-
8
- class BackendNotFoundError(CacheXError):
9
- """Exception raised when a cache backend is not found."""
10
-
11
-
12
- class ProxyNotSetError(BackendNotFoundError):
13
- """Exception raised when a manager proxy has no instance set.
14
-
15
- Raised by ``CacheManagerProxy``. It subclasses ``BackendNotFoundError``,
16
- which the manager proxies raised before 0.3.8, so existing handlers keep
17
- catching it.
18
- """
19
-
20
-
21
- class RequestNotFoundError(CacheXError):
22
- """Exception raised when a request is not found."""
23
-
24
-
25
- class LockTimeoutError(CacheXError, TimeoutError):
26
- """Exception raised when acquiring a lock or waiting for stampede protection times out."""
File without changes
File without changes