Spec: F20 — API Rate Limiting
Status: shipped · Audited: 2026-07-14 · Contract audit: partial
1. Problem & CEO Job
Trust the signal — operationally. Without throttling, one bad client or attacker can degrade every org’s morning brief. Rate limiting is a cheap availability and abuse control that must stay invisible to legitimate use: a CEO refreshing their brief should never see a 429.
2. User Stories & Acceptance Criteria
- As an operator, I want per-identity request caps, so that no single client can starve the platform.
- Anonymous: 30 req/min per IP. Authenticated: 120 req/min per user.
- Given the cap is exceeded, then HTTP 429 with
Retry-After; the frontend backs off silently and retries — no raw error shown for transient 429s. - Login endpoint additionally protected by django-axes lockout (already live, F01).
- Limits are settings-driven (env-overridable), not hardcoded.
THROTTLE_ANONandTHROTTLE_USERenv vars override defaults.
3. Scope Boundaries
- In: DRF default throttle classes/rates, 429 handling in the frontend API client, axes coexistence.
- Out: per-org quotas/billing tiers (F24 territory later), WAF/infra-level limits (deploy concern), per-endpoint custom budgets (only if real traffic shows hotspots).
4. Data Model
None. DRF throttling uses the cache backend (Redis — already provisioned).
5. API Contract
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {"anon": "30/min", "user": "120/min"},
}
Test settings keep throttling at 1000/min so the suite stays deterministic.
6. UI / UX
Invisible when healthy. API client (frontend/src/api/client.ts): on 429, exponential backoff retry ×2 (respects Retry-After), then shows rate_limited toast (en/vi).
7. Test Plan
Detailed proof matrix: F20 rate-limiting test plan.
| # | Case | Type | Where |
|---|---|---|---|
| 1 | 31st anon request in a minute → 429 | backend | tests/test_api.py::TestThrottling |
| 2 | Authenticated cap independent of anon cap | backend | TestThrottling |
| 3 | 429 carries Retry-After | backend | tests/api/test_ai_infra.py::TestThrottling::test_throttle_429_includes_retry_after |
8. Current Behavior
Shipped 2026-06-10 and contract-migrated 2026-07-14. AnonRateThrottle (30/min) +
UserRateThrottle (120/min) are configured in REST_FRAMEWORK; env vars override defaults and
test settings use 1000/min. Frontend client.ts retries 429s up to twice with Retry-After-based
backoff, then shows the localized rate_limited toast. Focused backend and frontend proofs pass.
The login path explicitly returns the django-axes lockout explanation before the generic
invalid-credential response, and the brute-force proof covers both the threshold and a subsequent
correct-password attempt. Signup abuse budgeting and deployed Redis-failure behavior remain
explicit gaps; F20 is partial, not green.
9. Error States
| # | Error | Response | Notes |
|---|---|---|---|
| ES1 | Malformed THROTTLE_ANON/THROTTLE_USER env (e.g., xyz) | ImproperlyConfigured at import time → 500 on all requests | No validation or startup test for env format |
| ES2 | Redis cache backend unavailable | ThrottleCache raises connection error → 500 all requests | No graceful bypass; Redis expected always-available |
| ES3 | Malformed Retry-After header | Frontend: Number("foo") → NaN → falls back to 1s retry | No test for malformed Retry-After header |
| ES4 | 429 from WAF (not DRF) | Frontend treats any 429 as throttle and retries; WAF Retry-After 60s + CAPTCHA requirement makes retries futile | No test distinguishes WAF 429 from app 429 |
| ES5 | Test settings drift (new throttle class missing from test.py) | Test suite runs at default rates for uncovered throttle class | No test verifies test.py covers all throttle classes |
| ES6 | Anon rate limit on signup/register endpoints | A shared office IP hitting 31 /register requests in one minute gets 429 during onboarding | No exception for auth endpoints; conversion loss risk |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md request lifecycle (throttle check stage) · docs/AGENTS.md security table.
11. Implementation Audit (2026-06-10)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | AnonRateThrottle 30/min + UserRateThrottle 120/min configured in REST_FRAMEWORK | ✅ match | config/settings/base.py; 2026-06-10 feature commit |
| 2 | THROTTLE_ANON/THROTTLE_USER env-overridable | ✅ match | env vars read at import; test.py overrides to 1000/min |
| 3 | Frontend 429 → exponential backoff retry ×2 → rate_limited toast (en/vi) | ✅ match | frontend/src/api/client.ts response interceptor; TestThrottling |
| 4 | django-axes (login lockout) coexists with DRF throttle | ✅ match | F01 §2; axes fires at 5 failed attempts; throttle fires at 30/min |
| 5 | django-axes lockout response precedence | ✅ match | login_view checks request.axes_locked_out before the generic invalid-credential response; TestBruteForceProtection.test_lockout_after_five_failures verifies the explanatory 429 |
| 6 | Anon rate applies to signup — shared IP could block prospective users (conversion risk) | ⚠️ gap | E2; no exception for /register; 30 registrations/min from same IP (e.g., office bulk-onboarding) triggers 429 |
| 7 | Redis down → 500 all requests; no graceful bypass | ⚠️ gap | ES2; Redis expected available; no fallback to allow-all on cache miss |
Summary: 5/7 match; 2 gaps (anon rate on signup, Redis-down 500).
Edge Cases Catalog (formerly §12, 2026-06-12; E1–E8): Key items integrated above. Remaining: E1 burst traffic (120 requests in 10s → 429 for 50s, by design); E3 authenticated multi-tab polling (tabs share budget, stale data risk); E4 rate window reset (Retry-After alignment); E5 env var malformed (ES1); E7 no ScopedRateThrottle for premium users; E8 multi-tab retry isolation. Full catalog in git history (2026-06-12 changelog entry).
12. Open Questions
- Should
/api/pulses/submit/get a friendlier burst allowance for Telegram webhook traffic? Decide when wiring (webhook authenticates as a service identity).
13. Bug / Change Log
2026-07-14 — Migrate F20 into the Contract Graph
Type: Verification / Contract Graph
Summary:
- Added canonical F20 CUE ownership for DRF caps, 429/Retry-After, independent buckets, and web client backoff.
- Added runner-native proof IDs to the existing backend throttle tests and a focused client retry regression.
- Preserved the three existing policy/operational gaps rather than inferring them green.
Evidence:
TestThrottlingfocused pytest proofs andfrontend/src/test/_shared/apiClient.test.tspass.- Auth precedence, signup budget, and Redis failure remain named gaps in the contract audit.
2026-07-02 — Add test_throttle_429_includes_retry_after to close §7 row 3 gap
Type: Test
Summary:
test_throttle_429_includes_retry_afteradded toTestThrottlingintests/api/test_ai_infra.py.- Verifies that DRF sets
Retry-After(positive integer) on every 429 throttled response, using the same anon-throttle trigger astest_anon_throttle_429(patch.dictto3/min, 4th request hits limit). - DRF sets this automatically via
SimpleRateThrottle.wait()→Throttled(wait)→exception_handleraddsRetry-After: <seconds>header (confirmed inrest_framework/views.pylines 91–92). - §7 row 3 evidence updated to name the specific test method.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8.
- §8 Diagrams → §10; §9 Implementation Audit (narrative) → §11 (7-row table); §10 Open Questions → §12; §11+§14 Bug/Change Log merged → §13; §12 Edge Cases absorbed into §11 note; §13 Error States → §9.
- §11 audit: 7 rows, 4 ✅, 3 gaps (axes masked, signup anon rate, Redis-down 500).
2026-06-12 — Add Edge Cases (E1–E8) and Error States (ES1–ES5) to F20 spec
Type: Documentation Change
2026-06-10 — Implement DRF throttling + 429 frontend backoff
Type: Feature
Summary:
DEFAULT_THROTTLE_CLASSES+DEFAULT_THROTTLE_RATESconfigured; env-overridable.test.pysets 1000/min for deterministic CI.frontend/src/api/client.tsretries 429s up to twice with backoff; showsrate_limitedtoast.TestThrottlingbackend test class.- Closes §9 audit: “registry previously claimed this was shipped when no throttle config existed.”