This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
FastAPI-CacheX is a Python library (package: fastapi_cachex) providing HTTP caching and optional session management for FastAPI. It is published to PyPI as fastapi-cachex.
All commands use uv for environment management.
# Install development dependencies
uv sync --group dev
# Run tests
uv run pytest
# Run a single test file
uv run pytest tests/test_cache.py
# Run a specific test
uv run pytest tests/test_cache.py::test_function_name
# Run tests with coverage
uv run pytest --cov=fastapi_cachex --cov-report=term-missing
# Lint and format (ruff)
uv run ruff check fastapi_cachex
uv run ruff format fastapi_cachex
# Type checking
uv run mypy fastapi_cachex
uv run mypy fastapi_cachex --strict
# Run pre-commit on all files
uv run pre-commit run --all-files
# Run tox across all Python versions (3.10–3.14)
uv run tox
tox -e py310 # single version
tox -e lowest # every direct dependency at its declared floor, Python 3.10The library has five independent subsystems:
1. HTTP Caching (fastapi_cachex/cache.py, proxy.py, backends/)
@cache(...)decorator wraps FastAPI route handlers. It injects aRequestparameter into the handler signature if not already present, so the handler does not need to declare it.- Cache flow: check
no-store→ checkno-cache→ check ETag (If-None-Match) → check TTL-based cache hit → execute handler → store result. - Fails open by default (
fail_open=True): a backend error ongetis logged and treated as a miss, one onsetis logged and the response served unstored.fail_open=Falsepropagates the error. - Only GET requests are cached; other methods bypass the cache entirely.
- Cache keys follow the format
method|||host|||path|||query_params(separator defined intypes.py). Host and path go throughescape_key_component(|→%7C,%→%25) so client input cannot inject the separator;clear_pathencodes its argument androutes.pydecodes for display. BackendProxyis a non-instantiable class-level singleton (viaProxyMeta). CallBackendProxy.set(backend)at app startup;BackendProxy.get()raisesBackendNotFoundErrorif unset.get_backend_or_fallback()registers aMemoryBackendwhen none is set;@cache,CacheBackendandAppCacheuse it.ProxyBase.get_or_create(factory)is the one lazy get-or-create: a per-classthreading.Lock(sync dependencies run in worker threads; per class so a factory can call another proxy'sget_or_create). Used byget_backend_or_fallback,get_app_cacheandget_state_manager(no memory fallback for states).- Cache values are stored as
CacheEntry(fingerprint, content, media_type)dataclass (defined intypes.py).
2. Application-Level Caching (fastapi_cachex/manager.py, manager_proxy.py)
CacheManageris a thin, JSON-serializing wrapper around whatever backendBackendProxyhas configured, for caching arbitrary developer values (not HTTP responses) viaget/set/add/delete/has/get_or_set/clear_prefix/clear.add()is store-if-absent on top ofbackend.set_if_absent.- Keys live under their own
cache:-prefixed namespace by default (configurable viakey_prefix), separate from HTTP route keys andoauth_state:. get()never raises — returnsdefault(Noneunless overridden) on a miss or decode failure.set()letsTypeErrorpropagate for non-JSON-serializable values.CacheManagerProxymirrorsBackendProxy/SessionManagerProxy. TheAppCacheFastAPI dependency (get_app_cache, independencies.py) lazily creates and registers a defaultCacheManageron first use.clear()/clear_prefix()are built onbackend.get_all_keys()+backend.delete_many(), so they are no-ops on the Memcached backend (see below).
3. Session Management (fastapi_cachex/session/)
- Optional subsystem, activated via
FastAPICacheXSessionMiddleware(the header-onlySessionMiddlewareis deprecated until 0.4.0) andSessionManagerProxy. SessionManagerhandles create/get/update/delete/invalidate/regenerate operations. It storesSessionPydantic models serialized as JSON, wrapped inCacheEntryfor backend compatibility.- Token signing:
simpleformat uses HMAC-SHA256 (SecurityManager);jwtformat uses PyJWT (optional dependencyfastapi-cachex[jwt]). get_session()saves only when sliding expiration renewed the session (or withtouch=True), so the storedlast_accessedis the last write, not the last lookup. Session entries use the constant fingerprint"session"; nothing compares it.- Session token is passed via custom header (
X-Session-Tokenby default),Authorization: Bearertoken, or (FastAPICacheXSessionMiddlewareonly) the session cookie. SessionManagerProxymirrors theBackendProxypattern for managing theSessionManagersingleton.- Key FastAPI dependencies:
get_session,require_session,get_optional_session(insession/dependencies.py). These accept anonymous sessions (user=None);require_user_session/AuthenticatedSessionalso require a user.UserSessionDepis still an alias ofSessionDepuntil 0.4.0. JWTTokenSerializeremits oneUserWarningat construction whensecret_keyis shorter (in UTF-8 bytes) than the HMAC hash output (48 for HS384, 64 for HS512).rotate_session_id(request)(same module) regenerates the loaded session's ID at login against session fixation; a no-op when none was loaded. The middleware notices the changed ID and sends the new token.FastAPICacheXSessionMiddlewarewrapsrequest.sessionin_RequestSession, which records an explicitclear(): that deletes the loaded session (logout) even with empty data, and later writes start a new anonymous one. Emptying viadel/pop()keeps a user session (saved empty) and deletes an anonymous one.
4. State Management (fastapi_cachex/state/)
StateManagerprovides one-time-use state tokens for OAuth flows. States are consumed (deleted) on first successfulconsume_state()call.- Uses the same cache backends, with key prefix
oauth_state:by default. create_state(binding=...)/consume_state(state, binding=...)bind a state to the client that started the flow (SHA-256 stored,hmac.compare_digest); a mismatch in either direction raisesInvalidStateError, and the state is consumed either way.
5. Distributed Lock (fastapi_cachex/lock.py)
CacheLock(name, ttl=60, ...)is a lease onset_if_absent/delete_if_equals/expire_if_equals, with a per-instance random token; keys use thelock:prefix by default.- It uses
BackendProxy.get()(noMemoryBackendfallback). One instance per acquisition: re-acquiring a held instance raisesRuntimeError;async withraisesLockTimeoutError;release()/extend()returnFalseonce the lease is lost.
All backends implement BaseCacheBackend (abstract base in backends/base.py):
MemoryBackend: In-process dict with background cleanup task. Not suitable for multi-process production use.AsyncRedisCacheBackend(backends/redis.py): Fully async; usesSCAN(notKEYS) for pattern operations. Requiresredis[hiredis]andorjsonextras.MemcachedBackend(backends/memcached.py):clear_pattern/get_all_keysare no-ops (return0/[]with aRuntimeWarning) since the Memcached protocol has no key enumeration. Runs the sync pymemcache client in worker threads with connection pooling (use_pooling=True,default_noreply=False), so concurrent calls never share a socket and every write is acknowledged before the next call on another socket can observe it. Requires thememcachedextra (memcacheis a deprecated alias until 0.4.0).
The Redis and Memcached backends namespace keys automatically (key_prefix, default fastapi_cachex:); MemoryBackend has no prefix.
Five non-abstract atomic primitives live on the base class with non-atomic fallbacks, and every built-in backend overrides them (see docs/BACKENDS.md "Atomic backend primitives"):
increment(key, delta=1, ttl=None) -> int: fixed-window counter;ttlapplies only when the counter is created. Redis runs a registered Lua script, Memcached usesADD+INCR/DECR, memory works under its lock. A counter reads back throughget()as aCacheEntrywithCOUNTER_FINGERPRINT(types.py).get_and_delete(key) -> CacheEntry | None: one-shot retrieval (RedisGETDEL, Memcachedgets+cas(..., exptime=-1), retried up to 16 times, thenCacheXError).StateManager.consume_state,delete_state,CacheManager.deleteandinvalidate()use it.delete()keeps returningNonefor 0.3.x compatibility.set_if_absent(key, value, ttl=None) -> bool: claim-if-free for locks/slots. RedisSET NX EX, MemcachedADD, memory under its lock.delete_if_equals(key, expected) -> bool: release only while the key still holdsexpected(compared as decodedCacheEntry). Redis compares in Python then deletes via a Lua script that re-checks the raw bytes; Memcached usesGETS+CASwith exptime-1(immediate expiry), since classicDELETEhas no CAS.expire_if_equals(key, expected, ttl) -> bool: renew the TTL only while the key still holdsexpected(used byCacheLock.extend). Redis compares in Python then runs a LuaGETcompare +EXPIRE; MemcachedGETS+CASwriting the same bytes with the new exptime.
validate_ttl (in backends/base.py) accepts None or an int from 1 to MAX_TTL (2**31 - 1) and raises TypeError for floats/bools; validate_delta requires an int in signed 64-bit range. Both run before any I/O. Memcached's _expiry also rejects expiries after 2038-01-19.
delete_many(keys) -> int is the sixth non-abstract base method: a per-key loop by default, one batched operation on Redis (DEL) and Memory (single lock). Memcached sends one acknowledged DELETE per key inside a single worker call and counts the ones that existed (pymemcache's delete_many returns True regardless). Every Memcached multi-step op (increment, get_and_delete, *_if_equals) also runs as one sync helper in one asyncio.to_thread call.
backends/codec.py holds the JSON CacheEntry codec shared by Redis and Memcached; decode_entry maps a bare integer to a counter entry and every malformed value to None.
tests/conftest.py sets MemoryBackend as the default backend via an autouse=True fixture for every test. Tests requiring Redis or Memcached must configure their own backends. The memory_backend fixture manages the cleanup task lifecycle.
[tool.pytest.ini_options] sets asyncio_mode = "auto" (no @pytest.mark.asyncio), --strict-markers, xfail_strict = true and filterwarnings = ["error"]: an expected warning needs pytest.warns, live Memcached resets go through flush_memcached in tests/live_servers.py, and the autouse close_network_clients fixture closes every Redis/Memcached client a test builds (an unclosed socket's ResourceWarning would fail a later test). The one global ignore covers starlette 1.0.0's deprecated anyio alias in the lowest tox env.
- Ruff is configured with
extend-select = ['ALL']with specific ignores (seepyproject.toml). Notable: E501 (line length), FBT001/FBT002 (boolean args — intentional for Cache-Control API).fastapi.Dependsis allowed in argument defaults viaflake8-bugbear.extend-immutable-callsrather than ignoring B008. - mypy runs in strict mode on the package (not tests).
- pydocstring convention is Google style.
- Forward references are mostly quoted annotations with
TYPE_CHECKINGimports; only a couple of modules usefrom __future__ import annotations. - All public functions must have complete type annotations.
- Coverage threshold is 90% (enforced by
pytest-cov). - Changelog entries go in
changelog.d/<issue>.<section>.mdfragments (bold summary first, no leading-, no issue link), not inCHANGELOG.md; the release merges them. Seedocs/DEVELOPMENT.md#changelog-fragments.