Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ Note that 0.3.3 was never released; 0.3.4 follows 0.3.2.

## [Unreleased]

0.4.0 contains breaking changes; read
[Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/)
before upgrading. Sessions and OAuth state are deprecated and will be removed
in 0.5.0. Three session changes announced in 0.3.9 are not made as announced:
see the entries for #75, #131 and #377.

## [0.3.9] - 2026-09-29

0.3.9 is the last 0.3.x release. 0.4.0 contains breaking changes; see [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/).
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Use `uv` for everything (`uv sync --group dev --all-extras`, `uv run ...`).

## Invariants that span modules

- HTTP cache keys are `method|||host|||path|||query` (`CACHE_KEY_SEPARATOR`). Host and path go through `escape_key_component` so client input cannot inject the separator. Anything that builds or parses keys (`clear_path`, `routes.py`, backends) must stay consistent with it.
- HTTP cache keys are `http:v2|method|host|path|query` (`HTTP_KEY_FORMAT_TAG`, `CACHE_KEY_SEPARATOR = "|"`), built and parsed by `CacheKey` in `cache_key.py`. Method, host, path and extra components go through `escape_key_component` so client input cannot inject the separator; a query over 200 bytes becomes `sha256:<hex>`. A format change bumps the tag. Anything that builds or parses keys (`clear_path`, `routes.py`, backends) must go through `CacheKey`.
- `@cache` fails open by default: a backend error is logged and treated as a miss, or the response is served unstored. Only GET is cached.
- Backend lookup: `@cache` and the `CacheBackend` / `AppCache` dependencies fall back to a `MemoryBackend` when none is set. `CacheLock`, `StateManager` and a directly built `CacheManager(...)` call `BackendProxy.get()` and raise; the monitoring routes and `invalidate()` treat a missing backend as empty. The proxies' lazy creation goes through `ProxyBase.get_or_create` (per-class lock; sync dependencies run in threads).
- Memcached cannot enumerate keys. `clear_pattern`, `get_all_keys` and every `CacheManager.clear*` are no-ops with a `RuntimeWarning`, while `MemcachedBackend.clear()` issues `flush_all` and wipes the whole server.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async def report(cache: AppCache):

## Documentation

- [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to act on the 0.3.9 warnings now
- [Migrating to 0.4.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/) — what 0.4.0 changes and how to upgrade from 0.3.x
- [HTTP caching](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/) — the `@cache` decorator, Cache-Control directives, cache keys, invalidation and monitoring routes
- [Application cache](https://fastapi-cachex.readthedocs.io/en/latest/APP_CACHE/) — `CacheManager`
- [Backends](https://fastapi-cachex.readthedocs.io/en/latest/BACKENDS/) — choosing and configuring a backend, atomic primitives
Expand Down
18 changes: 9 additions & 9 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

## Supported versions

Security fixes are released for the latest 0.3.x release line only. Upgrade
to the newest 0.3.x release before reporting, and check whether the problem is
still there.
Security fixes are released for the latest release line only, currently
0.4.x. Upgrade to the newest 0.4.x release before reporting, and check whether
the problem is still there.

| Version | Supported |
|--------------------------|-----------|
| Latest 0.3.x (now 0.3.8) | Yes |
| Any older release | No |
| Version | Supported |
|-------------------|-----------|
| Latest 0.4.x | Yes |
| 0.3.9 and older | No |

A fix ships as a new 0.3.x patch release; earlier releases are not patched.
A fix ships as a new 0.4.x patch release; earlier releases are not patched.

`fastapi_cachex.session` and `fastapi_cachex.state` are deprecated in 0.4.0.
They get security fixes during 0.4.x and none after 0.5.0 removes them.
Expand Down Expand Up @@ -53,7 +53,7 @@ guaranteed response times. What you can expect:
- An acknowledgement once a maintainer has read the report, and a follow-up
after it has been looked into, saying whether it is being treated as a
vulnerability.
- If it is, a fix in a new 0.3.x patch release, followed by a published GitHub
- If it is, a fix in a new 0.4.x patch release, followed by a published GitHub
security advisory that credits you, unless you prefer not to be named.
- Questions in the advisory thread if the report needs more detail.

Expand Down
5 changes: 5 additions & 0 deletions changelog.d/131.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**`get_session_manager` keeps returning the middleware's manager, and the 0.3.9
`FutureWarning` is gone.** 0.3.9 announced that 0.4.0 would resolve it through
`SessionManagerProxy`. With sessions deprecated, that change is not made. See
the
[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#get-session-manager).
5 changes: 5 additions & 0 deletions changelog.d/377.deprecated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**`SessionConfig.use_bearer_token` is removed in 0.5.0, not 0.4.0.** It keeps
working and keeps its `DeprecationWarning`, and goes with
`fastapi_cachex.session`; list the token sources in `token_source_priority`
instead. See the
[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#token-source-priority).
3 changes: 1 addition & 2 deletions changelog.d/420.deprecated.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@ FastAPI-CacheX is narrowing to HTTP and application caching. Importing
names from the `fastapi_cachex` package, emits a `FutureWarning`; a plain
`import fastapi_cachex` does not. The names are no longer in
`fastapi_cachex.__all__`. Both packages get security fixes only until 0.5.0;
"Sessions and OAuth state are deprecated" in the 0.4.0 migration guide says
where to move.
the [migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#session-state-deprecated) says where to move.
2 changes: 1 addition & 1 deletion changelog.d/69.removed.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ reads the same custom header and `Authorization: Bearer` token and adds
`request.session` and the session cookie. Replace
`app.add_middleware(SessionMiddleware, ...)` with
`app.add_middleware(FastAPICacheXSessionMiddleware, ...)` and set the cookie
options (see the 0.4.0 migration guide).
options (see the [migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#session-middleware)).
6 changes: 6 additions & 0 deletions changelog.d/75.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**`token_source_priority` does not have to name `"cookie"`, and the 0.3.9
`FutureWarning` is gone.** 0.3.9 announced that 0.4.0 would read the session
cookie only when the list names it. With sessions deprecated, that change is
not made: the cookie is still read after the header sources, and `"cookie"` is
still accepted only as the last entry. See the
[migration guide](https://fastapi-cachex.readthedocs.io/en/stable/MIGRATING_0_4/#token-source-priority).
3 changes: 2 additions & 1 deletion docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ async def expensive_operation(cache: AppCache):
return result


# Or instantiate directly, e.g. outside of a request. `lock` defaults to True
# Or instantiate directly, e.g. outside of a request, once a backend is set with
# BackendProxy.set(...). `lock` defaults to True
# (see "Stampede protection").
manager = CacheManager(key_prefix="myapp:", default_ttl=60)
await manager.set("user:42", {"name": "Alice"})
Expand Down
5 changes: 3 additions & 2 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ BackendProxy.set(backend)
the prefix (see [Migrating to 0.4.0](MIGRATING_0_4.md#redis-clear-pattern))

**Configuring from a model**: `RedisConfig` is a pydantic model with the same
settings and validation, which is handy when they come from environment
settings, validated (port range, `db >= 0`, `protocol` 2 or 3), which is handy when they come from environment
variables or a settings file:

```python
Expand Down Expand Up @@ -187,7 +187,8 @@ BackendProxy.set(backend)
[`invalidate(request)`](HTTP_CACHING.md#invalidating-a-single-cached-route), which
rebuilds the exact key
- `backend.clear()` (`MemcachedBackend.clear()`) issues `flush_all`, which wipes the whole
Memcached server, not just this namespace. `CacheManager.clear()` is different: it
Memcached server, not just this namespace, and emits a `RuntimeWarning` each time.
`CacheManager.clear()` is different: it
enumerates keys, so on Memcached it deletes nothing (see
[Application cache](APP_CACHE.md))
- A key Memcached would reject (over 250 bytes, whitespace, non-ASCII) is stored
Expand Down
6 changes: 3 additions & 3 deletions docs/CACHE_FLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,10 @@ The decorator arguments control both the server-side behaviour and the

# Normal caching behaviour
@cache(ttl=3600) # Cache for 1 hour (also used as the max-age value)
@cache(public=True) # Allow shared caches, also for Authorization/session requests
@cache(ttl=3600, public=True) # Allow shared caches, also for Authorization/session requests
@cache(private=True) # Private only; never touches the shared backend
@cache(ttl=60, key_builder=per_user_key, cache_authorized=True) # Authorization/session requests use the backend, answered private
@cache(immutable=True) # Content never changes
@cache(ttl=3600, immutable=True) # Content never changes

# Header-only directives (they do not change server-side behaviour)
@cache(ttl=60, must_revalidate=True) # must-revalidate
Expand Down Expand Up @@ -463,7 +463,7 @@ async def cleanup_task():

# The task is only started lazily on the first get/set/set_if_absent/increment/
# get_and_delete call (it needs a running event loop), so write-only usage (for
# example StateManager.create_state) starts it too.
# example CacheManager.add()) starts it too.

# Redis/Memcached: TTL mechanism
# Use the backend's built-in TTL (SET ... EX, exptime)
Expand Down
4 changes: 2 additions & 2 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ so do not point these at anything you care about. When a port is set but nothing
is listening, the suites skip and say so.

A run with nothing opted in still clears the coverage gate (`fail_under = 90`)
at about 94.5%, but only because the rest of the suite carries it — `redis.py`
alone drops to roughly 42%. The margin is thin, so the first place an untested
at about 95%, but only because the rest of the suite carries it — `redis.py`
alone drops below 40%. The margin is thin, so the first place an untested
line shows up as a failure is a local opted-out run, not CI, which sets both
variables against its own service containers and sees 99.95%.

Expand Down
22 changes: 11 additions & 11 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ A response that belongs to one caller is never stored either (#296):
backend anyway, but its response to such a request still gets `private`
(before 0.3.9 it was sent without it, #362); `private=True` routes send it
already.
A request has a session when `FastAPICacheXSessionMiddleware` loaded one
for it, from the token header, a
A request has a session when `FastAPICacheXSessionMiddleware` (deprecated,
removed in 0.5.0) loaded one for it, from the token header, a
bearer token or the session cookie, with or without a user, or when
`request.session` is non-empty under any session middleware, Starlette's
included. A token that resolves to no session (forged, expired) does not
Expand Down Expand Up @@ -278,8 +278,8 @@ async def report():
return await build_report()
```

This only covers `@cache`. `invalidate()`, `CacheManager`, `StateManager`,
`CacheLock` and sessions still raise backend errors to the caller.
This only covers `@cache`. `invalidate()`, `CacheManager`, `CacheLock` and the
deprecated `StateManager` and sessions still raise backend errors to the caller.

## Cache keys

Expand All @@ -295,7 +295,7 @@ another tag, so its keys never collide with these; on Redis and memory,

This ensures that:

- Different HTTP methods (GET, POST, etc.) don't share cache
- The method is part of the key
- Different hosts don't share cache (useful for multi-tenant scenarios)
- Different query parameters get separate cache entries
- The same endpoint with different parameters can be cached independently
Expand Down Expand Up @@ -348,9 +348,9 @@ The host and path come from the client, so `|` and `%` in them are percent-encod
the components and make one request's key equal another's. The query string is
URL-encoded already. `clear_path()` takes the path as your application sees it
(`request.url.path`) and encodes it the same way; `clear_pattern()` matches the
stored key, so write `%7C` there for a `|`. Before 0.3.8 both were stored as sent,
so after upgrading, entries for a host or path containing `|` or `%` are cached
afresh once.
stored key, so write `%7C` there for a `|`. 0.4.0 does not read entries
written by 0.3.x at all; they are cached afresh once (see
[Migrating to 0.4.0](MIGRATING_0_4.md#cache-keys)).

The host is normalised first, so every spelling of one origin shares an entry:
it is lower-cased (hostnames are case-insensitive), and an empty port or the
Expand Down Expand Up @@ -475,7 +475,7 @@ The key is not secret: it is listed by `get_all_keys()`, shown by the
`/cached-records` and `/cached-hits` monitoring routes, and stored as-is in the
Redis or Memcached keyspace. So for the headers that carry credentials,
`Authorization`, `Proxy-Authorization`, `Cookie` and `X-Session-Token` (the
session subsystem's default `header_name`), matched in any case, the component
deprecated session subsystem's default `header_name`), matched in any case, the component
holds the full hex SHA-256 of the value (trimmed and joined as above) instead
of the value:

Expand Down Expand Up @@ -580,8 +580,8 @@ request it is given selects.
> `cache_authorized=True` when callers authenticate with an `Authorization`
> header or a session: without it such requests bypass the backend too.
>
> If callers authenticate with a cookie of your own rather than the library's
> session, nothing triggers the bypass: a plain `@cache` serves the first
> If callers authenticate with a cookie that no session middleware loads (for
> example a token cookie your own dependency reads), nothing triggers the bypass: a plain `@cache` serves the first
> caller's response to everyone, so use one of the two options above.

```python
Expand Down
2 changes: 1 addition & 1 deletion docs/JWT_CLAIMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ The base class below does what the built-in `JWTTokenSerializer` does and leaves
```
<!-- fmt:on -->

PyJWT verifies the signature, `exp`, `iat` and (when present) `nbf` by default, and `iss`/`aud` when `issuer`/`audience` are given. Two checks of the built-in serializer are not repeated here: it rejects an asymmetric `jwt_algorithm` and warns about a `secret_key` shorter than the HMAC output. This class signs with `secret_key` too, so keep an `HS*` algorithm; for an asymmetric one, hold the private and public keys in the class and use them in `jwt.encode()` and `jwt.decode()`.
PyJWT verifies the signature, `exp`, `iat` and (when present) `nbf` by default, and `iss`/`aud` when `issuer`/`audience` are given. Two checks of the built-in serializer are not repeated here: it rejects an asymmetric `jwt_algorithm` and rejects (`ValueError`) a `secret_key` shorter than the HMAC output. This class signs with `secret_key` too, so keep an `HS*` algorithm; for an asymmetric one, hold the private and public keys in the class and use them in `jwt.encode()` and `jwt.decode()`.

### Example 1: Adding `jti` and `nbf`

Expand Down
2 changes: 1 addition & 1 deletion docs/LOCK.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Complete runnable example: [`examples/cache_lock.py`](https://github.com/allen00
- **Safety & Token Ownership**: Each `CacheLock` instance generates a unique token (`secrets.token_hex(16)`) stored inside a `CacheEntry`. Releases (`release()`) and extensions (`extend()`) use owner-checked backend primitives (`delete_if_equals` and `expire_if_equals`), so a holder whose lock expired cannot release or renew a lock claimed by someone else.
- **Blocking & Non-blocking Modes**:
- Non-blocking (`acquire(blocking=False)`): Performs a single atomic `set_if_absent` and immediately returns `True` if acquired or `False` if held.
- Blocking (`acquire(blocking=True, timeout=None, poll_interval=0.1)`): Retries at `poll_interval` seconds until acquired or until `timeout` seconds elapse. The default `timeout=None` makes a blocking `acquire()` (and `async with CacheLock(...)`) wait indefinitely until the lock becomes free. If a finite `timeout` is reached, `acquire()` returns `False`.
- Blocking (the default; `CacheLock(..., blocking=True, timeout=None, poll_interval=0.1)`, each overridable per `acquire()` call, where `None` means "use the instance's value"): Retries at `poll_interval` seconds until acquired or until `timeout` seconds elapse. The default `timeout=None` makes a blocking `acquire()` (and `async with CacheLock(...)`) wait indefinitely until the lock becomes free. If a finite `timeout` is reached, `acquire()` returns `False`.
- **Context Manager Timeouts**: Entering a context manager (`async with CacheLock(...)`) invokes `acquire()`. If acquisition fails or times out, it raises `LockTimeoutError`.
- **TTL Expiration**: If a task takes longer than its `ttl` and fails to renew, the lock entry expires in the backend and becomes free. Another process or container can then acquire the lock while the original code is still running. Subsequent calls to `extend()` or `release()` by the original holder will safely return `False` without throwing an error. Always choose a `ttl` longer than the expected work, or call `extend()` periodically during long-running operations.
- **TTL Renewal (`extend`)**: `extend(ttl)` updates the key's TTL only while the lock is still owned by this holder instance, preventing race conditions on expired locks.
Expand Down
Loading
Loading