fastapi-cachex 0.5.0__tar.gz → 0.5.2__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.5.0 → fastapi_cachex-0.5.2}/PKG-INFO +17 -16
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/README.md +16 -15
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/__init__.py +32 -0
- fastapi_cachex-0.5.2/fastapi_cachex/backends/_unavailable.py +100 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/backends/base.py +12 -9
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/backends/memcached.py +43 -3
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/backends/redis.py +31 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/cache.py +12 -7
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/cached.py +6 -5
- fastapi_cachex-0.5.2/fastapi_cachex/exceptions.py +64 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/lock.py +4 -3
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/routes.py +43 -11
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/pyproject.toml +5 -4
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/pyproject.toml.orig +7 -4
- fastapi_cachex-0.5.0/fastapi_cachex/exceptions.py +0 -26
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/LICENSE +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/_cache_control.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/_callables.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/_coalesce.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/_key_builders.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/_rendering.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/_stored_response.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/_vary.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/_warnings.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/backends/codec.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/backends/memory.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/cache_key.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/dependencies.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/headers.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/manager.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/proxy.py +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.5.0 → fastapi_cachex-0.5.2}/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.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
|
|
5
5
|
Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
|
|
6
6
|
Author: allen0099
|
|
@@ -50,11 +50,11 @@ Description-Content-Type: text/markdown
|
|
|
50
50
|
[](https://pypi.org/project/fastapi-cachex)
|
|
51
51
|
[](https://pypi.org/project/fastapi-cachex/)
|
|
52
52
|
|
|
53
|
-
[English](https://fastapi-cachex.readthedocs.io/en/
|
|
53
|
+
[English](https://fastapi-cachex.readthedocs.io/en/stable/) | [繁體中文](https://fastapi-cachex.readthedocs.io/zh-tw/stable/)
|
|
54
54
|
|
|
55
55
|
A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, and application-level caching.
|
|
56
56
|
|
|
57
|
-
**Documentation:** <https://fastapi-cachex.readthedocs.io/en/
|
|
57
|
+
**Documentation:** <https://fastapi-cachex.readthedocs.io/en/stable/> — guides and the full API reference.
|
|
58
58
|
|
|
59
59
|
## Features
|
|
60
60
|
|
|
@@ -115,7 +115,7 @@ async def report(cache: AppCache):
|
|
|
115
115
|
> Put `@cache` **below** the route decorator. FastAPI registers whatever function
|
|
116
116
|
> reaches `@app.get(...)`; with `@cache` on top, FastAPI registers the
|
|
117
117
|
> undecorated handler, so the route works but nothing is cached and nothing warns
|
|
118
|
-
> (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/
|
|
118
|
+
> (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/#decorator-order)).
|
|
119
119
|
>
|
|
120
120
|
> ```python
|
|
121
121
|
> @app.get("/items") # ✅ route decorator first,
|
|
@@ -132,7 +132,7 @@ async def report(cache: AppCache):
|
|
|
132
132
|
> with `private=True` or a per-user key builder plus `cache_authorized=True`
|
|
133
133
|
> (requests with `Authorization` or non-empty `request.session` data otherwise
|
|
134
134
|
> bypass the backend) — see
|
|
135
|
-
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/
|
|
135
|
+
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/#authenticated-endpoints).
|
|
136
136
|
|
|
137
137
|
## Documentation
|
|
138
138
|
|
|
@@ -141,18 +141,19 @@ a code block that includes one of the runnable examples renders on the site,
|
|
|
141
141
|
while GitHub shows only its `--8<--` include line (the sentence before each
|
|
142
142
|
block links the example file).
|
|
143
143
|
|
|
144
|
-
- [Migrating to 0.
|
|
145
|
-
- [Migrating to 0.
|
|
146
|
-
- [Migrating
|
|
147
|
-
- [
|
|
148
|
-
- [
|
|
149
|
-
- [
|
|
150
|
-
- [
|
|
151
|
-
- [
|
|
152
|
-
- [
|
|
144
|
+
- [Migrating to 0.6.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_6/) — what 0.6.0 will change, starting with the end of Python 3.10 support
|
|
145
|
+
- [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x
|
|
146
|
+
- [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x
|
|
147
|
+
- [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs
|
|
148
|
+
- [When to use it](https://fastapi-cachex.readthedocs.io/en/stable/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better
|
|
149
|
+
- [HTTP caching](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
|
|
150
|
+
- [Application cache](https://fastapi-cachex.readthedocs.io/en/stable/APP_CACHE/) — `CacheManager`
|
|
151
|
+
- [Backends](https://fastapi-cachex.readthedocs.io/en/stable/BACKENDS/) — choosing and configuring a backend, atomic primitives
|
|
152
|
+
- [Distributed lock](https://fastapi-cachex.readthedocs.io/en/stable/LOCK/) — `CacheLock` for multi-process mutual exclusion
|
|
153
|
+
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/stable/CACHE_FLOW/) — what happens inside a cached request
|
|
153
154
|
- [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
|
|
154
|
-
- [API reference](https://fastapi-cachex.readthedocs.io/en/
|
|
155
|
-
- [Development guide](https://fastapi-cachex.readthedocs.io/en/
|
|
155
|
+
- [API reference](https://fastapi-cachex.readthedocs.io/en/stable/api/http-caching/)
|
|
156
|
+
- [Development guide](https://fastapi-cachex.readthedocs.io/en/stable/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/stable/CONTRIBUTING/) · [Security policy](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md) — report vulnerabilities privately, not in public issues
|
|
156
157
|
- [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
|
|
157
158
|
|
|
158
159
|
## License
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
[](https://pypi.org/project/fastapi-cachex)
|
|
13
13
|
[](https://pypi.org/project/fastapi-cachex/)
|
|
14
14
|
|
|
15
|
-
[English](https://fastapi-cachex.readthedocs.io/en/
|
|
15
|
+
[English](https://fastapi-cachex.readthedocs.io/en/stable/) | [繁體中文](https://fastapi-cachex.readthedocs.io/zh-tw/stable/)
|
|
16
16
|
|
|
17
17
|
A high-performance caching extension for FastAPI: a server-side response cache with `Cache-Control` and `ETag` support, and application-level caching.
|
|
18
18
|
|
|
19
|
-
**Documentation:** <https://fastapi-cachex.readthedocs.io/en/
|
|
19
|
+
**Documentation:** <https://fastapi-cachex.readthedocs.io/en/stable/> — guides and the full API reference.
|
|
20
20
|
|
|
21
21
|
## Features
|
|
22
22
|
|
|
@@ -77,7 +77,7 @@ async def report(cache: AppCache):
|
|
|
77
77
|
> Put `@cache` **below** the route decorator. FastAPI registers whatever function
|
|
78
78
|
> reaches `@app.get(...)`; with `@cache` on top, FastAPI registers the
|
|
79
79
|
> undecorated handler, so the route works but nothing is cached and nothing warns
|
|
80
|
-
> (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/
|
|
80
|
+
> (see [Decorator order](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/#decorator-order)).
|
|
81
81
|
>
|
|
82
82
|
> ```python
|
|
83
83
|
> @app.get("/items") # ✅ route decorator first,
|
|
@@ -94,7 +94,7 @@ async def report(cache: AppCache):
|
|
|
94
94
|
> with `private=True` or a per-user key builder plus `cache_authorized=True`
|
|
95
95
|
> (requests with `Authorization` or non-empty `request.session` data otherwise
|
|
96
96
|
> bypass the backend) — see
|
|
97
|
-
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/
|
|
97
|
+
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/#authenticated-endpoints).
|
|
98
98
|
|
|
99
99
|
## Documentation
|
|
100
100
|
|
|
@@ -103,18 +103,19 @@ a code block that includes one of the runnable examples renders on the site,
|
|
|
103
103
|
while GitHub shows only its `--8<--` include line (the sentence before each
|
|
104
104
|
block links the example file).
|
|
105
105
|
|
|
106
|
-
- [Migrating to 0.
|
|
107
|
-
- [Migrating to 0.
|
|
108
|
-
- [Migrating
|
|
109
|
-
- [
|
|
110
|
-
- [
|
|
111
|
-
- [
|
|
112
|
-
- [
|
|
113
|
-
- [
|
|
114
|
-
- [
|
|
106
|
+
- [Migrating to 0.6.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_6/) — what 0.6.0 will change, starting with the end of Python 3.10 support
|
|
107
|
+
- [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x
|
|
108
|
+
- [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x
|
|
109
|
+
- [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs
|
|
110
|
+
- [When to use it](https://fastapi-cachex.readthedocs.io/en/stable/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better
|
|
111
|
+
- [HTTP caching](https://fastapi-cachex.readthedocs.io/en/stable/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
|
|
112
|
+
- [Application cache](https://fastapi-cachex.readthedocs.io/en/stable/APP_CACHE/) — `CacheManager`
|
|
113
|
+
- [Backends](https://fastapi-cachex.readthedocs.io/en/stable/BACKENDS/) — choosing and configuring a backend, atomic primitives
|
|
114
|
+
- [Distributed lock](https://fastapi-cachex.readthedocs.io/en/stable/LOCK/) — `CacheLock` for multi-process mutual exclusion
|
|
115
|
+
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/stable/CACHE_FLOW/) — what happens inside a cached request
|
|
115
116
|
- [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
|
|
116
|
-
- [API reference](https://fastapi-cachex.readthedocs.io/en/
|
|
117
|
-
- [Development guide](https://fastapi-cachex.readthedocs.io/en/
|
|
117
|
+
- [API reference](https://fastapi-cachex.readthedocs.io/en/stable/api/http-caching/)
|
|
118
|
+
- [Development guide](https://fastapi-cachex.readthedocs.io/en/stable/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/stable/CONTRIBUTING/) · [Security policy](https://github.com/allen0099/FastAPI-CacheX/blob/master/SECURITY.md) — report vulnerabilities privately, not in public issues
|
|
118
119
|
- [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
|
|
119
120
|
|
|
120
121
|
## License
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import warnings
|
|
4
6
|
from importlib.metadata import PackageNotFoundError
|
|
5
7
|
from importlib.metadata import version
|
|
6
8
|
|
|
@@ -16,10 +18,14 @@ from .dependencies import CacheBackend as CacheBackend
|
|
|
16
18
|
from .dependencies import get_app_cache as get_app_cache
|
|
17
19
|
from .dependencies import get_cache_backend as get_cache_backend
|
|
18
20
|
from .exceptions import BackendNotFoundError as BackendNotFoundError
|
|
21
|
+
from .exceptions import BackendUnavailableError as BackendUnavailableError
|
|
19
22
|
from .exceptions import CacheXError as CacheXError
|
|
23
|
+
from .exceptions import InvalidTTLError as InvalidTTLError
|
|
20
24
|
from .exceptions import LockTimeoutError as LockTimeoutError
|
|
21
25
|
from .exceptions import ProxyNotSetError as ProxyNotSetError
|
|
22
26
|
from .exceptions import RequestNotFoundError as RequestNotFoundError
|
|
27
|
+
from .exceptions import TTLTypeError as TTLTypeError
|
|
28
|
+
from .exceptions import TTLValueError as TTLValueError
|
|
23
29
|
from .lock import CacheLock as CacheLock
|
|
24
30
|
from .manager import CacheManager as CacheManager
|
|
25
31
|
from .manager_proxy import CacheManagerProxy as CacheManagerProxy
|
|
@@ -43,6 +49,28 @@ def _read_version() -> str:
|
|
|
43
49
|
|
|
44
50
|
__version__ = _read_version()
|
|
45
51
|
|
|
52
|
+
_PYTHON_310_NOTICE = (
|
|
53
|
+
"fastapi-cachex 0.6.0 will require Python 3.11 or later; Python 3.10 "
|
|
54
|
+
"reached its end of life in October 2026. Upgrade Python; see "
|
|
55
|
+
"https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_6/#python-311"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _warn_python_310(python: tuple[int, int] = sys.version_info[:2]) -> None:
|
|
60
|
+
"""Warn on import under Python 3.10, which 0.6.0 drops (#491).
|
|
61
|
+
|
|
62
|
+
``FutureWarning``, not ``DeprecationWarning``: it is shown by default.
|
|
63
|
+
``stacklevel=3`` skips this function and the package module; ``warnings``
|
|
64
|
+
skips the import machinery's frames itself, so the warning names the line
|
|
65
|
+
that imported ``fastapi_cachex`` (for ``importlib.import_module()`` it
|
|
66
|
+
names ``importlib/__init__.py``; 3.10 has no ``skip_file_prefixes``).
|
|
67
|
+
"""
|
|
68
|
+
if python < (3, 11):
|
|
69
|
+
warnings.warn(_PYTHON_310_NOTICE, FutureWarning, stacklevel=3)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_warn_python_310()
|
|
73
|
+
|
|
46
74
|
_package_logger = logging.getLogger("fastapi_cachex")
|
|
47
75
|
_package_logger.addHandler(
|
|
48
76
|
logging.NullHandler()
|
|
@@ -52,6 +80,7 @@ __all__ = [
|
|
|
52
80
|
"AppCache",
|
|
53
81
|
"BackendNotFoundError",
|
|
54
82
|
"BackendProxy",
|
|
83
|
+
"BackendUnavailableError",
|
|
55
84
|
"CacheBackend",
|
|
56
85
|
"CacheKey",
|
|
57
86
|
"CacheKeyBuilder",
|
|
@@ -60,9 +89,12 @@ __all__ = [
|
|
|
60
89
|
"CacheManagerProxy",
|
|
61
90
|
"CacheXError",
|
|
62
91
|
"CachedFunction",
|
|
92
|
+
"InvalidTTLError",
|
|
63
93
|
"LockTimeoutError",
|
|
64
94
|
"ProxyNotSetError",
|
|
65
95
|
"RequestNotFoundError",
|
|
96
|
+
"TTLTypeError",
|
|
97
|
+
"TTLValueError",
|
|
66
98
|
"__version__",
|
|
67
99
|
"add_routes",
|
|
68
100
|
"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
|
-
|
|
79
|
-
included)
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
cannot represent: it would accept the write and drop the
|
|
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
|
|
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
|
-
|
|
382
|
-
|
|
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
|
|
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
|
|
404
|
+
raise TTLTypeError(msg)
|
|
400
405
|
if value < 0:
|
|
401
406
|
msg = f"{name} must not be negative"
|
|
402
|
-
raise
|
|
407
|
+
raise TTLValueError(msg)
|
|
403
408
|
if value > MAX_TTL:
|
|
404
409
|
msg = f"{name} must be at most {MAX_TTL} seconds"
|
|
405
|
-
raise
|
|
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
|
-
|
|
236
|
-
``int``, a ``timedelta`` or ``None
|
|
237
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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=
|
|
185
|
-
active_entries=
|
|
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.
|
|
3
|
+
version = "0.5.2"
|
|
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"
|
|
@@ -59,7 +59,7 @@ dev = [
|
|
|
59
59
|
"coverage>=7.8.0",
|
|
60
60
|
"httpx2>=2.13.1",
|
|
61
61
|
"itsdangerous>=1.1.0",
|
|
62
|
-
"mypy>=2.4.
|
|
62
|
+
"mypy>=2.4.1",
|
|
63
63
|
"orjson>=3.10.16",
|
|
64
64
|
"pre-commit>=4.2.0",
|
|
65
65
|
"pymemcache>=4.0.0",
|
|
@@ -67,7 +67,7 @@ dev = [
|
|
|
67
67
|
"pytest-asyncio>=0.26.0",
|
|
68
68
|
"pytest-cov>=6.1.0",
|
|
69
69
|
"redis[hiredis]>=5.3.0",
|
|
70
|
-
"ruff>=0.17.
|
|
70
|
+
"ruff>=0.17.4",
|
|
71
71
|
"tox>=4.25.0",
|
|
72
72
|
"tox-uv>=1.29.0",
|
|
73
73
|
"ty>=0.0.1a34",
|
|
@@ -75,7 +75,7 @@ dev = [
|
|
|
75
75
|
"types-redis>=4.6.0.20241004",
|
|
76
76
|
]
|
|
77
77
|
docs = [
|
|
78
|
-
"zensical>=0.
|
|
78
|
+
"zensical>=0.1,<0.2; python_version >= '3.11'",
|
|
79
79
|
"mkdocstrings-python>=2.0",
|
|
80
80
|
"markdown-callouts>=0.4",
|
|
81
81
|
]
|
|
@@ -98,6 +98,7 @@ asyncio_default_fixture_loop_scope = "function"
|
|
|
98
98
|
filterwarnings = [
|
|
99
99
|
"error",
|
|
100
100
|
"ignore:The anyio.abc.BlockingPortal alias is deprecated:DeprecationWarning",
|
|
101
|
+
"ignore:fastapi-cachex 0.6.0 will require Python 3.11:FutureWarning",
|
|
101
102
|
]
|
|
102
103
|
|
|
103
104
|
[tool.ruff.lint]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-cachex"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.2"
|
|
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"
|
|
@@ -51,7 +51,7 @@ dev = [
|
|
|
51
51
|
"httpx2>=2.13.1",
|
|
52
52
|
# Starlette's SessionMiddleware, which tests of the session bypass use.
|
|
53
53
|
"itsdangerous>=1.1.0",
|
|
54
|
-
"mypy>=2.4.
|
|
54
|
+
"mypy>=2.4.1",
|
|
55
55
|
"orjson>=3.10.16",
|
|
56
56
|
"pre-commit>=4.2.0",
|
|
57
57
|
"pymemcache>=4.0.0",
|
|
@@ -59,7 +59,7 @@ dev = [
|
|
|
59
59
|
"pytest-asyncio>=0.26.0",
|
|
60
60
|
"pytest-cov>=6.1.0",
|
|
61
61
|
"redis[hiredis]>=5.3.0",
|
|
62
|
-
"ruff>=0.17.
|
|
62
|
+
"ruff>=0.17.4",
|
|
63
63
|
"tox>=4.25.0",
|
|
64
64
|
"tox-uv>=1.29.0",
|
|
65
65
|
"ty>=0.0.1a34",
|
|
@@ -68,7 +68,7 @@ dev = [
|
|
|
68
68
|
]
|
|
69
69
|
docs = [
|
|
70
70
|
# Pre-1.0: upgrades are deliberate, not automatic.
|
|
71
|
-
"zensical>=0.
|
|
71
|
+
"zensical>=0.1,<0.2; python_version >= '3.11'",
|
|
72
72
|
"mkdocstrings-python>=2.0",
|
|
73
73
|
"markdown-callouts>=0.4",
|
|
74
74
|
]
|
|
@@ -104,6 +104,9 @@ filterwarnings = [
|
|
|
104
104
|
# deprecates. Later starlette releases no longer use it; nothing in this
|
|
105
105
|
# package or its tests does.
|
|
106
106
|
"ignore:The anyio.abc.BlockingPortal alias is deprecated:DeprecationWarning",
|
|
107
|
+
# Importing the package warns on Python 3.10 until 0.6.0 drops it (#491);
|
|
108
|
+
# tests/test_python_support.py checks that warning.
|
|
109
|
+
"ignore:fastapi-cachex 0.6.0 will require Python 3.11:FutureWarning",
|
|
107
110
|
]
|
|
108
111
|
|
|
109
112
|
[tool.ruff.lint]
|
|
@@ -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
|
|
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
|