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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ async def report(cache: AppCache):
- [Session management](https://fastapi-cachex.readthedocs.io/en/latest/SESSION/) and [JWT claims](https://fastapi-cachex.readthedocs.io/en/latest/JWT_CLAIMS/)
- [OAuth state](https://fastapi-cachex.readthedocs.io/en/latest/STATE/) — one-shot OAuth/CSRF state tokens
- [Distributed lock](https://fastapi-cachex.readthedocs.io/en/latest/LOCK/) — `CacheLock` for multi-process mutual exclusion
- [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/)
- [Development guide](https://fastapi-cachex.readthedocs.io/en/latest/DEVELOPMENT/) and [contributing](https://fastapi-cachex.readthedocs.io/en/latest/CONTRIBUTING/)
- [Changelog](https://github.com/allen0099/FastAPI-CacheX/blob/master/CHANGELOG.md) · [Known limitations and planned work](https://github.com/allen0099/FastAPI-CacheX/issues)
Expand Down
2 changes: 2 additions & 0 deletions docs/APP_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ if await manager.add(f"webhook:{event_id}", True, ttl=86400):
await manager.clear_pattern("user:*") # matches "myapp:user:*"
```

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

## Behavior

- `get()` returns `None` (or a supplied `default=`) on a cache miss — it never
Expand Down
4 changes: 4 additions & 0 deletions docs/BACKENDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ Keep `protocol=2` unless you need RESP3 features *and* your `hiredis` build
supports it (RESP3 needs hiredis >= 3.0). Redis 8.0 speaks RESP3, but an older
hiredis will fail to negotiate it.

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

## Memcached

Install the extra with `uv add "fastapi-cachex[memcached]"`. Before 0.3.8 it was
Expand Down Expand Up @@ -240,6 +242,8 @@ All five have a non-atomic fallback on `BaseCacheBackend`, so a third-party back
that only implements the abstract methods keeps working; override them to get
real atomicity.

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`, `increment`, and the `CacheManager`
Expand Down
2 changes: 2 additions & 0 deletions docs/HTTP_CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ The `@cache` decorator caches the responses of FastAPI GET routes and handles
`Cache-Control`, `ETag` and `If-None-Match` for you. This page covers how to use
it; [Cache flow](CACHE_FLOW.md) explains what happens inside a request.

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

## The `@cache` decorator

```python
Expand Down
2 changes: 2 additions & 0 deletions docs/LOCK.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ finally:
await lock.release()
```

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

## Behavior

- **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.
Expand Down
2 changes: 2 additions & 0 deletions docs/SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ The six `cookie_*` settings of `SessionConfig` (`cookie_name`, `cookie_max_age`,
`cookie_same_site`, `cookie_https_only`, `cookie_domain`) are **read only by
`FastAPICacheXSessionMiddleware`**; setting them has no effect when `SessionMiddleware` is installed.

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

## Features

- ✅ **Session lifecycle management**: create, read, update, delete, invalidate
Expand Down
2 changes: 2 additions & 0 deletions docs/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ does remove them, because it clears everything under the backend's namespace.

Everything in this guide can also be imported from the top-level `fastapi_cachex` package.

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

## Quick start

```python
Expand Down
56 changes: 56 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Examples

Each file here is a complete FastAPI app that shows one feature of
FastAPI-CacheX. They use only the public API and the in-memory backend (except
`redis_backend.py`), so they run without any server.

| Example | What it shows | Needs |
|---------|---------------|-------|
| [`http_cache.py`](http_cache.py) | `@cache` with a TTL, ETag and `304 Not Modified`, `no_cache` and `private` routes, `clear_path()` after an update, the monitoring routes behind an admin check | — |
| [`app_cache.py`](app_cache.py) | `CacheManager.get_or_set()` for an expensive call, `add()` as an idempotency check, the `AppCache` dependency | — |
| [`session_login.py`](session_login.py) | `FastAPICacheXSessionMiddleware` with cookies: an anonymous session, login with `rotate_session_id()`, `AuthenticatedSession`, logout with `request.session.clear()` | — |
| [`session_jwt.py`](session_jwt.py) | Sessions with `token_format="jwt"` for API clients (`Authorization: Bearer`), revoked on logout | `jwt` extra |
| [`oauth_state.py`](oauth_state.py) | One-time OAuth `state` values with `StateManager`, bound to the browser that started the flow | — |
| [`cache_lock.py`](cache_lock.py) | `CacheLock`, waiting (`async with`) and non-blocking (`acquire(blocking=False)`) | — |
| [`rate_limit.py`](rate_limit.py) | A fixed-window rate limiter on `backend.increment()`, answering `429` with `Retry-After` | — |
| [`redis_backend.py`](redis_backend.py) | `AsyncRedisCacheBackend` configured from environment variables in the lifespan and closed on shutdown | `redis` extra, a Redis server |

## Running an example

From a checkout of this repository:

```bash
uv sync --group dev
uv run --with "fastapi-cli[standard]" fastapi dev examples/http_cache.py
```

Then open <http://127.0.0.1:8000/docs>. The dev group already includes the
`jwt` and `redis` extras. `fastapi dev` comes from `fastapi-cli`, which is not
a dependency of this project, hence `--with`. Uvicorn works too:

```bash
uv run --with uvicorn uvicorn examples.http_cache:app --reload
```

`redis_backend.py` reads `REDIS_HOST`, `REDIS_PORT`, `REDIS_DB` and
`REDIS_PASSWORD`; point it at a server you can write to.

In your own project, install the extras an example needs, for example
`uv add "fastapi-cachex[jwt]"`, and copy the file.

## Secrets

The session examples read their signing key from `SESSION_SECRET_KEY` and fall
back to an obvious development placeholder. The monitoring routes in
`http_cache.py` stay closed until `CACHE_ADMIN_TOKEN` is set. Always set real,
random values outside local development:

```bash
python -c "import secrets; print(secrets.token_urlsafe(48))"
```

## Tests

`tests/test_examples.py` loads every example and drives its main flow, so the
examples keep working as the library changes. A new example needs a test
there and a row in the table above.
78 changes: 78 additions & 0 deletions examples/app_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Caching your own values with ``CacheManager``.

``get_or_set`` computes a value once and serves it from the cache until it
expires; ``add`` stores a key only if it is absent, which makes a simple
idempotency check. Both come through the ``AppCache`` dependency.

Run it from a checkout (see ``examples/README.md``)::

uv run --with "fastapi-cli[standard]" fastapi dev examples/app_cache.py
"""

import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated

from fastapi import FastAPI
from fastapi import Header
from fastapi import HTTPException

from fastapi_cachex import AppCache
from fastapi_cachex import BackendProxy
from fastapi_cachex import CacheManager
from fastapi_cachex import CacheManagerProxy
from fastapi_cachex.backends import MemoryBackend

backend = MemoryBackend()
BackendProxy.set(backend)
# Optional: without this, AppCache creates a CacheManager with the defaults
# (key prefix "cache:", no TTL) on first use.
CacheManagerProxy.set(CacheManager(key_prefix="example:", default_ttl=300))


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Stop the memory backend's cleanup task on shutdown."""
yield
await backend.aclose()


app = FastAPI(lifespan=lifespan)

# How often the "slow upstream" was really called.
upstream_calls: dict[str, int] = {"rates": 0}


async def fetch_rates() -> dict[str, float]:
"""Stand-in for a slow call to another service."""
upstream_calls["rates"] += 1
return {"EUR": 0.92, "JPY": 151.3}


@app.get("/rates")
async def read_rates(app_cache: AppCache) -> dict[str, float]:
"""Call the upstream once, then answer from the cache for 60 seconds."""
rates: dict[str, float] = await app_cache.get_or_set("rates", fetch_rates, ttl=60)
return rates


@app.post("/orders", status_code=201)
async def create_order(
app_cache: AppCache, idempotency_key: Annotated[str, Header()]
) -> dict[str, str]:
"""Accept each ``Idempotency-Key`` once; a retry gets ``409``."""
order_id = secrets.token_hex(8)
# add() is atomic: of two concurrent retries exactly one gets True.
if not await app_cache.add(f"order:{idempotency_key}", order_id, ttl=3600):
existing = await app_cache.get(f"order:{idempotency_key}")
raise HTTPException(
status_code=409, detail=f"Already accepted as order {existing}"
)
return {"order_id": order_id}


@app.delete("/rates")
async def forget_rates(app_cache: AppCache) -> dict[str, bool]:
"""Drop the cached rates; the next read calls the upstream again."""
return {"deleted": await app_cache.delete("rates")}
76 changes: 76 additions & 0 deletions examples/cache_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Mutual exclusion across workers with ``CacheLock``.

``/reports/{name}/rebuild`` waits for the lock, so concurrent rebuilds of the
same report run one after the other. ``/imports`` does not wait: a second
import while one is running gets ``409``. With a shared backend such as Redis
this holds across processes and machines, not just within one.

Run it from a checkout (see ``examples/README.md``)::

uv run --with "fastapi-cli[standard]" fastapi dev examples/cache_lock.py
"""

import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi import HTTPException

from fastapi_cachex import BackendProxy
from fastapi_cachex import CacheLock
from fastapi_cachex import LockTimeoutError
from fastapi_cachex.backends import MemoryBackend

backend = MemoryBackend()
BackendProxy.set(backend)


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Stop the memory backend's cleanup task on shutdown."""
yield
await backend.aclose()


app = FastAPI(lifespan=lifespan)

# How many rebuilds run right now, and the most that ever ran at once.
rebuilds = {"running": 0, "max_running": 0}


async def rebuild_report() -> None:
"""Stand-in for slow work that must not run twice at the same time."""
rebuilds["running"] += 1
rebuilds["max_running"] = max(rebuilds["max_running"], rebuilds["running"])
try:
await asyncio.sleep(0.2)
finally:
rebuilds["running"] -= 1


@app.post("/reports/{name}/rebuild")
async def rebuild(name: str) -> dict[str, str]:
"""Wait up to 10 seconds for the lock, then rebuild."""
try:
# ttl bounds how long a crashed holder keeps the lock; make it longer
# than the work, or call lock.extend() while working.
async with CacheLock(f"report:{name}", ttl=30, timeout=10):
await rebuild_report()
except LockTimeoutError as exc:
raise HTTPException(status_code=503, detail="Report is busy") from exc
return {"rebuilt": name}


@app.post("/imports")
async def start_import() -> dict[str, str]:
"""Refuse at once when another import holds the lock."""
# One CacheLock instance per acquisition.
lock = CacheLock("import", ttl=60)
if not await lock.acquire(blocking=False):
raise HTTPException(status_code=409, detail="An import is already running")
try:
await asyncio.sleep(0.2) # the import itself
finally:
await lock.release()
return {"imported": "ok"}
101 changes: 101 additions & 0 deletions examples/http_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""HTTP response caching with ``@cache``.

Shows a TTL-cached route with ETag / ``304 Not Modified``, ``no_cache`` and
``private`` routes, clearing a path after an update, and the monitoring routes
behind an admin check.

Run it from a checkout (see ``examples/README.md``)::

uv run --with "fastapi-cli[standard]" fastapi dev examples/http_cache.py
"""

import os
import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import Depends
from fastapi import FastAPI
from fastapi import Header
from fastapi import HTTPException

from fastapi_cachex import BackendProxy
from fastapi_cachex import CacheBackend
from fastapi_cachex import add_routes
from fastapi_cachex import cache
from fastapi_cachex.backends import MemoryBackend

# One backend per process; register it before the first request.
backend = MemoryBackend()
BackendProxy.set(backend)


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Stop the memory backend's cleanup task on shutdown."""
yield
await backend.aclose()


app = FastAPI(lifespan=lifespan)

PRODUCTS: dict[int, dict[str, object]] = {
1: {"name": "Keyboard", "price": 49},
2: {"name": "Mouse", "price": 19},
}

# How often each handler really ran, so a cache hit is visible.
handler_runs: dict[str, int] = {"product": 0, "stock": 0}


@app.get("/products/{product_id}")
@cache(ttl=60)
async def read_product(product_id: int) -> dict[str, object]:
"""Served from the cache for 60 seconds, with an ETag for revalidation."""
handler_runs["product"] += 1
if product_id not in PRODUCTS:
raise HTTPException(status_code=404, detail="Unknown product")
return {"id": product_id, **PRODUCTS[product_id]}


@app.put("/products/{product_id}")
async def update_product(
product_id: int, price: int, cache_backend: CacheBackend
) -> dict[str, object]:
"""Change a price, then drop the cached copy so the next GET sees it."""
if product_id not in PRODUCTS:
raise HTTPException(status_code=404, detail="Unknown product")
PRODUCTS[product_id]["price"] = price
await cache_backend.clear_path(f"/products/{product_id}")
return {"id": product_id, **PRODUCTS[product_id]}


@app.get("/stock/{product_id}")
@cache(ttl=60, no_cache=True)
async def read_stock(product_id: int) -> dict[str, int]:
"""Sent as ``no-cache``: clients revalidate with ``If-None-Match`` each time."""
handler_runs["stock"] += 1
return {"id": product_id, "in_stock": 3}


@app.get("/me/preferences")
@cache(ttl=60, private=True)
async def my_preferences() -> dict[str, str]:
"""Sent as ``private``: never stored in the shared backend."""
# In a real app this depends on the verified caller.
return {"theme": "dark"}


def require_admin(x_admin_token: str | None = Header(default=None)) -> None:
"""Allow the monitoring routes only with the token from ``CACHE_ADMIN_TOKEN``."""
expected = os.environ.get("CACHE_ADMIN_TOKEN")
# With no token configured the routes stay closed.
if not expected or x_admin_token is None:
raise HTTPException(status_code=403, detail="Forbidden")
if not secrets.compare_digest(x_admin_token, expected):
raise HTTPException(status_code=403, detail="Forbidden")


# GET /_cache/cached-hits and /_cache/cached-records. They have no auth of
# their own, so always pass a dependency that has.
add_routes(app, prefix="/_cache", dependencies=[Depends(require_admin)])
Loading
Loading