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.

4 min read · verified against [email protected]

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:

  1. ZADD - record this request. Score = now (Unix ms). Member = the same timestamp.
  2. ZREMRANGEBYSCORE - drop every entry older than (now − window). The set now holds only timestamps still relevant to this decision.
  3. 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 hot

Use SCAN, not KEYS, in production.

Tuning for the traffic you have

Traffic shaperate_limitauto_ban_thresholdauto_ban_duration
High-traffic APIraise - real traffic doesn't fit in 50/minkeep moderate; rely on per-route limits-
B2B / known partnersmatch agreed contract rateslower - you know who should be hitting youlonger - bad actors aren't accidents
Public sitegenerous; rely on detection rules for malicekeep high - false-positive cost is real usersshort - 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

SymptomLikely causeFix
A user keeps getting banned despite normal usageIP is shared (corporate NAT, mobile CGNAT) - all users behind it contribute to the same counterRaise auto_ban_threshold for their case, or whitelist the IP range. Lowering the threshold makes it worse.
Bans don't expireRedis key was set without TTL (different code path, or Redis restart without persistence). TTL on the key returns -1Delete the bad keys with DEL. New bans created by fastapi-guard will get the correct TTL.
Per-route decorator limit isn't applyingRoutes registered after the middleware aren't seen at decoration timeConfirm app.add_middleware(SecurityMiddleware) is called BEFORE registering routes, or that all routes are defined before app start.