shipped

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_ANON and THROTTLE_USER env 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.

#CaseTypeWhere
131st anon request in a minute → 429backendtests/test_api.py::TestThrottling
2Authenticated cap independent of anon capbackendTestThrottling
3429 carries Retry-Afterbackendtests/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

#ErrorResponseNotes
ES1Malformed THROTTLE_ANON/THROTTLE_USER env (e.g., xyz)ImproperlyConfigured at import time → 500 on all requestsNo validation or startup test for env format
ES2Redis cache backend unavailableThrottleCache raises connection error → 500 all requestsNo graceful bypass; Redis expected always-available
ES3Malformed Retry-After headerFrontend: Number("foo") → NaN → falls back to 1s retryNo test for malformed Retry-After header
ES4429 from WAF (not DRF)Frontend treats any 429 as throttle and retries; WAF Retry-After 60s + CAPTCHA requirement makes retries futileNo test distinguishes WAF 429 from app 429
ES5Test settings drift (new throttle class missing from test.py)Test suite runs at default rates for uncovered throttle classNo test verifies test.py covers all throttle classes
ES6Anon rate limit on signup/register endpointsA shared office IP hitting 31 /register requests in one minute gets 429 during onboardingNo 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)

#ClaimStatusEvidence
1AnonRateThrottle 30/min + UserRateThrottle 120/min configured in REST_FRAMEWORK✅ matchconfig/settings/base.py; 2026-06-10 feature commit
2THROTTLE_ANON/THROTTLE_USER env-overridable✅ matchenv vars read at import; test.py overrides to 1000/min
3Frontend 429 → exponential backoff retry ×2 → rate_limited toast (en/vi)✅ matchfrontend/src/api/client.ts response interceptor; TestThrottling
4django-axes (login lockout) coexists with DRF throttle✅ matchF01 §2; axes fires at 5 failed attempts; throttle fires at 30/min
5django-axes lockout response precedence✅ matchlogin_view checks request.axes_locked_out before the generic invalid-credential response; TestBruteForceProtection.test_lockout_after_five_failures verifies the explanatory 429
6Anon rate applies to signup — shared IP could block prospective users (conversion risk)⚠️ gapE2; no exception for /register; 30 registrations/min from same IP (e.g., office bulk-onboarding) triggers 429
7Redis down → 500 all requests; no graceful bypass⚠️ gapES2; 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:

  • TestThrottling focused pytest proofs and frontend/src/test/_shared/apiClient.test.ts pass.
  • 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_after added to TestThrottling in tests/api/test_ai_infra.py.
  • Verifies that DRF sets Retry-After (positive integer) on every 429 throttled response, using the same anon-throttle trigger as test_anon_throttle_429 (patch.dict to 3/min, 4th request hits limit).
  • DRF sets this automatically via SimpleRateThrottle.wait() → Throttled(wait) → exception_handler adds Retry-After: <seconds> header (confirmed in rest_framework/views.py lines 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_RATES configured; env-overridable.
  • test.py sets 1000/min for deterministic CI.
  • frontend/src/api/client.ts retries 429s up to twice with backoff; shows rate_limited toast.
  • TestThrottling backend test class.
  • Closes §9 audit: “registry previously claimed this was shipped when no throttle config existed.”