fastapi-cachex 0.3.5__tar.gz → 0.3.6__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 (40) hide show
  1. fastapi_cachex-0.3.6/PKG-INFO +135 -0
  2. fastapi_cachex-0.3.6/README.md +97 -0
  3. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/base.py +52 -0
  4. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/memcached.py +45 -0
  5. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/memory.py +31 -0
  6. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/redis.py +48 -0
  7. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/cache.py +2 -7
  8. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/dependencies.py +13 -7
  9. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/proxy.py +28 -0
  10. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/config.py +6 -0
  11. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/token_serializers.py +18 -0
  12. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/manager.py +2 -2
  13. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/pyproject.toml +7 -1
  14. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/pyproject.toml.orig +8 -1
  15. fastapi_cachex-0.3.5/PKG-INFO +0 -552
  16. fastapi_cachex-0.3.5/README.md +0 -515
  17. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/__init__.py +0 -0
  18. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/__init__.py +0 -0
  19. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/codec.py +0 -0
  20. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/backends/config.py +0 -0
  21. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/directives.py +0 -0
  22. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/exceptions.py +0 -0
  23. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/manager.py +0 -0
  24. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/manager_proxy.py +0 -0
  25. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/py.typed +0 -0
  26. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/routes.py +0 -0
  27. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/__init__.py +0 -0
  28. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/dependencies.py +0 -0
  29. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/exceptions.py +0 -0
  30. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/manager.py +0 -0
  31. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/middleware.py +0 -0
  32. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/models.py +0 -0
  33. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/proxy.py +0 -0
  34. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/session/security.py +0 -0
  35. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/__init__.py +0 -0
  36. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/dependencies.py +0 -0
  37. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/exceptions.py +0 -0
  38. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/models.py +0 -0
  39. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/state/proxy.py +0 -0
  40. {fastapi_cachex-0.3.5 → fastapi_cachex-0.3.6}/fastapi_cachex/types.py +0 -0
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-cachex
3
+ Version: 0.3.6
4
+ Summary: A caching library for FastAPI with support for Cache-Control, ETag, and multiple backends.
5
+ Keywords: fastapi,cache,etag,cache-control,redis,memcached,in-memory
6
+ Author: allen0099
7
+ Author-email: allen0099 <s96016641@gmail.com>
8
+ License-Expression: Apache-2.0
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Framework :: FastAPI
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
22
+ Requires-Dist: fastapi
23
+ Requires-Dist: itsdangerous
24
+ Requires-Dist: pydantic
25
+ Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
26
+ Requires-Dist: pymemcache ; extra == 'memcache'
27
+ Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
28
+ Requires-Dist: orjson ; extra == 'redis'
29
+ Requires-Python: >=3.10
30
+ Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
31
+ Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
32
+ Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
33
+ Project-URL: Documentation, https://fastapi-cachex.readthedocs.io/
34
+ Provides-Extra: jwt
35
+ Provides-Extra: memcache
36
+ Provides-Extra: redis
37
+ Description-Content-Type: text/markdown
38
+
39
+ # FastAPI-Cache X
40
+
41
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
42
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
43
+ [![Tests](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml/badge.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
44
+ [![Coverage Status](https://raw.githubusercontent.com/allen0099/FastAPI-CacheX/coverage-badge/coverage.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/coverage.yml)
45
+
46
+ [![Downloads](https://static.pepy.tech/badge/fastapi-cachex)](https://pepy.tech/project/fastapi-cachex)
47
+ [![Weekly downloads](https://static.pepy.tech/badge/fastapi-cachex/week)](https://pepy.tech/project/fastapi-cachex)
48
+ [![Monthly downloads](https://static.pepy.tech/badge/fastapi-cachex/month)](https://pepy.tech/project/fastapi-cachex)
49
+
50
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-cachex.svg?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/fastapi-cachex)
51
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/fastapi-cachex/)
52
+
53
+ [English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/docs/README.zh-TW.md)
54
+
55
+ A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
56
+
57
+ **Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
58
+
59
+ ## Features
60
+
61
+ - **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
62
+ `ETag` / `If-None-Match` (304) and per-route invalidation.
63
+ - **Application cache** — `CacheManager` for caching arbitrary JSON values in
64
+ your own code, with compute-on-miss `get_or_set()`.
65
+ - **Backends** — in-memory, Redis and Memcached, with atomic counters,
66
+ one-shot values and locks.
67
+ - **Sessions (optional)** — HMAC-signed or JWT session tokens over headers,
68
+ bearer tokens or cookies, with sliding expiration and IP/User-Agent binding.
69
+ - **OAuth state** — one-time state tokens for CSRF protection in OAuth/OIDC flows.
70
+
71
+ ## Installation
72
+
73
+ ```bash
74
+ uv add fastapi-cachex
75
+ ```
76
+
77
+ Everything in the core package works with the in-memory backend. The other
78
+ backends and the optional session transports ship as extras:
79
+
80
+ | Extra | Install | Pulls in | Needed for |
81
+ |-------|---------|----------|------------|
82
+ | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
83
+ | `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
84
+ | `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
85
+
86
+ Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
87
+
88
+ ## Quick Start
89
+
90
+ ```python
91
+ from fastapi import FastAPI
92
+
93
+ from fastapi_cachex import AppCache, BackendProxy, cache
94
+ from fastapi_cachex.backends import MemoryBackend
95
+
96
+ app = FastAPI()
97
+ BackendProxy.set(MemoryBackend()) # or AsyncRedisCacheBackend / MemcachedBackend
98
+
99
+
100
+ @app.get("/items/{item_id}")
101
+ @cache(ttl=60) # served from the cache for 60 seconds, with ETag revalidation
102
+ async def read_item(item_id: int):
103
+ return {"item_id": item_id}
104
+
105
+
106
+ def build_report() -> dict:
107
+ return {"total": 42} # stands in for something slow
108
+
109
+
110
+ @app.get("/report")
111
+ async def report(cache: AppCache):
112
+ # Cache any JSON value in your own code.
113
+ return await cache.get_or_set("report", build_report, ttl=300)
114
+ ```
115
+
116
+ > [!WARNING]
117
+ > The default cache key carries no user identity. Cache authenticated endpoints
118
+ > with `private=True` or a per-user key builder — see
119
+ > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
120
+
121
+ ## Documentation
122
+
123
+ - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
124
+ - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
125
+ - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
126
+ - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
127
+ - [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
128
+ - [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
129
+ - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
130
+ - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
131
+ - [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
132
+
133
+ ## License
134
+
135
+ This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/allen0099/FastAPI-CacheX/blob/master/LICENSE) file for details.
@@ -0,0 +1,97 @@
1
+ # FastAPI-Cache X
2
+
3
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
4
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
5
+ [![Tests](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml/badge.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/test.yml)
6
+ [![Coverage Status](https://raw.githubusercontent.com/allen0099/FastAPI-CacheX/coverage-badge/coverage.svg)](https://github.com/allen0099/FastAPI-CacheX/actions/workflows/coverage.yml)
7
+
8
+ [![Downloads](https://static.pepy.tech/badge/fastapi-cachex)](https://pepy.tech/project/fastapi-cachex)
9
+ [![Weekly downloads](https://static.pepy.tech/badge/fastapi-cachex/week)](https://pepy.tech/project/fastapi-cachex)
10
+ [![Monthly downloads](https://static.pepy.tech/badge/fastapi-cachex/month)](https://pepy.tech/project/fastapi-cachex)
11
+
12
+ [![PyPI version](https://img.shields.io/pypi/v/fastapi-cachex.svg?logo=pypi&logoColor=gold&label=PyPI)](https://pypi.org/project/fastapi-cachex)
13
+ [![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-cachex.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/fastapi-cachex/)
14
+
15
+ [English](https://github.com/allen0099/FastAPI-CacheX/blob/master/README.md) | [繁體中文](https://github.com/allen0099/FastAPI-CacheX/blob/master/docs/README.zh-TW.md)
16
+
17
+ A high-performance caching extension for FastAPI, providing comprehensive HTTP caching support and optional session management.
18
+
19
+ **Documentation:** <https://fastapi-cachex.readthedocs.io/en/latest/> — guides and the full API reference.
20
+
21
+ ## Features
22
+
23
+ - **HTTP caching** — a `@cache` decorator for GET routes with `Cache-Control`,
24
+ `ETag` / `If-None-Match` (304) and per-route invalidation.
25
+ - **Application cache** — `CacheManager` for caching arbitrary JSON values in
26
+ your own code, with compute-on-miss `get_or_set()`.
27
+ - **Backends** — in-memory, Redis and Memcached, with atomic counters,
28
+ one-shot values and locks.
29
+ - **Sessions (optional)** — HMAC-signed or JWT session tokens over headers,
30
+ bearer tokens or cookies, with sliding expiration and IP/User-Agent binding.
31
+ - **OAuth state** — one-time state tokens for CSRF protection in OAuth/OIDC flows.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ uv add fastapi-cachex
37
+ ```
38
+
39
+ Everything in the core package works with the in-memory backend. The other
40
+ backends and the optional session transports ship as extras:
41
+
42
+ | Extra | Install | Pulls in | Needed for |
43
+ |-------|---------|----------|------------|
44
+ | `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
45
+ | `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
46
+ | `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
47
+
48
+ Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
49
+
50
+ ## Quick Start
51
+
52
+ ```python
53
+ from fastapi import FastAPI
54
+
55
+ from fastapi_cachex import AppCache, BackendProxy, cache
56
+ from fastapi_cachex.backends import MemoryBackend
57
+
58
+ app = FastAPI()
59
+ BackendProxy.set(MemoryBackend()) # or AsyncRedisCacheBackend / MemcachedBackend
60
+
61
+
62
+ @app.get("/items/{item_id}")
63
+ @cache(ttl=60) # served from the cache for 60 seconds, with ETag revalidation
64
+ async def read_item(item_id: int):
65
+ return {"item_id": item_id}
66
+
67
+
68
+ def build_report() -> dict:
69
+ return {"total": 42} # stands in for something slow
70
+
71
+
72
+ @app.get("/report")
73
+ async def report(cache: AppCache):
74
+ # Cache any JSON value in your own code.
75
+ return await cache.get_or_set("report", build_report, ttl=300)
76
+ ```
77
+
78
+ > [!WARNING]
79
+ > The default cache key carries no user identity. Cache authenticated endpoints
80
+ > with `private=True` or a per-user key builder — see
81
+ > [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).
82
+
83
+ ## Documentation
84
+
85
+ - [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
86
+ - [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
87
+ - [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
88
+ - [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
89
+ - [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
90
+ - [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
91
+ - [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
92
+ - [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
93
+ - [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
94
+
95
+ ## License
96
+
97
+ This project is licensed under the Apache License 2.0 - see the [LICENSE](https://github.com/allen0099/FastAPI-CacheX/blob/master/LICENSE) file for details.
@@ -83,6 +83,58 @@ class BaseCacheBackend(ABC):
83
83
  await self.delete(key)
84
84
  return value
85
85
 
86
+ async def set_if_absent(
87
+ self, key: str, value: CacheEntry, ttl: int | None = None
88
+ ) -> bool:
89
+ """Store ``value`` only when ``key`` does not exist yet.
90
+
91
+ The building block for locks and slots: of several concurrent callers
92
+ exactly one stores its value and gets ``True``, every other caller
93
+ gets ``False`` and the stored value is left untouched. An expired key
94
+ counts as absent. Pair it with ``delete_if_equals`` to release only
95
+ what you still hold.
96
+
97
+ The base implementation is a best-effort, NON-atomic get-then-set
98
+ fallback for third-party backends; the built-in backends override it
99
+ with an atomic implementation.
100
+
101
+ Args:
102
+ key: Cache key to claim
103
+ value: Entry to store, typically carrying a unique owner token
104
+ ttl: Time to live in seconds (``None`` = never expires)
105
+
106
+ Returns:
107
+ Whether ``value`` was stored
108
+ """
109
+ if await self.get(key) is not None:
110
+ return False
111
+ await self.set(key, value, ttl=ttl)
112
+ return True
113
+
114
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
115
+ """Remove ``key`` only while it still holds ``expected``.
116
+
117
+ Releasing a lock with a plain ``delete`` is unsafe: if the holder's
118
+ entry expired and someone else claimed the key in the meantime, the
119
+ delete removes the new holder's entry. Comparing against the value the
120
+ caller stored makes the release a no-op in that case.
121
+
122
+ The base implementation is a best-effort, NON-atomic get-compare-delete
123
+ fallback for third-party backends; the built-in backends override it
124
+ with an atomic implementation.
125
+
126
+ Args:
127
+ key: Cache key to release
128
+ expected: The entry the caller stored (compared with ``==``)
129
+
130
+ Returns:
131
+ Whether the entry was removed
132
+ """
133
+ if await self.get(key) != expected:
134
+ return False
135
+ await self.delete(key)
136
+ return True
137
+
86
138
  async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
87
139
  """Atomically add ``delta`` to the integer counter stored at ``key``.
88
140
 
@@ -167,6 +167,51 @@ class MemcachedBackend(BaseCacheBackend):
167
167
  logger.debug("Memcached GET_AND_DELETE HIT; key=%s", key)
168
168
  return decode_entry(raw)
169
169
 
170
+ async def set_if_absent(
171
+ self, key: str, value: CacheEntry, ttl: int | None = None
172
+ ) -> bool:
173
+ """Atomically store ``value`` unless ``key`` exists (see base class).
174
+
175
+ Memcached's ``ADD`` is exactly this operation.
176
+ """
177
+ stored = await asyncio.to_thread(
178
+ self.client.add,
179
+ self._make_key(key),
180
+ encode_entry(value),
181
+ _expiry(ttl),
182
+ noreply=False,
183
+ )
184
+ logger.debug(
185
+ "Memcached SET_IF_ABSENT %s; key=%s ttl=%s",
186
+ "STORED" if stored else "EXISTS",
187
+ key,
188
+ ttl,
189
+ )
190
+ return bool(stored)
191
+
192
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
193
+ """Atomically remove ``key`` while it holds ``expected`` (see base class).
194
+
195
+ The classic protocol's DELETE takes no CAS token, so the release is a
196
+ CAS write with a negative exptime, which Memcached treats as "expired
197
+ immediately": it succeeds only if nothing wrote the key since ``GETS``
198
+ read the value that was compared.
199
+ """
200
+ prefixed_key = self._make_key(key)
201
+ raw, cas_token = await asyncio.to_thread(self.client.gets, prefixed_key)
202
+ if raw is None or decode_entry(raw) != expected:
203
+ logger.debug("Memcached DELETE_IF_EQUALS MISMATCH; key=%s", key)
204
+ return False
205
+ deleted = await asyncio.to_thread(
206
+ self.client.cas, prefixed_key, b"", cas_token, -1, noreply=False
207
+ )
208
+ logger.debug(
209
+ "Memcached DELETE_IF_EQUALS %s; key=%s",
210
+ "HIT" if deleted else "LOST RACE",
211
+ key,
212
+ )
213
+ return bool(deleted)
214
+
170
215
  def _add_delta(self, prefixed_key: str, delta: int) -> int | None:
171
216
  """Apply ``delta`` with INCR/DECR; ``None`` when the key does not exist."""
172
217
  if delta < 0:
@@ -157,6 +157,37 @@ class MemoryBackend(BaseCacheBackend):
157
157
  logger.debug("Memory cache GET_AND_DELETE HIT; key=%s", key)
158
158
  return item.value
159
159
 
160
+ async def set_if_absent(
161
+ self, key: str, value: CacheEntry, ttl: int | None = None
162
+ ) -> bool:
163
+ """Atomically store ``value`` unless ``key`` exists (see base class)."""
164
+ self._ensure_cleanup_started()
165
+
166
+ async with self.lock:
167
+ now = time.time()
168
+ item = self.cache.get(key)
169
+ if item is not None and _is_live(item, now):
170
+ logger.debug("Memory cache SET_IF_ABSENT EXISTS; key=%s", key)
171
+ return False
172
+ expiry = now + ttl if ttl is not None else None
173
+ self.cache[key] = CacheItem(value=value, expiry=expiry)
174
+ logger.debug("Memory cache SET_IF_ABSENT STORED; key=%s ttl=%s", key, ttl)
175
+ return True
176
+
177
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
178
+ """Atomically remove ``key`` while it holds ``expected`` (see base class)."""
179
+ async with self.lock:
180
+ item = self.cache.get(key)
181
+ if item is None or not _is_live(item, time.time()):
182
+ logger.debug("Memory cache DELETE_IF_EQUALS MISS; key=%s", key)
183
+ return False
184
+ if item.value != expected:
185
+ logger.debug("Memory cache DELETE_IF_EQUALS MISMATCH; key=%s", key)
186
+ return False
187
+ del self.cache[key]
188
+ logger.debug("Memory cache DELETE_IF_EQUALS HIT; key=%s", key)
189
+ return True
190
+
160
191
  async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
161
192
  """Atomically add ``delta`` to the counter at ``key`` (see base class).
162
193
 
@@ -36,6 +36,15 @@ end
36
36
  return value
37
37
  """
38
38
 
39
+ # DEL that only fires while the key still holds the exact bytes the caller read,
40
+ # so a value replaced in the meantime survives. KEYS[1] = key, ARGV[1] = bytes.
41
+ _DELETE_IF_EQUALS_SCRIPT = """
42
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
43
+ return redis.call('DEL', KEYS[1])
44
+ end
45
+ return 0
46
+ """
47
+
39
48
 
40
49
  class AsyncRedisCacheBackend(BaseCacheBackend):
41
50
  """Async Redis cache backend implementation.
@@ -108,6 +117,9 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
108
117
  # Registered once so every call is an EVALSHA (redis-py reloads the
109
118
  # script transparently if the server has flushed it).
110
119
  self._increment_script = self.client.register_script(_INCREMENT_SCRIPT)
120
+ self._delete_if_equals_script = self.client.register_script(
121
+ _DELETE_IF_EQUALS_SCRIPT
122
+ )
111
123
 
112
124
  @staticmethod
113
125
  def load_from_config(config: RedisConfig) -> "AsyncRedisCacheBackend":
@@ -189,6 +201,42 @@ class AsyncRedisCacheBackend(BaseCacheBackend):
189
201
  logger.debug("Redis GETDEL %s; key=%s", "HIT" if value else "MISS", key)
190
202
  return value
191
203
 
204
+ async def set_if_absent(
205
+ self, key: str, value: CacheEntry, ttl: int | None = None
206
+ ) -> bool:
207
+ """Atomically store ``value`` unless ``key`` exists (see base class).
208
+
209
+ A single ``SET ... NX EX``.
210
+ """
211
+ stored = await self.client.set(
212
+ self._make_key(key), encode_entry(value), ex=ttl, nx=True
213
+ )
214
+ logger.debug(
215
+ "Redis SET_IF_ABSENT %s; key=%s ttl=%s",
216
+ "STORED" if stored else "EXISTS",
217
+ key,
218
+ ttl,
219
+ )
220
+ return bool(stored)
221
+
222
+ async def delete_if_equals(self, key: str, expected: CacheEntry) -> bool:
223
+ """Atomically remove ``key`` while it holds ``expected`` (see base class).
224
+
225
+ The stored value is decoded and compared here, then a Lua script
226
+ deletes the key only if it still holds the bytes that were compared,
227
+ so a value written in between is never removed.
228
+ """
229
+ prefixed_key = self._make_key(key)
230
+ raw = await self.client.get(prefixed_key)
231
+ if raw is None or decode_entry(raw) != expected:
232
+ logger.debug("Redis DELETE_IF_EQUALS MISMATCH; key=%s", key)
233
+ return False
234
+ deleted = await self._delete_if_equals_script(keys=[prefixed_key], args=[raw])
235
+ logger.debug(
236
+ "Redis DELETE_IF_EQUALS %s; key=%s", "HIT" if deleted else "LOST RACE", key
237
+ )
238
+ return bool(deleted)
239
+
192
240
  async def increment(self, key: str, delta: int = 1, ttl: int | None = None) -> int:
193
241
  """Atomically add ``delta`` to the counter at ``key`` (see base class).
194
242
 
@@ -26,12 +26,12 @@ from starlette.status import HTTP_206_PARTIAL_CONTENT
26
26
  from starlette.status import HTTP_300_MULTIPLE_CHOICES
27
27
  from starlette.status import HTTP_304_NOT_MODIFIED
28
28
 
29
- from .backends import MemoryBackend
30
29
  from .directives import DirectiveType
31
30
  from .exceptions import BackendNotFoundError
32
31
  from .exceptions import CacheXError
33
32
  from .exceptions import RequestNotFoundError
34
33
  from .proxy import BackendProxy
34
+ from .proxy import get_backend_or_fallback
35
35
  from .types import CACHE_KEY_SEPARATOR
36
36
  from .types import CacheEntry
37
37
  from .types import CacheKeyBuilder
@@ -470,12 +470,7 @@ def cache(
470
470
  @wraps(func)
471
471
  async def wrapper(*args: Any, **kwargs: Any) -> Response:
472
472
  # Resolve backend on every request to support lifespan-configured backends
473
- try:
474
- cache_backend = BackendProxy.get()
475
- except BackendNotFoundError:
476
- cache_backend = MemoryBackend()
477
- BackendProxy.set(cache_backend)
478
- logger.debug("No backend configured; using MemoryBackend fallback")
473
+ cache_backend = get_backend_or_fallback()
479
474
 
480
475
  if found_request:
481
476
  req: Request | None = kwargs.get(request_name)
@@ -1,15 +1,18 @@
1
1
  """FastAPI dependency injection utilities for cache control."""
2
2
 
3
+ import threading
3
4
  from typing import Annotated
4
5
 
5
6
  from fastapi import Depends
6
7
 
7
- from .backends import MemoryBackend
8
8
  from .backends.base import BaseCacheBackend
9
9
  from .exceptions import BackendNotFoundError
10
10
  from .manager import CacheManager
11
11
  from .manager_proxy import CacheManagerProxy
12
12
  from .proxy import BackendProxy
13
+ from .proxy import get_backend_or_fallback
14
+
15
+ _manager_lock = threading.Lock()
13
16
 
14
17
 
15
18
  def get_cache_backend() -> BaseCacheBackend:
@@ -36,14 +39,17 @@ def get_app_cache() -> CacheManager:
36
39
  try:
37
40
  return CacheManagerProxy.get()
38
41
  except BackendNotFoundError:
42
+ pass
43
+ # Checked again under the lock: FastAPI runs this sync dependency in a
44
+ # worker thread, so concurrent first requests would otherwise each build
45
+ # and register their own manager (and fallback backend).
46
+ with _manager_lock:
39
47
  try:
40
- backend = BackendProxy.get()
48
+ return CacheManagerProxy.get()
41
49
  except BackendNotFoundError:
42
- backend = MemoryBackend()
43
- BackendProxy.set(backend)
44
- manager = CacheManager(backend=backend)
45
- CacheManagerProxy.set(manager)
46
- return manager
50
+ manager = CacheManager(backend=get_backend_or_fallback())
51
+ CacheManagerProxy.set(manager)
52
+ return manager
47
53
 
48
54
 
49
55
  AppCache = Annotated[CacheManager, Depends(get_app_cache)]
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import threading
5
6
  import warnings
6
7
  from logging import getLogger
7
8
  from typing import Generic
@@ -9,12 +10,17 @@ from typing import NoReturn
9
10
  from typing import TypeVar
10
11
 
11
12
  from .backends import BaseCacheBackend
13
+ from .backends import MemoryBackend
12
14
  from .exceptions import BackendNotFoundError
13
15
 
14
16
  ProxyInstance = TypeVar("ProxyInstance")
15
17
 
16
18
  logger = getLogger(__name__)
17
19
 
20
+ # Serialises the lazy fallback below. `get_app_cache` is a sync dependency that
21
+ # FastAPI runs in a worker thread, so two first requests can reach it at once.
22
+ _fallback_lock = threading.Lock()
23
+
18
24
 
19
25
  class ProxyMeta(type):
20
26
  """Metaclass for BackendProxy to prevent instantiation."""
@@ -94,3 +100,25 @@ class BackendProxy(ProxyBase[BaseCacheBackend]):
94
100
  stacklevel=2,
95
101
  )
96
102
  BackendProxy.set(backend)
103
+
104
+
105
+ def get_backend_or_fallback() -> BaseCacheBackend:
106
+ """Return the configured backend, registering a `MemoryBackend` if none is.
107
+
108
+ Used by `@cache` and the `AppCache` dependency. The check and the
109
+ registration happen under one lock, so concurrent first callers — including
110
+ ones on worker threads — all end up with the same fallback instead of each
111
+ installing its own and overwriting the others.
112
+ """
113
+ try:
114
+ return BackendProxy.get()
115
+ except BackendNotFoundError:
116
+ pass
117
+ with _fallback_lock:
118
+ try:
119
+ return BackendProxy.get()
120
+ except BackendNotFoundError:
121
+ backend = MemoryBackend()
122
+ BackendProxy.set(backend)
123
+ logger.debug("No backend configured; using MemoryBackend fallback")
124
+ return backend
@@ -30,6 +30,12 @@ JWT_ALGORITHMS = frozenset(
30
30
  }
31
31
  )
32
32
 
33
+ # The algorithms the built-in `JWTTokenSerializer` can use: it signs and
34
+ # verifies with the single `secret_key` string. The asymmetric ones above need
35
+ # a private key to sign and a public key to verify, so they only work with a
36
+ # custom `token_serializer`.
37
+ JWT_HMAC_ALGORITHMS = frozenset({"HS256", "HS384", "HS512"})
38
+
33
39
 
34
40
  class SessionConfig(BaseModel):
35
41
  """Session configuration settings."""
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
18
18
  from typing import Any
19
19
  from typing import Protocol
20
20
 
21
+ from .config import JWT_HMAC_ALGORITHMS
21
22
  from .models import SessionToken
22
23
 
23
24
  if TYPE_CHECKING: # Import for typing only to avoid circular import concerns
@@ -109,7 +110,24 @@ class JWTTokenSerializer:
109
110
  config: Session configuration instance.
110
111
  jwt_module: Optional JWT-compatible module providing ``encode`` and
111
112
  ``decode``; defaults to importing ``jwt`` (PyJWT).
113
+
114
+ Raises:
115
+ ValueError: If ``config.jwt_algorithm`` is asymmetric. This
116
+ serializer signs and verifies with the ``secret_key`` string,
117
+ which only the HMAC algorithms can use; an asymmetric
118
+ algorithm needs a custom ``token_serializer`` that holds the
119
+ key pair.
112
120
  """
121
+ if config.jwt_algorithm not in JWT_HMAC_ALGORITHMS:
122
+ supported = ", ".join(sorted(JWT_HMAC_ALGORITHMS))
123
+ msg = (
124
+ f"jwt_algorithm {config.jwt_algorithm!r} needs a private/public "
125
+ f"key pair, but the built-in JWT serializer signs with secret_key; "
126
+ f"use one of {supported}, or pass a custom token_serializer to "
127
+ f"SessionManager"
128
+ )
129
+ raise ValueError(msg)
130
+
113
131
  if jwt_module is not None:
114
132
  self.jwt_encoder = jwt_module
115
133
  else:
@@ -118,8 +118,8 @@ class StateManager:
118
118
  Returns:
119
119
  The generated state string
120
120
 
121
- Raises:
122
- StateDataError: If backend storage fails
121
+ Backend errors (for example a Redis connection error) propagate
122
+ unchanged; they are not wrapped in ``StateDataError``.
123
123
  """
124
124
  # Generate a random state string (32 bytes = 256 bits of entropy)
125
125
  state = secrets.token_urlsafe(32)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-cachex"
3
- version = "0.3.5"
3
+ version = "0.3.6"
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"
@@ -43,6 +43,7 @@ email = "s96016641@gmail.com"
43
43
  Homepage = "https://github.com/allen0099/FastAPI-CacheX"
44
44
  Repository = "https://github.com/allen0099/FastAPI-CacheX.git"
45
45
  Issues = "https://github.com/allen0099/FastAPI-CacheX/issues"
46
+ Documentation = "https://fastapi-cachex.readthedocs.io/"
46
47
 
47
48
  [project.optional-dependencies]
48
49
  memcache = ["pymemcache"]
@@ -72,6 +73,11 @@ dev = [
72
73
  "types-orjson>=3.6.2",
73
74
  "types-redis>=4.6.0.20241004",
74
75
  ]
76
+ docs = [
77
+ "zensical>=0.0.65,<0.1",
78
+ "mkdocstrings-python>=2.0",
79
+ "markdown-callouts>=0.4",
80
+ ]
75
81
 
76
82
  [build-system]
77
83
  requires = ["uv_build>=0.12,<0.13"]