API Reference¶
Setup¶
from fastapi import FastAPI
from redis_fastapi import FastAPIRedis
app = FastAPI()
FastAPIRedis(app).lifespan() # connection pools only
FastAPIRedis(app).lifespan().caching() # + DI caching support
FastAPIRedis(app).lifespan().rate_limiting() # + DI rate limiting support
FastAPIRedis(app).lifespan().sessions() # + server-side sessions
Or compose the lifespan directly:
redis_lifespan is an async context manager that creates the shared async connection pool on startup and closes it on shutdown. Accessing the pool without a registered lifespan raises RuntimeError.
Redis client dependencies¶
AsyncRedisDep¶
from redis_fastapi import AsyncRedisDep
@app.get("/")
async def handler(redis: AsyncRedisDep):
await redis.get("key")
Annotated[AsyncRedis | AsyncRedisCluster, Depends(get_async_redis)] - returns a cached async Redis client backed by the shared connection pool. Returns AsyncRedisCluster when settings.cluster is True. When settings.sentinel is True, returns AsyncRedis connected to the primary that the Sentinels report.
get_async_redis()¶
Async function underlying AsyncRedisDep. Returns the same client instance on every call (no per-request overhead). Raises RuntimeError if no lifespan has initialised the pool.
Cache backend dependencies¶
CacheBackendDep¶
from redis_fastapi import CacheBackendDep
@app.get("/dashboard/{user_id}")
async def dashboard(user_id: int, cache: CacheBackendDep):
cached = await cache.get(f"stats:{user_id}", eviction_group="dashboard")
if cached:
return cached
result = await compute_dashboard(user_id)
await cache.set(f"stats:{user_id}", result, ttl=300, eviction_group="dashboard")
return result
Annotated[CacheBackend, Depends(get_cache_backend)] - async cache backend with get/set/delete/has/delete_group. Use for conditional caching, cascade invalidation, and dynamic TTL.
CacheBackend(redis, coder=...) accepts any Coder. Use
pydantic_model_coder(Model) when cache hits should decode back into Pydantic
model instances.
SyncCacheBackendDep¶
from redis_fastapi import SyncCacheBackendDep
@app.get("/sync-dashboard/{user_id}")
def dashboard(user_id: int, cache: SyncCacheBackendDep):
cached = cache.get(f"stats:{user_id}", eviction_group="dashboard")
if cached:
return cached
result = compute_dashboard(user_id)
cache.set(f"stats:{user_id}", result, ttl=300, eviction_group="dashboard")
return result
Annotated[SyncCacheBackend, Depends(get_sync_cache_backend)] - blocking facade over CacheBackend, bridges async calls via anyio.from_thread.run. Only works from sync endpoints running in FastAPI's worker threads.
get_cache_backend() / get_sync_cache_backend()¶
async def get_cache_backend(request: Request) -> CacheBackend
async def get_sync_cache_backend(request: Request) -> SyncCacheBackend
Factory functions underlying the type aliases above.
DI caching factories¶
All three factories return Depends()-compatible dependencies. Requires FastAPIRedis(app).caching() (or add_redis_caching(app)).
cache()¶
from fastapi import Depends
from redis_fastapi import cache
@app.get("/items", dependencies=[Depends(cache(ttl=60, eviction_group="items"))])
async def get_items():
...
On a cache hit the endpoint is skipped (response served from Redis). On a miss the response is captured and stored. Adds X-Redis-Cache (HIT/MISS/BYPASS), Cache-Control and ETag headers, with 304 Not Modified for If-None-Match and If-Modified-Since.
An entry stores the response's own header fields and replays them on a hit, so Link, Content-Disposition, Content-Language and your own headers survive. Only text representations are stored, and Date and Set-Cookie are deliberately withheld — see Cache scope and Headers a hit carries.
| Parameter | Type | Default | Description |
|---|---|---|---|
ttl |
int \| None |
settings.default_ttl |
Cache TTL in seconds (0 = no expiry) |
eviction_group |
str |
"" |
Namespace segment in the cache key |
cache_prefix |
str \| None |
settings.pattern_prefix("cache") |
Key prefix override |
key_builder |
KeyBuilder \| None |
default_key_builder |
Custom key builder |
private |
bool |
False |
Emit Cache-Control: private. Added automatically when valid_session() gated the request |
vary_on_session |
bool \| None |
None |
True: one entry per user, private and Vary: Cookie on the miss and the hit. False: one shared entry, no Vary: Cookie. None: a response that read the session is served but not stored |
no_store |
bool |
False |
Emit Cache-Control: no-store on the miss and every hit; the entry is still kept in Redis. Added automatically under valid_session(issued_within=...) |
cache_evict()¶
from redis_fastapi import cache_evict, default_key_builder
@app.delete("/items/{item_id}", dependencies=[Depends(cache_evict(eviction_group="items", key_builder=default_key_builder))])
async def delete_item(item_id: int):
...
Eviction runs after the endpoint succeeds. With a key_builder, deletes the matching key. Without one, clears the entire eviction group.
| Parameter | Type | Default | Description |
|---|---|---|---|
eviction_group |
str |
"" |
Namespace to evict from |
key_builder |
KeyBuilder \| None |
None |
Specific key; omit to clear eviction group |
prefix |
str \| None |
settings.pattern_prefix("cache") |
Key prefix override |
cache_put()¶
from redis_fastapi import cache_put, default_key_builder
@app.put("/items/{item_id}", dependencies=[Depends(cache_put(eviction_group="items", key_builder=default_key_builder, ttl=300))])
async def update_item(item_id: int, body: Item):
...
Write-through: endpoint always executes, response is stored so the next cache() read is a HIT.
| Parameter | Type | Default | Description |
|---|---|---|---|
ttl |
int \| None |
settings.default_ttl |
Cache TTL in seconds |
eviction_group |
str |
"" |
Namespace to write into |
key_builder |
KeyBuilder \| None |
default_key_builder |
Custom key builder |
prefix |
str \| None |
settings.pattern_prefix("cache") |
Key prefix override |
private |
bool |
False |
Emit Cache-Control: private |
default_key_builder¶
Builds a cache key from the request path (slashes → colons) and sorted query params. Eviction group is wrapped in Redis hash-tag braces ({eviction_group}) for Cluster slot consistency.
Rate limit backend dependencies¶
RateLimitBackendDep¶
from fastapi import HTTPException
from redis_fastapi import RateLimitBackendDep
@app.post("/send-email")
async def send_email(to: str, limiter: RateLimitBackendDep):
result = await limiter.hit(f"email:{to}", limit=3, window=3600)
if not result.allowed:
raise HTTPException(429, f"retry in {result.retry_after}s")
...
Annotated[RateLimitBackend, Depends(get_rate_limit_backend)] - imperative window-counter backend for custom keys, dynamic cost, or non-HTTP flows. Needs only a Redis connection; .rate_limiting() is not required for the backend itself.
| Method | Description |
|---|---|
hit(identifier, *, limit, window, scope=None, cost=1, fail_closed=None) -> RateLimitResult |
Consume cost units against the counter. |
peek(identifier, *, limit, window, scope=None) -> RateLimitResult |
Report current state without consuming. |
reset(identifier, *, scope=None) -> bool |
Clear the counter; True if it existed. |
SyncRateLimitBackendDep¶
from redis_fastapi import SyncRateLimitBackendDep
@app.post("/sync-send")
def sync_send(to: str, limiter: SyncRateLimitBackendDep):
if not limiter.hit(f"email:{to}", limit=3, window=3600).allowed:
raise HTTPException(429, "slow down")
...
Annotated[SyncRateLimitBackend, Depends(get_sync_rate_limit_backend)] - blocking facade over RateLimitBackend, bridges async calls via anyio.from_thread.run. Only works from sync endpoints running in FastAPI's worker threads.
get_rate_limit_backend() / get_sync_rate_limit_backend()¶
async def get_rate_limit_backend(request: Request) -> RateLimitBackend
async def get_sync_rate_limit_backend(request: Request) -> SyncRateLimitBackend
Factory functions underlying the type aliases above. The backend's server-capability cache (INCREX / EVAL support) lives on the pool state, so detection is paid once per process rather than per request.
DI rate limiting¶
rate_limit()¶
from fastapi import Depends
from redis_fastapi import rate_limit
@app.get("/api", dependencies=[Depends(rate_limit("100/minute"))])
async def api():
...
Returns a Depends()-compatible dependency. Requires FastAPIRedis(app).rate_limiting() (or add_redis_rate_limiting(app)) so the 429 handler and header middleware are registered. Allowed responses carry X-RateLimit-* headers; over-limit requests raise a 429 with Retry-After.
| Parameter | Type | Default | Description |
|---|---|---|---|
rate |
str \| Rate \| tuple[int, int] \| None |
None |
Fluent rate ("100/minute"), Rate, or (limit, window). Mutually exclusive with limit/window. |
limit |
int \| None |
None |
Request limit (use with window). |
window |
int \| None |
None |
Window in seconds (use with limit). |
scope |
str |
matched route template | Counter namespace and unit of sharing. |
identifier |
Identifier \| None |
ip_identifier |
Returns the per-client identity segment of the key. |
cost |
int |
1 |
Units this request consumes. |
skip_when |
Callable[[Request], bool] (sync/async) |
None |
Truthy ⇒ request not counted. |
on_limit_exceeded |
Callable[[Request, RateLimitResult], Response] (sync/async) |
None |
Builds the 429 response. |
emit_headers |
bool \| None |
settings.rate_limit_emit_headers |
Emit the X-RateLimit-* trio. |
ietf_headers |
bool \| None |
settings.rate_limit_ietf_headers |
Also emit IETF RateLimit / RateLimit-Policy. |
fail_closed |
bool \| None |
settings.rate_limit_fail_closed |
Reject (vs allow) when Redis is unreachable. |
add_redis_rate_limiting() / FastAPIRedis.rate_limiting()¶
FastAPIRedis(app).lifespan().rate_limiting(global_rate="1000/minute")
# or, without the builder:
add_redis_rate_limiting(app, global_rate="1000/minute")
Registers the RateLimitExceeded handler and the RateLimitMiddleware (header injection + optional global limiter). Idempotent. A global limiter is wired when global_rate is provided, or when REDIS_RATE_LIMIT_DEFAULT_LIMIT > 0.
| Parameter | Type | Default | Description |
|---|---|---|---|
global_rate |
str \| Rate \| tuple[int, int] \| None |
None |
Enables an app-wide limiter at this rate. |
identifier |
Identifier \| None |
ip_identifier |
Identity strategy for the global limiter. |
scope |
str |
"" |
Scope for the global limiter's counters. |
skip_when |
Callable[[Request], bool] (sync/async) |
None |
Skip predicate for the global limiter. |
on_limit_exceeded |
Callable[[Request, RateLimitResult], Response] (sync/async) |
None |
Custom 429 builder for the global limiter. |
ietf_headers |
bool \| None |
settings.rate_limit_ietf_headers |
Emit IETF headers on global responses. |
fail_closed |
bool \| None |
settings.rate_limit_fail_closed |
Reject on Redis errors for the global limiter. |
Identifiers and rate definitions¶
ip_identifier / Identifier¶
def ip_identifier(request: Request) -> str # default identifier (client IP)
Identifier = Callable[[Request], str | Awaitable[str]]
An Identifier returns the client identity segment of the counter key (e.g. an IP or user id) - the backend adds scope and prefix. Pass any callable as identifier= to key by user, API key, or tenant. ip_identifier returns request.client.host and never parses X-Forwarded-For - configure proxy headers at the ASGI server (Uvicorn --proxy-headers), or supply your own identifier. See Behind a proxy.
CannotIdentifyClient¶
Raise from an Identifier that cannot tell callers apart. The limiter then treats the check like a Redis outage: RateLimitResult.degraded is True, the error metric is recorded, and fail_closed decides whether the request passes uncounted or gets a 429 - so the request is never counted against a shared placeholder bucket. ip_identifier raises it when the ASGI scope has no client address. See Callers that cannot be identified.
Rate / parse_rate()¶
from redis_fastapi import Rate, parse_rate
Rate(limit=100, window=60) # limit within a window (seconds)
Rate.per_minute(100) # also per_second / per_hour / per_day
parse_rate("100/minute") # "5/second", "10/15seconds", "100 per 2 minutes"
parse_rate(spec: str | Rate | tuple[int, int]) -> Rate coerces any accepted form into a frozen Rate(limit, window). str(rate) round-trips to the canonical spec.
RateLimitResult¶
@dataclass(frozen=True)
class RateLimitResult:
allowed: bool
limit: int
remaining: int
reset_after: int # seconds until the window resets
reset_at: int # Unix epoch second of reset (X-RateLimit-Reset)
retry_after: int # seconds to wait before retrying (Retry-After)
backend: str = "" # "increx" | "lua"; empty for peek / Redis-unreachable
degraded: bool = False # True when served by the fail-open/closed fallback
Returned by hit() / peek(), and stashed on request.state so the middleware can emit headers.
RateLimitExceeded / RateLimitMiddleware¶
RateLimitExceeded(Exception) - control-flow exception raised by rate_limit() when a request is over the limit; carries the prebuilt 429 Response. It is turned into that response automatically once add_redis_rate_limiting (or .rate_limiting()) has registered its handler.
RateLimitMiddleware - ASGI middleware that injects X-RateLimit-* headers on allowed responses and, when a global limit is configured, enforces it before routing. Registered for you by add_redis_rate_limiting; you rarely construct it directly.
Session dependencies¶
SessionDep¶
from redis_fastapi import SessionDep
@app.post("/login")
async def login(session: SessionDep):
session["user_id"] = 42 # writing the identity rotates the ID
Annotated[Session, Depends(get_session)] - the session payload as a dict
subclass. Loaded before the application runs, so this performs no I/O.
request.session returns the same object.
SessionStateDep¶
from redis_fastapi import SessionStateDep, SessionStoreDep
@app.post("/logout")
async def logout(state: SessionStateDep, store: SessionStoreDep):
await store.revoke(state)
Annotated[SessionState, Depends(get_session_state)] - everything about the
session that is not its data: session_id, subject, created,
absolute_remaining, and the revoked / rotated flags. Operations that act
on the session rather than its contents take this rather than the payload.
SessionStoreDep / SyncSessionStoreDep¶
async def handler(store: SessionStoreDep): ... # async endpoints
def handler(store: SyncSessionStoreDep): ... # def endpoints
Annotated[SessionStore, Depends(get_session_store)]. Typed as the abstract
base, not the Redis implementation, so a substituted backend type-checks.
SyncSessionStore bridges each call via anyio.from_thread.run and is usable
only from FastAPI worker threads.
get_session() / get_session_state() / get_session_store() / get_sync_session_store()¶
get_session_store resolves in three steps: an entry in
app.dependency_overrides, then a store or store_factory given to
add_redis_sessions, then a RedisSessionStore built from the remaining
options. It consults the override map itself, so one override covers both the
middleware and your handlers.
valid_session()¶
from fastapi import Depends
from redis_fastapi import valid_session
@app.post("/checkout", dependencies=[Depends(valid_session())])
async def checkout(): ...
@app.post("/account/email", dependencies=[
Depends(current_user),
Depends(valid_session(issued_within=600, on_reject=my_rejection)),
])
async def change_email(): ...
A dependency that rejects a request without a valid session: the cookie must name a record that was live in Redis when the request arrived, and nothing earlier in the request may have ended it. It does not ask who the session belongs to.
| Parameter | Type | Default | Description |
|---|---|---|---|
issued_within |
int \| timedelta \| None |
None |
Also require the session ID to have been issued at most this long ago. Adds the reason "stale" and makes the response Cache-Control: no-store. Not an authentication check |
on_reject |
OnReject[SessionRejection], or OnReject[RecencyRejection] with issued_within |
None |
Receives the request and the reason, returns the response (sync or async). Default: 401 |
| Reason | Meaning |
|---|---|
"missing" |
No usable session: no cookie, a malformed one, or one an earlier dependency revoked or emptied |
"expired" |
A well-formed cookie naming no live session |
"unavailable" |
The read failed and the request continued without a session |
"stale" |
Only with issued_within: the session ID was issued longer ago than that |
The default rejection is HTTPException(401, "No valid session"), with a
WWW-Authenticate header only when the challenge setting is configured. For
"stale" it is a 401 with the body
{"detail": ..., "error": "stale_session", "issued_within": N}.
The gate marks the session as read. When it passes, a following
cache(vary_on_session=False) adds private. Listed after such a
cache(), it raises SessionConfigurationError on the first request. It also
raises that error on a route excluded by skip, and raises TypeError when
on_reject returns something other than a Response.
| Type | Values |
|---|---|
SessionRejection |
Literal["missing", "expired", "unavailable"] |
RecencyRejection |
SessionRejection plus "stale" |
OnReject[R] |
Callable[[Request, R], Response \| Awaitable[Response]] |
Challenge |
str \| SecurityBase \| Callable[[Request, str], str \| None] |
SessionRejected |
The exception that carries a rejection response out of the dependency. Control flow, not a SessionError |
Session setup¶
add_redis_sessions() / FastAPIRedis.sessions()¶
FastAPIRedis(app).lifespan().sessions(
principal_keys=["user_id", "role"],
subject_of=lambda s: s.get("tenant_id"),
descriptor_of=lambda req, s: {"ip": req.client.host},
cookie_builder=my_builder,
skip=lambda req: req.url.path.startswith("/health"),
challenge=None,
store=None, store_factory=None,
coder=JsonCoder, encryptor=None, id_factory=None, key_prefix=None,
idle_ttl=None, absolute_ttl=None, gc_ttl=None,
)
| Argument | Purpose |
|---|---|
principal_of / principal_keys |
What a change to triggers a rotation. Must be pure and return a verified identity. |
subject_of |
What the reverse index is keyed on. None leaves the session out of it. |
descriptor_of |
What a device listing shows. Stored beside the session, never inside it. |
cookie_builder |
Renders Set-Cookie, for setting and clearing. |
skip |
Requests needing no session, at zero Redis cost. |
challenge |
The WWW-Authenticate header on a valid_session() rejection: a FastAPI security scheme, a string, or a callable of the request and the reason. Omitted by default; Basic is refused. |
store / store_factory |
Supply a whole store. Mutually exclusive. |
coder, encryptor, id_factory, key_prefix, idle_ttl, absolute_ttl, gc_ttl |
Passed to the store constructor. TTLs accept int seconds or timedelta. |
SessionStore¶
Abstract base owning the lifecycle. RedisSessionStore is the shipped
implementation; SessionStoreProtocol is the structural type for substituting
one without inheritance.
| Method | Purpose |
|---|---|
load(session_id, *, refresh=True) |
Read a session and restart its idle clock. None for every way it can be absent. |
load_with_status(session_id, *, refresh=True) |
load(), plus whether the read itself failed. |
session_age(state) |
Seconds since the session ID was issued, from the key's TTL. None without a stored session, or for one created under a longer session_absolute_ttl. |
create(session_id, record) |
Write a session that does not exist yet. The only method that writes the absolute deadline. |
save(session_id, record) |
Update the payload, and only the payload. |
touch(session_id) |
Restart the idle clock alone. |
delete(session_id) |
Remove the session. |
rotate(state, *, subject=None, descriptor=None) |
New identifier, old key deleted first. Returns the new ID. |
reauthenticate(state) |
Ask the middleware to rotate on the way out. |
revoke(state, *, subject=None) |
End this session and drop its index entry. |
revoke_id(session_id, *, subject) |
End one session, refusing an ID not indexed under that subject. |
revoke_all(subject) |
End every session of a subject. Returns keys removed. |
list_for_subject(subject) |
Live sessions, verified before being reported. |
count_for_subject(subject, *, limit=None) |
Upper-bound count; exact only at or above limit. Raises rather than failing open. |
index(subject, session_id, record, *, absolute_remaining, extra=None) |
Record a session under its subject. |
new_id() / is_valid_id(value) |
Generate and validate identifiers. |
new_record(data, *, created=None) |
Build an envelope, carrying created forward. |
session_id(state) |
The current identifier, or None. |
Session data types¶
| Type | Contents |
|---|---|
Session |
dict subclass with accessed / modified flags and raw(). Nothing else. |
SessionState |
data, session_id, subject, created, absolute_remaining, revoked, rotated. |
SessionRecord |
data plus SessionMetadata. |
SessionMetadata |
created, last_access. |
SessionInfo |
One listing row: session_id, created, last_access, descriptor. |
LoadedSession |
record plus absolute_remaining, returned by load(). |
CookieSpec |
Cookie attributes; cleared() returns the deletion form. |
Outcome |
What a response owes the session. Eight members. |
Deadline |
ABSENT / UNBOUNDED — a deadline that is not a number. |
Session errors¶
SessionError
├── SessionConfigurationError a missing or invalid setting
└── SessionStoreError the store failed; wraps the driver error
SessionRejected is not a SessionError: it carries a valid_session()
rejection out of the dependency, and the handler that .sessions() registers
returns its response.
A failed read yields an empty session unless session_fail_closed is set.
A failed write always raises. count_for_subject always raises, because
there the store is the authorization answer.