Rate limiting & auto-ban
How the rate limiter and the auto-ban escalator interact, and how to tune them for the traffic you actually have.
Rate limit catches everyday noise. Auto-ban catches IPs that keep tripping it. They're separate mechanisms with separate storage, but they share a counter.
Two stages: throttle, then escalate
Rate limit - per IP, per route (or global), sliding window backed by Redis sorted sets. Reject = HTTP 429. The IP can come back next request.
Auto-ban - per-IP counter increments on rate-limit AND threat detections. When the counter exceeds auto_ban_threshold, the IP is banned for auto_ban_duration seconds. Reject = HTTP 403, no retries. Ban expires from Redis on TTL.
Global default, per-route override
SecurityConfig sets the default for every route. A decorator on a handler overrides that default for that single route.
SecurityConfig(
rate_limit=50,
rate_limit_window=60,
)
@app.get("/public")
@guard.rate_limit(requests=5, window=60)
async def public_endpoint(): ...Routes that don't carry a decorator inherit the global. The decorator only attaches metadata at definition time; enforcement happens inside the middleware. The decorator alone does nothing.
True sliding window, not fixed buckets
Backed by a Redis sorted set per IP-route key, scored by request timestamp. Three operations per request:
ZADD- record this request. Score = now (Unix ms). Member = the same timestamp.ZREMRANGEBYSCORE- drop every entry older than(now − window). The set now holds only timestamps still relevant to this decision.ZCARD- read the cardinality. If >rate_limit, reject with 429 and increment the strike counter. Otherwise let the request through.
Fixed-bucket limiters let an attacker burst 2× the limit by straddling a bucket boundary. Sliding windows evaluate against the actual last N seconds - no boundary games.
What's in your Redis
Two key patterns, depending on whether a route has its own override.
Per-route - when a decorator is on the handler:
{redis_prefix}rate_limit:rate:{client_ip}:{endpoint_path}Global - for routes without a decorator override:
{redis_prefix}rate_limit:rate:{client_ip}Per-route keys are independent. Hitting /public 5 times in 60s and /search 5 times in 60s are separate counters. A single IP could trip both without tripping the global limit.
Two signals feed one counter
auto_ban_threshold isn't a rate-limit-only counter. Detection events count too, and they share the same per-IP tally.
Rate-limit violation ─┐
├─→ strike counter (per IP) ─→ if > threshold → BAN for auto_ban_duration
Threat detection ─────┘The counter resets on a clean request or on ban expiry. Effectively a streak counter, not a long-term tally. An IP that occasionally trips and then behaves doesn't accumulate strikes forever.
The ban - fixed duration, two-tier storage
Every ban lasts exactly auto_ban_duration seconds. No exponential backoff. A tenth ban is the same length as the first.
Hot cache (in-memory) - checked first on every request. Holds up to ~10,000 recently-seen banned IPs with a ~1-hour entry TTL. No network round-trip when the IP is hot.
Source of truth (Redis) - {redis_prefix}banned_ips:{ip} with TTL = auto_ban_duration. Used on cache miss; the result is then cached.
Inspecting and clearing bans
# Is this IP banned?
EXISTS {prefix}banned_ips:203.0.113.42 # 1 = banned
# How much time is left?
TTL {prefix}banned_ips:203.0.113.42 # seconds remaining
# Lift the ban now
DEL {prefix}banned_ips:203.0.113.42 # in-memory cache still hotUse SCAN, not KEYS, in production.
Tuning for the traffic you have
| Traffic shape | rate_limit | auto_ban_threshold | auto_ban_duration |
|---|---|---|---|
| High-traffic API | raise - real traffic doesn't fit in 50/min | keep moderate; rely on per-route limits | - |
| B2B / known partners | match agreed contract rates | lower - you know who should be hitting you | longer - bad actors aren't accidents |
| Public site | generous; rely on detection rules for malice | keep high - false-positive cost is real users | short - collateral damage must be temporary |
Wiring it together
SecurityConfig(
rate_limit=50,
rate_limit_window=60,
auto_ban_threshold=5,
auto_ban_duration=300,
enable_rate_limiting=True,
enable_ip_banning=True,
)
@app.get("/public")
@guard.rate_limit(requests=5, window=60)
async def public_endpoint(): ...This gives you: 50/min default per-IP rate limit; 5/min stricter on /public; 5 strikes (rate-limit + detections combined) before ban; 300-second ban duration.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| A user keeps getting banned despite normal usage | IP is shared (corporate NAT, mobile CGNAT) - all users behind it contribute to the same counter | Raise auto_ban_threshold for their case, or whitelist the IP range. Lowering the threshold makes it worse. |
| Bans don't expire | Redis key was set without TTL (different code path, or Redis restart without persistence). TTL on the key returns -1 | Delete the bad keys with DEL. New bans created by fastapi-guard will get the correct TTL. |
| Per-route decorator limit isn't applying | Routes registered after the middleware aren't seen at decoration time | Confirm app.add_middleware(SecurityMiddleware) is called BEFORE registering routes, or that all routes are defined before app start. |