Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0

- name: Sync dependencies
run: uv sync --locked --group dev --extra jwt
run: uv sync --locked --group dev

# `tests` is linted here too. pre-commit only sees staged files, so test
# code that was never touched again drifts; that is how the suite came to
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,12 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0

# The extras are redundant — the dev group already pins redis, pymemcache,
# PyJWT and orjson — but they are named anyway. This is the one job where
# The extras are redundant — the dev group already pins redis, pymemcache
# and orjson — but they are named anyway. This is the one job where
# a missing backend package would turn the gate below into a formality,
# so it does not rely on a dev-group entry staying put.
- name: Sync dependencies
run: uv sync --locked --group dev --extra jwt --extra redis --extra memcached
run: uv sync --locked --group dev --extra redis --extra memcached

# The gate. A release is the one run where a red test result arrives too
# late to matter, so everything CI checks elsewhere is checked here too,
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0

- name: Sync dependencies
run: uv sync --locked --extra jwt
run: uv sync --locked

- name: Run tests
run: uv run coverage run -m pytest && uv run coverage report
Expand Down
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,5 @@ repos:
- orjson
- types-orjson
- pymemcache>=4.0.0
- PyJWT>=2.9.0
args: [--strict]
exclude: ^(docs/|scripts/)
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Guidance for Claude Code in this repository. It lists only what the code, `pypro

## Project

`fastapi_cachex` (PyPI `fastapi-cachex`): HTTP caching, application cache and a distributed lock for FastAPI, on pluggable backends (memory, Redis, Memcached). The 0.4 line is current (0.4.0 released 2026-10-02). `release.yml` releases only from `master`, so `master` stays 0.4.x-compatible: merging breaking 0.5.0 work (milestone `0.5.0`) ends 0.4.x patch releases. Sessions and OAuth state (`fastapi_cachex.session`, `fastapi_cachex.state`) are deprecated (#420) and get security fixes only until 0.5.0 removes them (#421).
`fastapi_cachex` (PyPI `fastapi-cachex`): HTTP caching, application cache and a distributed lock for FastAPI, on pluggable backends (memory, Redis, Memcached). The 0.4 line is current (0.4.0 released 2026-10-02). `release.yml` releases only from `master`, so `master` stays 0.4.x-compatible: merging breaking 0.5.0 work (milestone `0.5.0`) ends 0.4.x patch releases. 0.5.0 removes sessions and OAuth state (`fastapi_cachex.session`, `fastapi_cachex.state`, deprecated in 0.4.0 by #420; removed by #421); `docs/MIGRATING_0_5.md` covers the move.

## Commands

Expand All @@ -20,11 +20,11 @@ Use `uv` for everything (`uv sync --group dev --all-extras`, `uv run ...`).

- 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 stored; HEAD reads the GET entry (key built as GET) and is never stored.
- 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).
- Backend lookup: `@cache` and the `CacheBackend` / `AppCache` dependencies fall back to a `MemoryBackend` when none is set. `CacheLock` 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.
- The atomic primitives on `BaseCacheBackend` (`increment`, `get_and_delete`, `set_if_absent`, `delete_if_equals`, `expire_if_equals`, `delete_many`) have non-atomic fallbacks. Every built-in backend must override them atomically; see "Atomic backend primitives" in `docs/BACKENDS.md`. `tests/backends/*_contract.py` covers TTL validation, counters and `clear_pattern`; the other primitives are tested in each backend's own test file, so a change needs all three.
- `validate_ttl` / `validate_delta` run before any I/O in every backend; floats and bools raise `TypeError`.
- Redis and Memcached keys carry `key_prefix` (default `fastapi_cachex:`); memory has no prefix. `CacheManager` (`cache:`), `StateManager` (`oauth_state:`) and `CacheLock` (`lock:`) add their own prefixes on top.
- Redis and Memcached keys carry `key_prefix` (default `fastapi_cachex:`); memory has no prefix. `CacheManager` (`cache:`) and `CacheLock` (`lock:`) add their own prefixes on top.

## Tests

Expand All @@ -38,4 +38,4 @@ Use `uv` for everything (`uv sync --group dev --all-extras`, `uv run ...`).
- Changelog: add a `changelog.d/<issue>.<section>.md` fragment and never edit `CHANGELOG.md`. The format is in `changelog.d/README.md`.
- Docs changes go into both `docs/` and `i18n/zh-TW/docs/`. Terms follow `i18n/zh-TW/GLOSSARY.md`.
- Never edit `version` in `pyproject.toml` by hand. Releases run through the `release.yml` workflow (`docs/DEVELOPMENT.md#releasing`).
- Breaking changes need a runtime warning in a release first and land only in the next minor, with a section in that minor's migration guide (`docs/MIGRATING_0_4.md` for 0.4.0; 0.5.0 gets a new `docs/MIGRATING_0_5.md` with its zh-TW copy and nav entry).
- Breaking changes need a runtime warning in a release first and land only in the next minor, with a section in that minor's migration guide (`docs/MIGRATING_0_4.md` for 0.4.0, `docs/MIGRATING_0_5.md` for 0.5.0, each with its zh-TW copy and nav entry).
14 changes: 6 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ A high-performance caching extension for FastAPI: a server-side response cache w
and `@cached` for a plain function, keyed on its arguments.
- **Backends** — in-memory, Redis and Memcached, with atomic counters,
one-shot values and locks.
- **Sessions and OAuth state (deprecated)**: signed session tokens and one-time
OAuth state tokens. Both are deprecated in 0.4.0 and removed in 0.5.0; see
[where to move](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_4/#session-state-deprecated).

## Installation

Expand All @@ -38,15 +35,14 @@ uv add fastapi-cachex
```

Everything in the core package works with the in-memory backend. The other
backends and the optional session transports ship as extras:
backends ship as extras:

| Extra | Install | Pulls in | Needed for |
|-------|---------|----------|------------|
| `redis` | `uv add "fastapi-cachex[redis]"` | `redis[hiredis]`, `orjson` | `AsyncRedisCacheBackend` |
| `memcached` | `uv add "fastapi-cachex[memcached]"` | `pymemcache` | `MemcachedBackend` |
| `jwt` | `uv add "fastapi-cachex[jwt]"` | `PyJWT` | `SessionConfig(token_format="jwt")` |

Extras combine: `uv add "fastapi-cachex[redis,jwt]"`.
Extras combine: `uv add "fastapi-cachex[redis,memcached]"`.

## Quick Start

Expand Down Expand Up @@ -96,7 +92,8 @@ async def report(cache: AppCache):
> [!WARNING]
> The default cache key carries no user identity. Cache authenticated endpoints
> with `private=True` or a per-user key builder plus `cache_authorized=True`
> (requests with `Authorization` or a session otherwise bypass the backend) — see
> (requests with `Authorization` or non-empty `request.session` data otherwise
> bypass the backend) — see
> [Authenticated endpoints](https://fastapi-cachex.readthedocs.io/en/latest/HTTP_CACHING/#authenticated-endpoints).

## Documentation
Expand All @@ -106,13 +103,14 @@ a code block that includes one of the runnable examples renders on the site,
while GitHub shows only its `--8<--` include line (the sentence before each
block links the example file).

- [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/) — what 0.5.0 removes and how to upgrade from 0.4.x
- [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
- [Migrating from fastapi-cache2](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_FROM_FASTAPI_CACHE2/) — its API mapped onto this one, and the behaviour that differs
- [When to use it](https://fastapi-cachex.readthedocs.io/en/latest/COMPARISON/) — how it compares with fastapi-cache2, cashews, aiocache and a CDN, and when another one fits better
- [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
- [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
- Deprecated, removed in 0.5.0: [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/), [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) (one-shot OAuth/CSRF state tokens) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
- [Cache flow](https://fastapi-cachex.readthedocs.io/en/latest/CACHE_FLOW/) — what happens inside a cached request
- [Runnable examples](https://github.com/allen0099/FastAPI-CacheX/tree/master/examples) — one complete app per feature, each covered by the test suite
- [API reference](https://fastapi-cachex.readthedocs.io/en/latest/api/http-caching/)
Expand Down
13 changes: 5 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ the problem is still there.

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.

## Reporting a vulnerability

Please do **not** open a public issue, pull request or discussion for a
Expand All @@ -27,9 +24,9 @@ reporting instead:
The report is visible only to you and the maintainers until an advisory is
published.

Examples of what counts: a way to forge, reuse or steal a session or OAuth
state token, to read or poison another client's cached response, or to make
the library leak data it was given to protect. A bug in your own application's
Examples of what counts: a way to read or poison another client's cached
response, to make `@cache` store or serve a response it should have bypassed,
or to make the library leak data it was given to protect. A bug in your own application's
use of the library, or in a dependency that FastAPI-CacheX does not work around,
is usually better reported to that project.

Expand All @@ -39,8 +36,8 @@ The more of these a report has, the faster it can be confirmed:

- The FastAPI-CacheX version, the Python version, and the backend in use
(memory, Redis or Memcached) with its server version.
- The affected component, for example `@cache`, `CacheManager`, the session
middleware, `StateManager` or `CacheLock`, and the relevant configuration.
- The affected component, for example `@cache`, `CacheManager` or
`CacheLock`, and the relevant configuration.
- Steps to reproduce, ideally a minimal FastAPI app or test case.
- What an attacker can achieve, and under which conditions.
- Any fix or mitigation you have in mind.
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/421.changed.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**A third-party backend `delete()` that returns `None` now counts as not
removed.** The non-atomic fallbacks on `BaseCacheBackend` (`delete_many()`,
`get_and_delete()`, `delete_if_equals()`) read `delete()`'s result with
`bool()`, so `None` is `False` and no `FutureWarning` is emitted; 0.4.x
counted it as removed. Return a `bool` from `delete()`, as 0.4.0 requires. See
[Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/#backend-delete-none).
5 changes: 5 additions & 0 deletions changelog.d/421.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
**Lower dependency floors: `itsdangerous` and `starlette` are no longer
required directly, and `fastapi>=0.128.2` replaces `fastapi>=0.133.0`.** Only
the removed session middleware needed `itsdangerous` and `starlette>=1.0.0`;
the core now runs on whichever Starlette the installed FastAPI allows, down to
0.40.0. The `lowest` tox env tests that oldest stack.
8 changes: 8 additions & 0 deletions changelog.d/421.removed.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**`@cache` no longer counts a session loaded by the removed session
middleware as a credential.** A request bypasses the shared backend for an
`Authorization` header or a non-empty `request.session` from any session
middleware, as before; a session that `FastAPICacheXSessionMiddleware` loaded
no longer counts, since the middleware is gone. A `Cookie` header alone still
does not bypass the cache, and `X-Session-Token` in `@cache(vary=[...])` is
still keyed on its SHA-256 digest, so keys do not change.
See [Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/#cache-session-token).
9 changes: 9 additions & 0 deletions changelog.d/421.removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
**Sessions and OAuth state are removed, and so is the `jwt` extra.**
`fastapi_cachex.session` and `fastapi_cachex.state`, deprecated in 0.4.0
(#420), are gone: `FastAPICacheXSessionMiddleware`, `SessionManager`,
`SessionConfig`, the session dependencies, the token serializers,
`StateManager` and their proxies and exceptions. Importing either package
raises `ModuleNotFoundError`, and their names on `fastapi_cachex` raise
`AttributeError` instead of warning. The `jwt` extra, which only installed
`PyJWT` for JWT session tokens, is removed too. See
[Migrating to 0.5.0](https://fastapi-cachex.readthedocs.io/en/latest/MIGRATING_0_5/#session-state-removed).
7 changes: 3 additions & 4 deletions docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,12 @@ cached wherever it is called. From [`examples/app_cache.py`](https://github.com/
deduplication. An expired key counts as free; a key holding an undecodable
value does not, even though `get()` treats it as a miss.
- Keys live under their own `cache:`-prefixed namespace by default, separate from
the HTTP route cache and OAuth state, so `clear()`/`clear_prefix()` never touch
the HTTP route cache and the locks, so `clear()`/`clear_prefix()` never touch
unrelated cache entries.
- The prefix is matched as a plain string prefix. A manager with
`key_prefix="cache:"` therefore also clears the entries of one with
`key_prefix="cache:users:"`, and an empty `key_prefix` makes `clear()` remove
everything in the backend, including HTTP responses, locks, OAuth states and
sessions. Give each manager a prefix that does not start with another's.
everything in the backend, including HTTP responses and locks. Give each manager a prefix that does not start with another's.
- `clear_pattern(pattern)` treats only `pattern` as a glob; `key_prefix` is
always matched literally. With a prefix free of glob metacharacters
(`*`, `?`, `[`, `]`, `\`) it hands `key_prefix + pattern` to the backend's
Expand All @@ -143,7 +142,7 @@ cached wherever it is called. From [`examples/app_cache.py`](https://github.com/
> `get()`/`set()`/`add()`/`delete()`/`has()` work normally. Use Redis or the in-memory
> backend if you need bulk clearing. Do not fall back to the backend's own `clear()`
> on Memcached: `MemcachedBackend.clear()` issues `flush_all` and wipes the whole
> server, HTTP responses, sessions, locks and other applications' keys included.
> server, HTTP responses, locks and other applications' keys included.

### Group invalidation on Memcached {#group-invalidation-on-memcached}

Expand Down
18 changes: 8 additions & 10 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Backends

Every cache — HTTP responses, `CacheManager` values, sessions and OAuth states —
Every cache — HTTP responses, `CacheManager` values and `CacheLock` locks —
lives in one backend, registered once at startup with `BackendProxy.set()`.

## Choosing a backend
Expand Down Expand Up @@ -170,8 +170,8 @@ after about 0.25 s. The costs:
- `socket_timeout` also bounds slow replies. Set both timeouts above your
normal Redis latency, including for the largest entries you cache.
- The settings apply to every command the backend sends, not only to `@cache`.
`CacheManager`, `StateManager`, `CacheLock` and sessions, which raise backend
errors instead of failing open, see those errors sooner.
`CacheManager` and `CacheLock`, which raise backend errors instead of
failing open, see those errors sooner.

`RedisConfig` has no `retry` field, so pass it to the constructor.

Expand Down Expand Up @@ -336,7 +336,6 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300):
`exptime=-1` (retrying if another writer replaced the value in between). If
writers keep replacing it for 16 attempts in a row, Memcached raises
`CacheXError` rather than report the key as missing.
`StateManager.consume_state`, `StateManager.delete_state`,
`CacheManager.delete` and `invalidate()` are built on it.
- `set_if_absent(key, value, ttl=None) -> bool` — stores `value` only when
`key` does not exist (an expired key counts as absent) and reports whether it
Expand All @@ -356,24 +355,23 @@ if await backend.set_if_absent(f"stream:{user_id}", owner, ttl=300):
same bytes with the new exptime (`TOUCH` takes no CAS token).
- `set_if_equals(key, expected, value, ttl=None) -> bool` — stores `value` only
while `key` still holds `expected`: a compare-and-set that fails if anything
changed, deleted or expired the key since the caller read it. Sessions save
through it (see [Session writes](MIGRATING_0_4.md#session-writes)). Memory
changed, deleted or expired the key since the caller read it. Memory
compares under its lock, Redis compares in Python then runs a Lua script
(`GET` compare + `SET`, with `EX` when `ttl` is set), and Memcached uses
`GETS` + a `CAS` write of the new value.

All six have a non-atomic fallback on `BaseCacheBackend`, so a third-party backend
that only implements the abstract methods keeps working; override them to get
real atomicity. The fallbacks rely on `delete()` returning whether the key held
an entry; a `delete()` that still returns `None`, as in 0.3.x, warns and counts
as removed until 0.5.0 (see [delete() return value](MIGRATING_0_4.md#backend-delete)).
an entry; since 0.5.0 a `delete()` that still returns `None`, as in 0.3.x,
counts as not removed (see [Backend `delete()` returning `None`](MIGRATING_0_5.md#backend-delete-none)).

Complete runnable example: [`examples/rate_limit.py`](https://github.com/allen0099/FastAPI-CacheX/blob/master/examples/rate_limit.py).

## TTL values

Every `ttl` argument (`set`, `set_if_absent`, `set_if_equals`, `increment`, and the `CacheManager`,
`CacheLock` and `StateManager` methods and defaults built on them) is either `None`, meaning
Every `ttl` argument (`set`, `set_if_absent`, `set_if_equals`, `increment`, and the `CacheManager`
and `CacheLock` methods and defaults built on them) is either `None`, meaning
the entry never expires, or a number of seconds from 1 up to `MAX_TTL`
(2**31 - 1, about 68 years), as an `int` or a `datetime.timedelta` of whole
seconds (`timedelta(minutes=5)` is stored as `300`). The checks run before any
Expand Down
Loading
Loading