| API daily quota (all callers, by IP) | `quota:{ns}:daily:{date}:ip:{ip}` | 24 h | 1 | negligible |
| API daily quota (all callers, by IP) | `quota:{ns}:daily:{date}:ip:{ip}` | 24 h | 1 | negligible |
| API weekly quota (all callers, by IP) | `quota:{ns}:weekly:{week}:ip:{ip}` | 7 d | 1 | negligible |
| API weekly quota (all callers, by IP) | `quota:{ns}:weekly:{week}:ip:{ip}` | 7 d | 1 | negligible |
| API rate counter (ns-scoped) | `rl:api:ns:{ns}:ip:{ip}:reqs` | 60 s | 1 | negligible |
| API block marker (ns-scoped) | `rl:api:ns:{ns}:ip:{ip}:blocked` | 60 s | 1 | negligible |
| Redis overhead (× 1.5) | | | | ~1.6 GB |
| Redis overhead (× 1.5) | | | | ~1.6 GB |
| **Total ceiling** | | | | **~5 GB** |
| **Total ceiling** | | | | **~5 GB** |
@ -138,11 +140,12 @@ graph TD
| Auth rate breach: no persistent block (2026-05-07) | **429 per-request only**, window resets after 60 s | A 300 s lockout is the wrong penalty for a logged-in user who clicked too fast; persistent block is appropriate for anonymous/bot traffic only |
| Auth rate breach: no persistent block (2026-05-07) | **429 per-request only**, window resets after 60 s | A 300 s lockout is the wrong penalty for a logged-in user who clicked too fast; persistent block is appropriate for anonymous/bot traffic only |
| Raise rate thresholds (2026-05-07) | anon 35→120/m · auth 120→240/m · 404 threshold 10→20 | SAPL pages fire 12–45 parallel requests; old thresholds blocked normal navigation for users in offices with multiple open tabs |
| Raise rate thresholds (2026-05-07) | anon 35→120/m · auth 120→240/m · 404 threshold 10→20 | SAPL pages fire 12–45 parallel requests; old thresholds blocked normal navigation for users in offices with multiple open tabs |
| API quota increase (2026-05-07) | anon 50→500/day · auth 1 000→5 000/day | Previous anon quota of 50/day was exhausted by a developer testing the API before lunch |
| API quota increase (2026-05-07) | anon 50→500/day · auth 1 000→5 000/day | Previous anon quota of 50/day was exhausted by a developer testing the API before lunch |
| Auth not exempt from `/api/` rate limit (2026-05-11) | **All callers keyed by IP** — auth status not checked | Authenticating must not bypass the per-minute cap; `_evaluate` (240/min per-user) still governs non-`/api/` paths |
| Auth parity on /api/ (2026-05-11) | Auth users subject to same 35/min cap and 1 000/day quota as anon | Authenticating must not bypass /api/ rate controls; both caller types keyed by IP |
| Auth-specific API quotas removed (2026-05-11) | **Single `API_QUOTA_DAILY/WEEKLY`** for all callers by IP | Per-user quota added false precision; IP-based cap is sufficient alongside the per-minute block |
| API threshold (2026-05-11) | 60 → 35 req/min | Forces sane polling intervals; 35/min is still well above any legitimate use case |
| API rate limit keys namespaced (2026-05-11) | `rl:api:ns:{ns}:ip:{ip}:reqs/blocked` | Without `{ns}` a block in one k8s pod namespace leaked into all tenants sharing the same Redis instance |
| API quota recalibrated (2026-05-11) | 500/day · 3 500/week → 1 000/day · 7 000/week | Old 500/day was exhausted in ~14 min at 35/min; new cap targets slow-drip scrapers (10–20 req/min all day) |
| API threshold raised (2026-05-11) | 60→120 req/min | Aligns with legitimate integration patterns; slow-drip abuse is caught by the daily quota |
| API key namespace (2026-05-11) | `rl:api:ip:{ip}:*` → `rl:api:ns:{ns}:ip:{ip}:*` | Block in one k8s tenant must not leak to other tenants sharing the same Redis |
| API block TTL reduced (2026-05-11) | 300→60 s | Shorter cooldown reduces false-positive lockout duration for shared IPs |
| API quota raised (2026-05-11) | 1 000→100 000/day · 7 000→700 000/week | Quota serves as outer envelope for all-day slow scrapers; 1 000/day was exhausted too quickly for legitimate integrations |
Auth status is **not checked**. Authenticated and anonymous callers are treated identically — both keyed by IP, both subject to the same threshold and quota. `_evaluate` (240/min per-user) still governs all non-`/api/` paths.
- `rl:ip:<ip>:blocked` is **never written** because of `/api/` abuse — page requests from the same NAT are unaffected.
- Auth status is not checked at any step — authenticating cannot bypass the 35/min cap or the daily quota.
**Key invariant**: `rl:ip:<ip>:blocked` is **never written** because of `/api/` abuse.
- Block keys include `{ns}` — a block in one k8s tenant does not affect other tenants.
`rl:api:ns:<ns>:ip:<ip>:blocked` is tenant-scoped and blocks only `/api/` — page requests from the same NAT continue, and a block in one k8s namespace does not affect other tenants.
### Same-origin detection — `_is_same_origin`
### Same-origin detection — `_is_same_origin`
@ -1385,20 +1388,20 @@ header means the browser knows this is cross-origin, regardless of what `Referer
| Setting | Env var | Default | Purpose |
| Setting | Env var | Default | Purpose |
|---------|---------|---------|---------|
|---------|---------|---------|---------|
| `API_RATE_LIMIT_ENABLED` | same | `True` | Master switch; set False to revert to quota-only |
| `API_RATE_LIMIT_ENABLED` | same | `True` | Master switch; set False to revert to quota-only |
| `API_RATE_LIMIT_THRESHOLD` | same | `35` | Requests per window before API block (all callers) |
| `API_RATE_LIMIT_THRESHOLD` | same | `120` | Requests per window before API block |