Redis backend & failure modes

What fastapi-guard stores in Redis, how it survives transient failures, and what falls apart when Redis goes away.

6 min read · verified against [email protected]

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

CategoryPatternExample
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 listguard_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)
ZCARD

Bans & 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.

  1. Request arrives - middleware extracts the client IP.
  2. In-memory cache check - bounded size, TTL per entry. If the IP is here, decision returns immediately.
  3. Redis check on miss - source of truth across instances.
  4. 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

CheckOn Redis errorSeverity
Rate limitingFalls 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 checkIn-memory cache enforced; new bans from other instances won't be seen, and new bans this instance writes won't reach others.Partial
Overall decisionSurfaces 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_openbool

On 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.

default: False

Checked 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_retriesint

Number of retries (with exponential backoff) on transient Redis connection/timeout errors before surfacing it. 0 disables retries.

default: 1
redis_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.

default: 2.0
redis_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.

default: 2.0
redis_health_check_intervalint

Seconds between health checks on pooled connections; recycles stale sockets so the first request after an idle period doesn't fail. 0 disables health checks.

default: 30
redis_max_connectionsUnion[int, NoneType]

Cap on the Redis connection pool size. None uses redis-py's default.

default: None

Retries 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

SymptomLikely causeFix
Bans not respected after a Redis restartBans are TTL'd keys; restart without persistence loses themEnable AOF (appendonly yes) or AOF+RDB. Confirm with redis-cli KEYS guard_core:banned_ips:*.
Rate limits work, but bans don't fireIn-memory cache empty (fresh process); Redis write that should have created the ban key didn't succeedCheck 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 IPsReplicas not sharing Redis, or same Redis with different redis_prefixConfirm DSN and redis_prefix from a single config source (env var or secrets manager).
Requests hang when Redis is partitionedA socket timeout set to None removes the bound - blocked Redis calls block requestsKeep redis_socket_connect_timeout and redis_socket_timeout at finite values (default 2.0s).