Redis backend & failure modes
What fastapi-guard stores in Redis, how it survives transient failures, and what falls apart when Redis goes away.
Redis is the shared state layer for things that need to be globally consistent across every instance of your app. Configuration stays in process memory. Behavioral knowledge stays in process memory. Only the cross-instance state goes to Redis.
What lives in Redis - and what doesn't
| Goes to Redis (cross-instance shared state) | Stays in-process (local config & caches) |
|---|---|
| Rate-limit counters (per IP, per endpoint) | SecurityConfig values from app.py |
| Ban list (IPs serving timeouts) | Dynamic rules cache (pulled from core API) |
| Agent event buffer (optional, off by default) | Detection patterns and compiled regexes |
| Route configuration from decorators | |
| Hot-path ban cache (in-memory layer) |
Rule of thumb: if two replicas need the same answer, it's in Redis. Same on every instance because they all booted from the same code.
Three key categories under redis_prefix
Every key fastapi-guard writes lives under the configured prefix (default "guard_core:").
Rate limits - ZSET. One sorted set per IP (and per IP+endpoint when route-scoped). Members are request IDs, scores are Unix timestamps. Window enforced via score-range removal on each check.
Ban list - STRING. One key per banned IP. Value is metadata; presence of the key + its TTL is what matters. TTL is set to auto_ban_duration on insert.
Agent buffer - JSON (optional). Buffer for events the agent couldn't deliver to the core API. Serialized blobs under agent_events:* and agent_metrics:*. TTL 3600s. Off by default; opt-in for delivery durability.
Naming - what you'll see in redis-cli
| Category | Pattern | Example |
|---|---|---|
| Rate limit (global) | guard_core:rate_limit:rate:{ip} | guard_core:rate_limit:rate:192.168.1.1 |
| Rate limit (per-endpoint) | guard_core:rate_limit:rate:{ip}:{endpoint} | guard_core:rate_limit:rate:192.168.1.1:/api/login |
| Ban list | guard_core:banned_ips:{ip} | guard_core:banned_ips:203.0.113.42 |
| Event buffer (optional) | agent_events:{key} | agent_events:evt_8f3a2b1c |
| Metric buffer (optional) | agent_metrics:{key} | agent_metrics:metric_7d29f1ab |
Set redis_prefix in SecurityConfig to namespace keys (e.g., when sharing a Redis with another app).
TTL strategy - sliding window vs fixed expiry
Rate limits - sliding window via ZSET scores. On each check: drop members with scores older than (now − window), then count the remaining members. The window IS the score range. No key-level TTL needed for correctness.
ZADD now {req_id}
ZREMRANGEBYSCORE 0 (now-window)
ZCARDBans & buffers - fixed expiry on the key. Set the key, set the TTL, walk away. Redis evicts it when it expires. Bans use auto_ban_duration. Agent buffer entries use 3600s.
SETEX banned_ips:{ip} auto_ban_duration ...Consequence: rate-limit ZSETs self-prune on read; ban keys self-expire on time. Neither needs a sweeper process.
Ban check - two-tier, hot cache + Redis as truth
Every request checks the ban list. A round-trip to Redis per request is too expensive, so an in-memory cache sits in front.
- Request arrives - middleware extracts the client IP.
- In-memory cache check - bounded size, TTL per entry. If the IP is here, decision returns immediately.
- Redis check on miss - source of truth across instances.
- Cache populate - if Redis returns a ban, populate the in-memory cache so the next request is hot.
Failure modes - what each check does on Redis error
| Check | On Redis error | Severity |
|---|---|---|
| Rate limiting | Falls back to in-memory defaultdict(deque). Per-process map keyed by IP. Instance keeps enforcing rate limits but loses cross-instance visibility. IP X hitting two workers can do 2× the limit. | Graceful |
| Ban check | In-memory cache enforced; new bans from other instances won't be seen, and new bans this instance writes won't reach others. | Partial |
| Overall decision | Surfaces a GuardRedisError to the check pipeline. If redis_fail_open=True, the failing check is skipped and the request proceeds. Otherwise fail_secure decides: True → 500, False → skip. | See below + Passive mode vs fail-secure |
redis_fail_open - Redis outages as an availability decision
redis_fail_openboolOn GuardRedisError (Redis unreachable), skip the failing check and let the request through instead of honoring fail_secure. Defaults to False so fail_secure is the single source of truth for every check failure, including Redis outages. Set True to opt into treating Redis outages as an availability concern distinct from other check failures.
FalseChecked before fail_secure, and only for Redis errors: when a check raises a GuardRedisError and redis_fail_open=True, that check is skipped and the request proceeds, while every non-Redis check failure still honors fail_secure. The default is False, so out of the box fail_secure remains the single decision point for all failures, Redis outages included. Flip it only if you've decided a Redis outage is an availability problem rather than a security one.
Rate limiting is the exception on both flags: it catches Redis errors itself and falls back to the in-memory counter, so neither redis_fail_open nor fail_secure ever sees them.
Connection resilience knobs
There is still no application-level reconnect loop - retries and timeouts are delegated to the Redis client - but since guard-core 3.5.0 they are explicitly configurable:
redis_retriesintNumber of retries (with exponential backoff) on transient Redis connection/timeout errors before surfacing it. 0 disables retries.
1redis_socket_connect_timeoutUnion[float, NoneType]Seconds to wait establishing a Redis TCP connection before giving up. Must be positive: 0 would put the socket in non-blocking mode, not disable the timeout. None disables the timeout (a partitioned/black-holed Redis then blocks the request indefinitely), so a bounded default is strongly recommended.
2.0redis_socket_timeoutUnion[float, NoneType]Seconds to wait on a Redis read/write before raising. Must be positive: 0 would put the socket in non-blocking mode, not disable the timeout. None means no timeout. Keep this low: every blocked Redis call blocks a request.
2.0redis_health_check_intervalintSeconds between health checks on pooled connections; recycles stale sockets so the first request after an idle period doesn't fail. 0 disables health checks.
30redis_max_connectionsUnion[int, NoneType]Cap on the Redis connection pool size. None uses redis-py's default.
NoneRetries use exponential backoff and fire only on transient connection/timeout errors. Keep both socket timeouts bounded - setting either to None means a partitioned, black-holed Redis blocks every request that needs it, indefinitely.
Operational - two things to get right in production
Shared Redis - all instances, one Redis, one prefix. Rate limits and bans are global state. If two replicas talk to different Redis instances, or the same one with different redis_prefix values, they keep separate ban lists. Symptom: "different replicas ban different IPs." Fix: confirm DSN and prefix match across all instances.
Persistence - AOF or RDB, not in-memory only. Bans are TTL'd Redis keys. If Redis restarts without persistence, every active ban is gone, and dashboards that look healthy will silently un-ban everyone. Run Redis with AOF (appendonly yes) for production.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Bans not respected after a Redis restart | Bans are TTL'd keys; restart without persistence loses them | Enable AOF (appendonly yes) or AOF+RDB. Confirm with redis-cli KEYS guard_core:banned_ips:*. |
| Rate limits work, but bans don't fire | In-memory cache empty (fresh process); Redis write that should have created the ban key didn't succeed | Check logs for Redis-write error around the ban event. Verify DSN and prefix match for read and write paths. Inspect with redis-cli GET guard_core:banned_ips:{ip}. |
| Different instances banning different IPs | Replicas not sharing Redis, or same Redis with different redis_prefix | Confirm DSN and redis_prefix from a single config source (env var or secrets manager). |
| Requests hang when Redis is partitioned | A socket timeout set to None removes the bound - blocked Redis calls block requests | Keep redis_socket_connect_timeout and redis_socket_timeout at finite values (default 2.0s). |