fastapi-cachex 0.3.4__tar.gz → 0.3.5__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.3.4 → fastapi_cachex-0.3.5}/PKG-INFO +212 -11
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/README.md +210 -8
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/__init__.py +19 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/base.py +40 -2
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/codec.py +12 -2
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/memcached.py +49 -6
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/memory.py +16 -12
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/redis.py +2 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/cache.py +230 -16
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/dependencies.py +13 -1
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/proxy.py +2 -2
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/config.py +43 -1
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/manager.py +1 -1
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/middleware.py +48 -46
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/security.py +9 -2
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/token_serializers.py +4 -4
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/types.py +12 -1
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/pyproject.toml +8 -4
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/pyproject.toml.orig +5 -4
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/__init__.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/backends/config.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/directives.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/exceptions.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/manager.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/manager_proxy.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/py.typed +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/routes.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/__init__.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/dependencies.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/exceptions.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/models.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/session/proxy.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/__init__.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/dependencies.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/exceptions.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/manager.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/models.py +0 -0
- {fastapi_cachex-0.3.4 → fastapi_cachex-0.3.5}/fastapi_cachex/state/proxy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-cachex
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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
|
|
@@ -20,12 +20,12 @@ Classifier: Framework :: FastAPI
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
21
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
22
22
|
Requires-Dist: fastapi
|
|
23
|
+
Requires-Dist: itsdangerous
|
|
23
24
|
Requires-Dist: pydantic
|
|
24
25
|
Requires-Dist: pyjwt>=2.9.0 ; extra == 'jwt'
|
|
25
26
|
Requires-Dist: pymemcache ; extra == 'memcache'
|
|
26
27
|
Requires-Dist: redis[hiredis]>=5.3.0 ; extra == 'redis'
|
|
27
28
|
Requires-Dist: orjson ; extra == 'redis'
|
|
28
|
-
Requires-Dist: itsdangerous ; extra == 'starlette'
|
|
29
29
|
Requires-Python: >=3.10
|
|
30
30
|
Project-URL: Homepage, https://github.com/allen0099/FastAPI-CacheX
|
|
31
31
|
Project-URL: Repository, https://github.com/allen0099/FastAPI-CacheX.git
|
|
@@ -33,7 +33,6 @@ Project-URL: Issues, https://github.com/allen0099/FastAPI-CacheX/issues
|
|
|
33
33
|
Provides-Extra: jwt
|
|
34
34
|
Provides-Extra: memcache
|
|
35
35
|
Provides-Extra: redis
|
|
36
|
-
Provides-Extra: starlette
|
|
37
36
|
Description-Content-Type: text/markdown
|
|
38
37
|
|
|
39
38
|
# FastAPI-Cache X
|
|
@@ -72,7 +71,9 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
|
|
|
72
71
|
- Secure session management with HMAC-SHA256 token signing
|
|
73
72
|
- Optional JWT token format for interoperability (install extra `jwt`)
|
|
74
73
|
- IP address and User-Agent binding (optional security features)
|
|
75
|
-
- Header
|
|
74
|
+
- Header, bearer token and cookie transports (cookies via
|
|
75
|
+
`FastAPICacheXSessionMiddleware`; the older `SessionMiddleware` is deprecated
|
|
76
|
+
and removed in 0.4.0 — see [Session Management Guide](docs/SESSION.md))
|
|
76
77
|
- Automatic session renewal (sliding expiration)
|
|
77
78
|
- Flash messages for cross-request communication
|
|
78
79
|
- Multiple backend support (Redis, Memcached, In-Memory)
|
|
@@ -102,11 +103,19 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
|
|
|
102
103
|
uv add fastapi-cachex
|
|
103
104
|
```
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
Everything in the core package works with the in-memory backend. The other
|
|
107
|
+
backends and the optional session transports ship as extras:
|
|
106
108
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
| Extra | Install | Pulls in | Needed for |
|
|
110
|
+
|-------|---------|----------|------------|
|
|
111
|
+
| `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
|
|
112
|
+
| `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
|
|
113
|
+
| `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
|
|
114
|
+
|
|
115
|
+
The `starlette` extra is gone: `itsdangerous` is a base dependency now, so
|
|
116
|
+
`FastAPICacheXSessionMiddleware` works on a plain install.
|
|
117
|
+
|
|
118
|
+
Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
|
|
110
119
|
|
|
111
120
|
### Development Installation
|
|
112
121
|
|
|
@@ -145,9 +154,88 @@ async def non_store_endpoint():
|
|
|
145
154
|
@app.get("/clear_cache")
|
|
146
155
|
async def remove_cache(cache: CacheBackend):
|
|
147
156
|
await cache.clear_path("/path/to/clear") # Clear cache for a specific path
|
|
148
|
-
|
|
157
|
+
# Patterns match the whole key, not just the path
|
|
158
|
+
await cache.clear_pattern("GET|||*|||/path/to/clear/*")
|
|
149
159
|
```
|
|
150
160
|
|
|
161
|
+
`clear_pattern` globs the whole logical key. HTTP cache keys look like
|
|
162
|
+
`method|||host|||path|||query`, so a pattern that is only a path matches
|
|
163
|
+
nothing — use `clear_path(path, include_params=True)` when the path is what you
|
|
164
|
+
mean, and keep `clear_pattern` for keys you built yourself.
|
|
165
|
+
|
|
166
|
+
### Invalidating a Single Cached Route
|
|
167
|
+
|
|
168
|
+
`clear_path`/`clear_pattern` work on ranges of keys. To drop exactly the entry a
|
|
169
|
+
`@cache`-decorated route would use — typically right after a mutation — call
|
|
170
|
+
`invalidate()`, which rebuilds that route's key with the same key builder and
|
|
171
|
+
deletes it:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from fastapi import Request
|
|
175
|
+
from starlette.requests import Request as StarletteRequest
|
|
176
|
+
|
|
177
|
+
from fastapi_cachex import cache, invalidate
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.get("/items/{item_id}")
|
|
181
|
+
@cache(ttl=300)
|
|
182
|
+
async def read_item(item_id: int):
|
|
183
|
+
return await load(item_id)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.post("/items/{item_id}")
|
|
187
|
+
async def update_item(item_id: int, request: Request):
|
|
188
|
+
await save(item_id)
|
|
189
|
+
# Build the key the cached GET would have used: same host and headers,
|
|
190
|
+
# GET method, the cached path, no query string.
|
|
191
|
+
scope = dict(request.scope)
|
|
192
|
+
scope["method"] = "GET"
|
|
193
|
+
scope["path"] = f"/items/{item_id}"
|
|
194
|
+
scope["query_string"] = b""
|
|
195
|
+
return {"invalidated": await invalidate(StarletteRequest(scope))}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`invalidate(request, key_builder=None)` returns `True` when an entry existed and
|
|
199
|
+
was removed, `False` otherwise (including when no backend is configured — it
|
|
200
|
+
never raises). The request you hand it must produce the cached route's key:
|
|
201
|
+
same method, host, path and query string. If the cached route uses a custom
|
|
202
|
+
`key_builder`, pass the same one here, or the key will not match.
|
|
203
|
+
|
|
204
|
+
### Cache Monitoring Routes
|
|
205
|
+
|
|
206
|
+
`add_routes()` mounts two read-only endpoints that report what is currently in
|
|
207
|
+
the backend:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from fastapi import Depends, FastAPI
|
|
211
|
+
from fastapi_cachex import add_routes
|
|
212
|
+
|
|
213
|
+
app = FastAPI()
|
|
214
|
+
add_routes(
|
|
215
|
+
app,
|
|
216
|
+
prefix="/admin/cache", # default "" -> /cached-hits, /cached-records
|
|
217
|
+
include_in_schema=False, # default: hidden from OpenAPI
|
|
218
|
+
dependencies=[Depends(verify_admin)],
|
|
219
|
+
)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
- `GET {prefix}/cached-hits` — per-route hit counts and cache key information.
|
|
223
|
+
- `GET {prefix}/cached-records` — every cached record with its size, expiry and
|
|
224
|
+
a preview of the cached content.
|
|
225
|
+
|
|
226
|
+
> [!WARNING]
|
|
227
|
+
> **These routes have no authentication of their own.** `include_in_schema=False`
|
|
228
|
+
> only hides them from the OpenAPI document; anyone who guesses the path can read
|
|
229
|
+
> them. `/cached-records` includes a preview of the cached content and exposes
|
|
230
|
+
> your whole route structure. In production always pass
|
|
231
|
+
> `dependencies=[Depends(your_auth)]`, or mount them on an internal-only app.
|
|
232
|
+
|
|
233
|
+
> [!NOTE]
|
|
234
|
+
> The `ttl_remaining` field is not available on the Redis backend.
|
|
235
|
+
> `AsyncRedisCacheBackend.get_cache_data()` does not issue a per-key `TTL`
|
|
236
|
+
> lookup, so Redis-backed entries are reported as never expiring. Expiry itself
|
|
237
|
+
> still happens — only the monitoring view is blind to it.
|
|
238
|
+
|
|
151
239
|
### Application-Level Caching (Manual Get/Set)
|
|
152
240
|
|
|
153
241
|
Beyond HTTP response caching via `@cache`, you can cache arbitrary JSON-serializable
|
|
@@ -157,6 +245,7 @@ namespaced wrapper around whichever backend is configured via `BackendProxy`.
|
|
|
157
245
|
```python
|
|
158
246
|
from fastapi_cachex import AppCache, CacheManager
|
|
159
247
|
|
|
248
|
+
|
|
160
249
|
@app.get("/expensive")
|
|
161
250
|
async def expensive_operation(cache: AppCache):
|
|
162
251
|
result = await cache.get("expensive:result")
|
|
@@ -172,9 +261,18 @@ await manager.set("user:42", {"name": "Alice"})
|
|
|
172
261
|
user = await manager.get("user:42") # {"name": "Alice"}
|
|
173
262
|
await manager.delete("user:42")
|
|
174
263
|
await manager.clear_prefix() # clear everything under "myapp:"
|
|
264
|
+
|
|
265
|
+
# Compute-on-miss: `factory` runs only when the key is missing, expired, or
|
|
266
|
+
# undecodable. It may be sync or async.
|
|
267
|
+
profile = await manager.get_or_set("user:42", lambda: load_user(42), ttl=300)
|
|
268
|
+
|
|
269
|
+
# Glob over this manager's namespace, using the backend's native pattern
|
|
270
|
+
# support (Redis SCAN) rather than enumerating every key.
|
|
271
|
+
await manager.clear_pattern("user:*") # matches "myapp:user:*"
|
|
175
272
|
```
|
|
176
273
|
|
|
177
|
-
`
|
|
274
|
+
`get_or_set()` provides no stampede protection: concurrent misses for the same
|
|
275
|
+
key each run `factory`. `CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
|
|
178
276
|
it never raises for missing or corrupted entries. `CacheManager` keys live under
|
|
179
277
|
their own `cache:`-prefixed namespace by default, separate from the HTTP route
|
|
180
278
|
cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
|
|
@@ -204,19 +302,91 @@ This ensures that:
|
|
|
204
302
|
- Different query parameters get separate cache entries
|
|
205
303
|
- The same endpoint with different parameters can be cached independently
|
|
206
304
|
|
|
305
|
+
Query parameters are taken in the order the client sent them, without sorting, so
|
|
306
|
+
`?a=1&b=2` and `?b=2&a=1` are two distinct cache entries for the same logical request.
|
|
307
|
+
|
|
207
308
|
All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) to avoid conflicts with other applications.
|
|
208
309
|
|
|
209
310
|
`CacheManager` (see [Application-Level Caching](#application-level-caching-manual-getset)) uses a separate, simpler `cache:`-prefixed key namespace instead of this `|||`-separated format, since its keys aren't tied to HTTP requests.
|
|
210
311
|
|
|
312
|
+
> [!WARNING]
|
|
313
|
+
> **The default cache key carries no user identity.** The backend is shared by
|
|
314
|
+
> every worker and every caller, so caching an authenticated endpoint with the
|
|
315
|
+
> default key builder will serve one user's response to the next user who hits
|
|
316
|
+
> the same path.
|
|
317
|
+
>
|
|
318
|
+
> For any endpoint whose response depends on who is asking, do one of:
|
|
319
|
+
>
|
|
320
|
+
> 1. **`private=True`** — the response is never read from or written to the
|
|
321
|
+
> shared backend. `Cache-Control: private` still lets the user's own browser
|
|
322
|
+
> cache it, and `If-None-Match` revalidation still works against freshly
|
|
323
|
+
> rendered content.
|
|
324
|
+
> 2. **A key builder that includes the caller's identity** — use this when you
|
|
325
|
+
> do want a server-side cache per user.
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
from fastapi_cachex import cache
|
|
329
|
+
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# 1. Keep it out of the shared cache entirely.
|
|
333
|
+
@app.get("/me/profile")
|
|
334
|
+
@cache(ttl=60, private=True)
|
|
335
|
+
async def my_profile(user: CurrentUser):
|
|
336
|
+
return user.profile
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# 2. Or give each user their own entry.
|
|
340
|
+
def per_user_key(request: Request) -> str:
|
|
341
|
+
# `request.state.user_id` is populated by your authentication layer after
|
|
342
|
+
# it has verified the caller — never read the identity straight off an
|
|
343
|
+
# unverified request header (see the note below).
|
|
344
|
+
user_id = getattr(request.state, "user_id", "anonymous")
|
|
345
|
+
return (
|
|
346
|
+
f"{request.method}{CACHE_KEY_SEPARATOR}"
|
|
347
|
+
f"{request.headers.get('host', 'unknown')}{CACHE_KEY_SEPARATOR}"
|
|
348
|
+
f"{request.url.path}{CACHE_KEY_SEPARATOR}"
|
|
349
|
+
f"{request.query_params}{CACHE_KEY_SEPARATOR}{user_id}"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@app.get("/me/dashboard")
|
|
354
|
+
@cache(ttl=60, private=True, key_builder=per_user_key)
|
|
355
|
+
async def my_dashboard(user: CurrentUser):
|
|
356
|
+
return build_dashboard(user)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
> [!CAUTION]
|
|
360
|
+
> The key builder decides who sees whose data, so the identity it reads must
|
|
361
|
+
> come from something already verified — a claim from a checked token, a user
|
|
362
|
+
> your dependency resolved, or a value your auth middleware wrote to
|
|
363
|
+
> `request.state`.
|
|
364
|
+
>
|
|
365
|
+
> ```python
|
|
366
|
+
> # ❌ Never do this: anyone can send this header.
|
|
367
|
+
> user_id = request.headers.get("x-user-id", "anonymous")
|
|
368
|
+
> ```
|
|
369
|
+
>
|
|
370
|
+
> A key built from a raw request header is a horizontal privilege escalation:
|
|
371
|
+
> sending `X-User-Id: <someone-else>` returns that user's cached response.
|
|
372
|
+
|
|
211
373
|
### Cache Hit Behavior
|
|
212
374
|
|
|
213
375
|
When a cached entry is valid (within TTL):
|
|
214
|
-
- **Default behavior**: Returns the cached content with
|
|
376
|
+
- **Default behavior**: Returns the cached content directly, with the status code and headers the handler originally produced, without re-executing the endpoint handler
|
|
215
377
|
- **With `If-None-Match` header**: Returns HTTP 304 Not Modified if the ETag matches
|
|
216
378
|
- **With `no-cache` directive**: Forces revalidation with fresh content before deciding on 304
|
|
379
|
+
- **With `private=True`**: Nothing is read from or written to the shared backend; the handler runs every time and only `If-None-Match` revalidation applies
|
|
217
380
|
|
|
218
381
|
This means **cached hits are extremely fast** - the endpoint handler function is never executed.
|
|
219
382
|
|
|
383
|
+
Only successful responses are stored. A response the handler *returns* with a
|
|
384
|
+
non-2xx status (for example `Response(..., status_code=404)`) is passed straight
|
|
385
|
+
through and never cached, so a transient error cannot replace or poison the last
|
|
386
|
+
good entry. `206 Partial Content` is excluded as well, since its body is only
|
|
387
|
+
meaningful for the `Range` request that produced it. `Set-Cookie` is never
|
|
388
|
+
stored or replayed.
|
|
389
|
+
|
|
220
390
|
### Atomic backend primitives
|
|
221
391
|
|
|
222
392
|
Every backend exposes two atomic operations on top of `get`/`set`/`delete`, for
|
|
@@ -317,6 +487,33 @@ backend = AsyncRedisCacheBackend(
|
|
|
317
487
|
BackendProxy.set(backend)
|
|
318
488
|
```
|
|
319
489
|
|
|
490
|
+
**Configuring from a model**: `RedisConfig` is a pydantic model with the same
|
|
491
|
+
settings and validation, which is handy when they come from environment
|
|
492
|
+
variables or a settings file:
|
|
493
|
+
|
|
494
|
+
```python
|
|
495
|
+
from fastapi_cachex.backends import AsyncRedisCacheBackend
|
|
496
|
+
from fastapi_cachex.backends.config import RedisConfig
|
|
497
|
+
|
|
498
|
+
config = RedisConfig(
|
|
499
|
+
host="127.0.0.1",
|
|
500
|
+
port=6379,
|
|
501
|
+
password=None, # SecretStr | None
|
|
502
|
+
db=0,
|
|
503
|
+
encoding="utf-8", # how the client decodes server responses
|
|
504
|
+
socket_timeout=1.0, # seconds; applies to reads/writes
|
|
505
|
+
socket_connect_timeout=1.0,
|
|
506
|
+
key_prefix="fastapi_cachex:",
|
|
507
|
+
protocol=2, # RESP version, 2 or 3
|
|
508
|
+
)
|
|
509
|
+
backend = AsyncRedisCacheBackend.load_from_config(config)
|
|
510
|
+
BackendProxy.set(backend)
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Keep `protocol=2` unless you need RESP3 features *and* your `hiredis` build
|
|
514
|
+
supports it (RESP3 needs hiredis >= 3.0). Redis 8.0 speaks RESP3, but an older
|
|
515
|
+
hiredis will fail to negotiate it.
|
|
516
|
+
|
|
320
517
|
## Performance Considerations
|
|
321
518
|
|
|
322
519
|
### Cache Hit Performance
|
|
@@ -343,8 +540,12 @@ async def expensive_operation():
|
|
|
343
540
|
|
|
344
541
|
- [Cache Flow Explanation](docs/CACHE_FLOW.md)
|
|
345
542
|
- [Development Guide](docs/DEVELOPMENT.md)
|
|
543
|
+
- [Known Limitations and Planned Work](docs/BACKLOG.md)
|
|
544
|
+
- [Changelog](CHANGELOG.md)
|
|
346
545
|
- [Contributing Guidelines](docs/CONTRIBUTING.md)
|
|
347
546
|
- [Session Management Guide](docs/SESSION.md) - Complete guide for session features
|
|
547
|
+
- [State Management Guide](docs/STATE.md) - One-shot OAuth/CSRF state tokens
|
|
548
|
+
- [JWT Claims Guide](docs/JWT_CLAIMS.md) - Claim design and extension points for JWT session tokens
|
|
348
549
|
|
|
349
550
|
## License
|
|
350
551
|
|
|
@@ -34,7 +34,9 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
|
|
|
34
34
|
- Secure session management with HMAC-SHA256 token signing
|
|
35
35
|
- Optional JWT token format for interoperability (install extra `jwt`)
|
|
36
36
|
- IP address and User-Agent binding (optional security features)
|
|
37
|
-
- Header
|
|
37
|
+
- Header, bearer token and cookie transports (cookies via
|
|
38
|
+
`FastAPICacheXSessionMiddleware`; the older `SessionMiddleware` is deprecated
|
|
39
|
+
and removed in 0.4.0 — see [Session Management Guide](docs/SESSION.md))
|
|
38
40
|
- Automatic session renewal (sliding expiration)
|
|
39
41
|
- Flash messages for cross-request communication
|
|
40
42
|
- Multiple backend support (Redis, Memcached, In-Memory)
|
|
@@ -64,11 +66,19 @@ A high-performance caching extension for FastAPI, providing comprehensive HTTP c
|
|
|
64
66
|
uv add fastapi-cachex
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
Everything in the core package works with the in-memory backend. The other
|
|
70
|
+
backends and the optional session transports ship as extras:
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
| Extra | Install | Pulls in | Needed for |
|
|
73
|
+
|-------|---------|----------|------------|
|
|
74
|
+
| `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
|
|
75
|
+
| `memcache` | `uv add "fastapi-cachex[memcache]"` | `pymemcache` | `MemcachedBackend` (note: `memcache`, not `memcached`) |
|
|
76
|
+
| `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |
|
|
77
|
+
|
|
78
|
+
The `starlette` extra is gone: `itsdangerous` is a base dependency now, so
|
|
79
|
+
`FastAPICacheXSessionMiddleware` works on a plain install.
|
|
80
|
+
|
|
81
|
+
Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
|
|
72
82
|
|
|
73
83
|
### Development Installation
|
|
74
84
|
|
|
@@ -107,9 +117,88 @@ async def non_store_endpoint():
|
|
|
107
117
|
@app.get("/clear_cache")
|
|
108
118
|
async def remove_cache(cache: CacheBackend):
|
|
109
119
|
await cache.clear_path("/path/to/clear") # Clear cache for a specific path
|
|
110
|
-
|
|
120
|
+
# Patterns match the whole key, not just the path
|
|
121
|
+
await cache.clear_pattern("GET|||*|||/path/to/clear/*")
|
|
111
122
|
```
|
|
112
123
|
|
|
124
|
+
`clear_pattern` globs the whole logical key. HTTP cache keys look like
|
|
125
|
+
`method|||host|||path|||query`, so a pattern that is only a path matches
|
|
126
|
+
nothing — use `clear_path(path, include_params=True)` when the path is what you
|
|
127
|
+
mean, and keep `clear_pattern` for keys you built yourself.
|
|
128
|
+
|
|
129
|
+
### Invalidating a Single Cached Route
|
|
130
|
+
|
|
131
|
+
`clear_path`/`clear_pattern` work on ranges of keys. To drop exactly the entry a
|
|
132
|
+
`@cache`-decorated route would use — typically right after a mutation — call
|
|
133
|
+
`invalidate()`, which rebuilds that route's key with the same key builder and
|
|
134
|
+
deletes it:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from fastapi import Request
|
|
138
|
+
from starlette.requests import Request as StarletteRequest
|
|
139
|
+
|
|
140
|
+
from fastapi_cachex import cache, invalidate
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.get("/items/{item_id}")
|
|
144
|
+
@cache(ttl=300)
|
|
145
|
+
async def read_item(item_id: int):
|
|
146
|
+
return await load(item_id)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.post("/items/{item_id}")
|
|
150
|
+
async def update_item(item_id: int, request: Request):
|
|
151
|
+
await save(item_id)
|
|
152
|
+
# Build the key the cached GET would have used: same host and headers,
|
|
153
|
+
# GET method, the cached path, no query string.
|
|
154
|
+
scope = dict(request.scope)
|
|
155
|
+
scope["method"] = "GET"
|
|
156
|
+
scope["path"] = f"/items/{item_id}"
|
|
157
|
+
scope["query_string"] = b""
|
|
158
|
+
return {"invalidated": await invalidate(StarletteRequest(scope))}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`invalidate(request, key_builder=None)` returns `True` when an entry existed and
|
|
162
|
+
was removed, `False` otherwise (including when no backend is configured — it
|
|
163
|
+
never raises). The request you hand it must produce the cached route's key:
|
|
164
|
+
same method, host, path and query string. If the cached route uses a custom
|
|
165
|
+
`key_builder`, pass the same one here, or the key will not match.
|
|
166
|
+
|
|
167
|
+
### Cache Monitoring Routes
|
|
168
|
+
|
|
169
|
+
`add_routes()` mounts two read-only endpoints that report what is currently in
|
|
170
|
+
the backend:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from fastapi import Depends, FastAPI
|
|
174
|
+
from fastapi_cachex import add_routes
|
|
175
|
+
|
|
176
|
+
app = FastAPI()
|
|
177
|
+
add_routes(
|
|
178
|
+
app,
|
|
179
|
+
prefix="/admin/cache", # default "" -> /cached-hits, /cached-records
|
|
180
|
+
include_in_schema=False, # default: hidden from OpenAPI
|
|
181
|
+
dependencies=[Depends(verify_admin)],
|
|
182
|
+
)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- `GET {prefix}/cached-hits` — per-route hit counts and cache key information.
|
|
186
|
+
- `GET {prefix}/cached-records` — every cached record with its size, expiry and
|
|
187
|
+
a preview of the cached content.
|
|
188
|
+
|
|
189
|
+
> [!WARNING]
|
|
190
|
+
> **These routes have no authentication of their own.** `include_in_schema=False`
|
|
191
|
+
> only hides them from the OpenAPI document; anyone who guesses the path can read
|
|
192
|
+
> them. `/cached-records` includes a preview of the cached content and exposes
|
|
193
|
+
> your whole route structure. In production always pass
|
|
194
|
+
> `dependencies=[Depends(your_auth)]`, or mount them on an internal-only app.
|
|
195
|
+
|
|
196
|
+
> [!NOTE]
|
|
197
|
+
> The `ttl_remaining` field is not available on the Redis backend.
|
|
198
|
+
> `AsyncRedisCacheBackend.get_cache_data()` does not issue a per-key `TTL`
|
|
199
|
+
> lookup, so Redis-backed entries are reported as never expiring. Expiry itself
|
|
200
|
+
> still happens — only the monitoring view is blind to it.
|
|
201
|
+
|
|
113
202
|
### Application-Level Caching (Manual Get/Set)
|
|
114
203
|
|
|
115
204
|
Beyond HTTP response caching via `@cache`, you can cache arbitrary JSON-serializable
|
|
@@ -119,6 +208,7 @@ namespaced wrapper around whichever backend is configured via `BackendProxy`.
|
|
|
119
208
|
```python
|
|
120
209
|
from fastapi_cachex import AppCache, CacheManager
|
|
121
210
|
|
|
211
|
+
|
|
122
212
|
@app.get("/expensive")
|
|
123
213
|
async def expensive_operation(cache: AppCache):
|
|
124
214
|
result = await cache.get("expensive:result")
|
|
@@ -134,9 +224,18 @@ await manager.set("user:42", {"name": "Alice"})
|
|
|
134
224
|
user = await manager.get("user:42") # {"name": "Alice"}
|
|
135
225
|
await manager.delete("user:42")
|
|
136
226
|
await manager.clear_prefix() # clear everything under "myapp:"
|
|
227
|
+
|
|
228
|
+
# Compute-on-miss: `factory` runs only when the key is missing, expired, or
|
|
229
|
+
# undecodable. It may be sync or async.
|
|
230
|
+
profile = await manager.get_or_set("user:42", lambda: load_user(42), ttl=300)
|
|
231
|
+
|
|
232
|
+
# Glob over this manager's namespace, using the backend's native pattern
|
|
233
|
+
# support (Redis SCAN) rather than enumerating every key.
|
|
234
|
+
await manager.clear_pattern("user:*") # matches "myapp:user:*"
|
|
137
235
|
```
|
|
138
236
|
|
|
139
|
-
`
|
|
237
|
+
`get_or_set()` provides no stampede protection: concurrent misses for the same
|
|
238
|
+
key each run `factory`. `CacheManager.get()` returns `None` (or a supplied `default=`) on a cache miss —
|
|
140
239
|
it never raises for missing or corrupted entries. `CacheManager` keys live under
|
|
141
240
|
their own `cache:`-prefixed namespace by default, separate from the HTTP route
|
|
142
241
|
cache and OAuth state, so `clear()`/`clear_prefix()` never touch unrelated cache
|
|
@@ -166,19 +265,91 @@ This ensures that:
|
|
|
166
265
|
- Different query parameters get separate cache entries
|
|
167
266
|
- The same endpoint with different parameters can be cached independently
|
|
168
267
|
|
|
268
|
+
Query parameters are taken in the order the client sent them, without sorting, so
|
|
269
|
+
`?a=1&b=2` and `?b=2&a=1` are two distinct cache entries for the same logical request.
|
|
270
|
+
|
|
169
271
|
All backends automatically namespace keys with a prefix (e.g., `fastapi_cachex:`) to avoid conflicts with other applications.
|
|
170
272
|
|
|
171
273
|
`CacheManager` (see [Application-Level Caching](#application-level-caching-manual-getset)) uses a separate, simpler `cache:`-prefixed key namespace instead of this `|||`-separated format, since its keys aren't tied to HTTP requests.
|
|
172
274
|
|
|
275
|
+
> [!WARNING]
|
|
276
|
+
> **The default cache key carries no user identity.** The backend is shared by
|
|
277
|
+
> every worker and every caller, so caching an authenticated endpoint with the
|
|
278
|
+
> default key builder will serve one user's response to the next user who hits
|
|
279
|
+
> the same path.
|
|
280
|
+
>
|
|
281
|
+
> For any endpoint whose response depends on who is asking, do one of:
|
|
282
|
+
>
|
|
283
|
+
> 1. **`private=True`** — the response is never read from or written to the
|
|
284
|
+
> shared backend. `Cache-Control: private` still lets the user's own browser
|
|
285
|
+
> cache it, and `If-None-Match` revalidation still works against freshly
|
|
286
|
+
> rendered content.
|
|
287
|
+
> 2. **A key builder that includes the caller's identity** — use this when you
|
|
288
|
+
> do want a server-side cache per user.
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
from fastapi_cachex import cache
|
|
292
|
+
from fastapi_cachex.types import CACHE_KEY_SEPARATOR
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# 1. Keep it out of the shared cache entirely.
|
|
296
|
+
@app.get("/me/profile")
|
|
297
|
+
@cache(ttl=60, private=True)
|
|
298
|
+
async def my_profile(user: CurrentUser):
|
|
299
|
+
return user.profile
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# 2. Or give each user their own entry.
|
|
303
|
+
def per_user_key(request: Request) -> str:
|
|
304
|
+
# `request.state.user_id` is populated by your authentication layer after
|
|
305
|
+
# it has verified the caller — never read the identity straight off an
|
|
306
|
+
# unverified request header (see the note below).
|
|
307
|
+
user_id = getattr(request.state, "user_id", "anonymous")
|
|
308
|
+
return (
|
|
309
|
+
f"{request.method}{CACHE_KEY_SEPARATOR}"
|
|
310
|
+
f"{request.headers.get('host', 'unknown')}{CACHE_KEY_SEPARATOR}"
|
|
311
|
+
f"{request.url.path}{CACHE_KEY_SEPARATOR}"
|
|
312
|
+
f"{request.query_params}{CACHE_KEY_SEPARATOR}{user_id}"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.get("/me/dashboard")
|
|
317
|
+
@cache(ttl=60, private=True, key_builder=per_user_key)
|
|
318
|
+
async def my_dashboard(user: CurrentUser):
|
|
319
|
+
return build_dashboard(user)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
> [!CAUTION]
|
|
323
|
+
> The key builder decides who sees whose data, so the identity it reads must
|
|
324
|
+
> come from something already verified — a claim from a checked token, a user
|
|
325
|
+
> your dependency resolved, or a value your auth middleware wrote to
|
|
326
|
+
> `request.state`.
|
|
327
|
+
>
|
|
328
|
+
> ```python
|
|
329
|
+
> # ❌ Never do this: anyone can send this header.
|
|
330
|
+
> user_id = request.headers.get("x-user-id", "anonymous")
|
|
331
|
+
> ```
|
|
332
|
+
>
|
|
333
|
+
> A key built from a raw request header is a horizontal privilege escalation:
|
|
334
|
+
> sending `X-User-Id: <someone-else>` returns that user's cached response.
|
|
335
|
+
|
|
173
336
|
### Cache Hit Behavior
|
|
174
337
|
|
|
175
338
|
When a cached entry is valid (within TTL):
|
|
176
|
-
- **Default behavior**: Returns the cached content with
|
|
339
|
+
- **Default behavior**: Returns the cached content directly, with the status code and headers the handler originally produced, without re-executing the endpoint handler
|
|
177
340
|
- **With `If-None-Match` header**: Returns HTTP 304 Not Modified if the ETag matches
|
|
178
341
|
- **With `no-cache` directive**: Forces revalidation with fresh content before deciding on 304
|
|
342
|
+
- **With `private=True`**: Nothing is read from or written to the shared backend; the handler runs every time and only `If-None-Match` revalidation applies
|
|
179
343
|
|
|
180
344
|
This means **cached hits are extremely fast** - the endpoint handler function is never executed.
|
|
181
345
|
|
|
346
|
+
Only successful responses are stored. A response the handler *returns* with a
|
|
347
|
+
non-2xx status (for example `Response(..., status_code=404)`) is passed straight
|
|
348
|
+
through and never cached, so a transient error cannot replace or poison the last
|
|
349
|
+
good entry. `206 Partial Content` is excluded as well, since its body is only
|
|
350
|
+
meaningful for the `Range` request that produced it. `Set-Cookie` is never
|
|
351
|
+
stored or replayed.
|
|
352
|
+
|
|
182
353
|
### Atomic backend primitives
|
|
183
354
|
|
|
184
355
|
Every backend exposes two atomic operations on top of `get`/`set`/`delete`, for
|
|
@@ -279,6 +450,33 @@ backend = AsyncRedisCacheBackend(
|
|
|
279
450
|
BackendProxy.set(backend)
|
|
280
451
|
```
|
|
281
452
|
|
|
453
|
+
**Configuring from a model**: `RedisConfig` is a pydantic model with the same
|
|
454
|
+
settings and validation, which is handy when they come from environment
|
|
455
|
+
variables or a settings file:
|
|
456
|
+
|
|
457
|
+
```python
|
|
458
|
+
from fastapi_cachex.backends import AsyncRedisCacheBackend
|
|
459
|
+
from fastapi_cachex.backends.config import RedisConfig
|
|
460
|
+
|
|
461
|
+
config = RedisConfig(
|
|
462
|
+
host="127.0.0.1",
|
|
463
|
+
port=6379,
|
|
464
|
+
password=None, # SecretStr | None
|
|
465
|
+
db=0,
|
|
466
|
+
encoding="utf-8", # how the client decodes server responses
|
|
467
|
+
socket_timeout=1.0, # seconds; applies to reads/writes
|
|
468
|
+
socket_connect_timeout=1.0,
|
|
469
|
+
key_prefix="fastapi_cachex:",
|
|
470
|
+
protocol=2, # RESP version, 2 or 3
|
|
471
|
+
)
|
|
472
|
+
backend = AsyncRedisCacheBackend.load_from_config(config)
|
|
473
|
+
BackendProxy.set(backend)
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Keep `protocol=2` unless you need RESP3 features *and* your `hiredis` build
|
|
477
|
+
supports it (RESP3 needs hiredis >= 3.0). Redis 8.0 speaks RESP3, but an older
|
|
478
|
+
hiredis will fail to negotiate it.
|
|
479
|
+
|
|
282
480
|
## Performance Considerations
|
|
283
481
|
|
|
284
482
|
### Cache Hit Performance
|
|
@@ -305,8 +503,12 @@ async def expensive_operation():
|
|
|
305
503
|
|
|
306
504
|
- [Cache Flow Explanation](docs/CACHE_FLOW.md)
|
|
307
505
|
- [Development Guide](docs/DEVELOPMENT.md)
|
|
506
|
+
- [Known Limitations and Planned Work](docs/BACKLOG.md)
|
|
507
|
+
- [Changelog](CHANGELOG.md)
|
|
308
508
|
- [Contributing Guidelines](docs/CONTRIBUTING.md)
|
|
309
509
|
- [Session Management Guide](docs/SESSION.md) - Complete guide for session features
|
|
510
|
+
- [State Management Guide](docs/STATE.md) - One-shot OAuth/CSRF state tokens
|
|
511
|
+
- [JWT Claims Guide](docs/JWT_CLAIMS.md) - Claim design and extension points for JWT session tokens
|
|
310
512
|
|
|
311
513
|
## License
|
|
312
514
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""FastAPI-CacheX: A powerful and flexible caching extension for FastAPI."""
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
from importlib.metadata import PackageNotFoundError
|
|
5
|
+
from importlib.metadata import version
|
|
4
6
|
|
|
5
7
|
from .cache import cache as cache
|
|
6
8
|
from .cache import default_key_builder as default_key_builder
|
|
@@ -41,6 +43,22 @@ from .state import StateManagerProxy as StateManagerProxy
|
|
|
41
43
|
from .state import get_state_manager as get_state_manager
|
|
42
44
|
from .types import CacheKeyBuilder as CacheKeyBuilder
|
|
43
45
|
|
|
46
|
+
|
|
47
|
+
def _read_version() -> str:
|
|
48
|
+
"""Return the installed distribution's version.
|
|
49
|
+
|
|
50
|
+
Importing from a source tree that was never installed leaves no metadata to
|
|
51
|
+
read; reporting a development version there is part of the contract, so this
|
|
52
|
+
lives in a function the tests can drive rather than behind a coverage pragma.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
return version("fastapi-cachex")
|
|
56
|
+
except PackageNotFoundError:
|
|
57
|
+
return "0.0.0.dev0"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__version__ = _read_version()
|
|
61
|
+
|
|
44
62
|
_package_logger = logging.getLogger("fastapi_cachex")
|
|
45
63
|
_package_logger.addHandler(
|
|
46
64
|
logging.NullHandler()
|
|
@@ -74,6 +92,7 @@ __all__ = [
|
|
|
74
92
|
"StateManager",
|
|
75
93
|
"StateManagerDep",
|
|
76
94
|
"StateManagerProxy",
|
|
95
|
+
"__version__",
|
|
77
96
|
"add_routes",
|
|
78
97
|
"cache",
|
|
79
98
|
"default_key_builder",
|