ADR-018: API Rate Limiting and Per-IP Auth Throttle¶
Status: Accepted
Date: 2026-05-14
Implements: Sprint 1, item S1-2
Related: agentic_v2/server/auth.py, agentic_v2/server/app.py
Context¶
The runtime server originally had no application-layer rate limiting, and the API-contracts doc deferred this to the reverse-proxy tier. In reverse-proxy-free or constrained production deployments, the server may process a large number of automated workflow-run requests without an upstream rate-limiting layer. Two risks emerged:
-
Resource exhaustion under bursty traffic. A misconfigured client loop or a runaway automation job can saturate the FastAPI event loop. Without a rate gate, the server degrades for all callers while a single bad actor consumes capacity.
-
Authentication enumeration. The existing API-key middleware using
secrets.compare_digest()prevents timing attacks, but an unconstrained attacker can fire unlimited401-producing probes without penalty. The window for offline dictionary attacks or credential stuffing against the single shared key is unbounded.
Both risks can be mitigated at the ingress / API-gateway layer, but the platform needs to be safe in its undecorated single-process form. A lightweight in-process solution covers the common case without adding infrastructure dependencies.
Decision¶
Implement two complementary in-process controls:
1. Global per-IP rate limit via slowapi¶
- Library:
slowapi(FastAPI-compatiblelimits-backed rate-limiter). - Default limit:
60/minuteper source IP, expressed as the string"60/minute". - Configuration:
AGENTIC_RATE_LIMIT_DEFAULTenvironment variable overrides the default at startup. - Response on breach:
429 Too Many Requestswith aRetry-Afterheader set to the next window boundary. - Granularity: Applied at the outermost middleware layer, before auth and sanitization. The public paths (
/api/health,/docs,/openapi.json,/redoc) are exempt. - Storage: In-process memory. No Redis or external store.
2. Per-IP auth throttle via AuthThrottle¶
- Class:
AuthThrottleinagentic_v2/server/auth.py. - Mechanism: Sliding-window counter per source IP. On every
401 Unauthorizedresponse, the IP's failure count is incremented. When the count reaches the threshold within the window, subsequent requests from that IP receive429 Too Many Requestswith aRetry-Afterheader for the lockout duration. - Default thresholds:
- Window: 60 seconds (
AGENTIC_AUTH_LOCKOUT_WINDOW_SECONDS) - Failure threshold: 5 (AGENTIC_AUTH_LOCKOUT_THRESHOLD)
- Lockout duration: 300 seconds (
AGENTIC_AUTH_LOCKOUT_DURATION_SECONDS) - Reset: A successful
200from a protected endpoint resets the failure counter for that IP. - Storage: In-process dictionary. No external store.
Both controls are in-process only. In a multi-replica deployment, each replica tracks its own counters independently — there is no shared state across instances. Cluster-wide rate limiting is explicitly deferred to Sprint 2.
Consequences¶
Positive¶
- Single-process and small-cluster deployments are protected against both resource exhaustion and auth-enumeration without any infrastructure dependency.
- The
slowapiintegration is a thin wrapper over thelimitslibrary; adding a Redis backend in Sprint 2 is a one-line config change. AuthThrottleis a pure-Python class; its counter store can be swapped for a Redis client in Sprint 2 with no interface changes.- All thresholds are configurable via environment variables — operators can tune for their environment without code changes.
Negative¶
- Multi-replica limitation. In an N-replica deployment, the effective per-IP limits are N times the configured limit. A caller can bypass throttling by distributing requests across replicas. See
KNOWN_LIMITATIONS.md§4.3 and §4.4. - Memory growth. The
AuthThrottlecounter map grows with the number of unique IPs seen. In practice, expired windows should be evicted; confirm eviction logic is active before deploying at internet scale. - No persistent state across restarts. A server restart resets all counters; an attacker who triggers a restart (or times one) resets their throttle window.
Alternatives Considered¶
| Alternative | Disposition |
|---|---|
| Redis-backed shared counters (Sprint 2 plan) | Correct solution for multi-replica; deferred because it introduces an infrastructure dependency. Sprint 2 will wire slowapi's Redis limiter and a Redis-backed AuthThrottle store. |
| WAF / ingress-layer rate limiting | Correct for internet-exposed production; out of scope for the application layer. Operators should layer this on top; the in-process controls are a defense-in-depth backstop. |
nginx limit_req / limit_conn |
Viable but not portable — requires nginx in the deployment stack. The platform cannot assume it. |
| No rate limiting; rely on reverse proxy only | The prior approach. Rejected because the platform is often deployed without a full reverse-proxy stack in early environments. |
Implementation References¶
- Rate-limit middleware setup:
agentic_v2/server/app.py - Auth throttle class:
agentic_v2/server/auth.py(AuthThrottle) - Environment variables: AGENTIC_RATE_LIMIT_DEFAULT, AGENTIC_AUTH_LOCKOUT_WINDOW_SECONDS, AGENTIC_AUTH_LOCKOUT_THRESHOLD, AGENTIC_AUTH_LOCKOUT_DURATION_SECONDS
- Known limitations:
KNOWN_LIMITATIONS.md§4.3, §4.4
Review Cadence¶
Re-evaluate when:
- Sprint 2 Redis migration lands — update this ADR to Superseded or amend the Consequences section.
- The platform is deployed to a target with more than one replica in steady state.
- An auth-enumeration incident in the wild demonstrates that the in-process threshold is inadequate.