Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
159 lines
5.4 KiB
Markdown
159 lines
5.4 KiB
Markdown
# Edge rate limits (multi-replica)
|
|
|
|
App HTTP limiters in `apps/api/internal/httpapi/ratelimit.go` are **per process**. Put **one** nginx or Caddy in front of all API replicas so RPM is cluster-wide.
|
|
|
|
## Env contract
|
|
|
|
```text
|
|
RATE_LIMIT_REPLICAS=<N> # N = API OS processes behind this edge (default 1)
|
|
RATE_LIMIT_MULTI_REPLICA=true # acknowledge multi-replica without a shared store (boot warns)
|
|
TRUSTED_PROXIES=<edge CIDRs/IPs> # hop-1 peers only so RemoteAddr is client IP
|
|
# RATE_LIMIT_BACKEND=memory # redis|postgres not implemented — forced to memory
|
|
```
|
|
|
|
- `RATE_LIMIT_REPLICAS` divides **HTTP middleware only** via `rateLimitEffectiveCap` (ceil) so even load ≈ documented RPM.
|
|
- **Not** a shared counter. Does **not** cover login email lockout, `StartLimiter`, `AIRateLimiter`, or email send limiters.
|
|
- **Not** a substitute for the edge zones below. Edge = hard global RPM; `RATE_LIMIT_REPLICAS` = optional soft split of in-app HTTP caps.
|
|
|
|
Company-keyed app budgets (heavy sync/process/export **30**/min, marketing, `/api/v1` company **120**/min) stay in-app — edge below is **per client IP**.
|
|
|
|
Documented IP-ish targets (align zones to these):
|
|
|
|
| Surface | RPM | Key |
|
|
|---------|-----|-----|
|
|
| Auth login / invite / set-password / sales-contact | 10 | IP |
|
|
| Auth register | 5 | IP |
|
|
| `/api/public/*` | 30 | IP |
|
|
| Public export probe (invalid token shape) | 15 | IP |
|
|
| `/api/v1` with API key present | 60 | IP |
|
|
|
|
Checklist / cutover: [docs/production-checklist.md](../../docs/production-checklist.md) §1f · [docs/cutover.md](../../docs/cutover.md#8-multi-replica-edge-rate-limits).
|
|
|
|
---
|
|
|
|
## nginx (shared `limit_req_zone` across upstreams)
|
|
|
|
```nginx
|
|
# Shared memory → one budget for all replicas behind this proxy.
|
|
limit_req_zone $binary_remote_addr zone=auth_login:10m rate=10r/m;
|
|
limit_req_zone $binary_remote_addr zone=auth_register:10m rate=5r/m;
|
|
limit_req_zone $binary_remote_addr zone=public_api:10m rate=30r/m;
|
|
limit_req_zone $binary_remote_addr zone=public_export:10m rate=30r/m;
|
|
limit_req_zone $binary_remote_addr zone=apikey_ip:10m rate=60r/m;
|
|
|
|
upstream descrybe_api {
|
|
least_conn;
|
|
server 10.0.1.10:28471;
|
|
server 10.0.1.11:28471;
|
|
}
|
|
|
|
server {
|
|
listen 443 ssl http2;
|
|
server_name api.example.com;
|
|
|
|
location = /api/auth/register {
|
|
limit_req zone=auth_register burst=2 nodelay;
|
|
proxy_pass http://descrybe_api;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
|
|
location ~ ^/api/auth/(login|forgot-password|reset-password|invite-preview|accept-invite|complete-set-password)$ {
|
|
limit_req zone=auth_login burst=3 nodelay;
|
|
proxy_pass http://descrybe_api;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
|
|
location = /api/sales/contact {
|
|
limit_req zone=auth_login burst=3 nodelay;
|
|
proxy_pass http://descrybe_api;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
|
|
location ~ ^/api/public/export-feeds/ {
|
|
limit_req zone=public_export burst=5 nodelay;
|
|
proxy_pass http://descrybe_api;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
|
|
location /api/public/ {
|
|
limit_req zone=public_api burst=5 nodelay;
|
|
proxy_pass http://descrybe_api;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
|
|
location /api/v1/ {
|
|
limit_req zone=apikey_ip burst=10 nodelay;
|
|
proxy_pass http://descrybe_api;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
|
|
location / {
|
|
proxy_pass http://descrybe_api;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
}
|
|
}
|
|
```
|
|
|
|
`429` from nginx is expected under burst; app still returns its own `429` + `Retry-After` / `RateLimit*` when in-process caps trip.
|
|
|
|
---
|
|
|
|
## Caddy (needs `rate_limit` handler — xcaddy / module)
|
|
|
|
```caddy
|
|
# xcaddy build --with github.com/mholt/caddy-ratelimit
|
|
api.example.com {
|
|
rate_limit {
|
|
zone auth_login {
|
|
key {remote_host}
|
|
events 10
|
|
window 1m
|
|
}
|
|
zone auth_register {
|
|
key {remote_host}
|
|
events 5
|
|
window 1m
|
|
}
|
|
zone public_api {
|
|
key {remote_host}
|
|
events 30
|
|
window 1m
|
|
}
|
|
zone apikey_ip {
|
|
key {remote_host}
|
|
events 60
|
|
window 1m
|
|
}
|
|
}
|
|
|
|
@register path /api/auth/register
|
|
rate_limit @register zone auth_register
|
|
|
|
@auth path /api/auth/login /api/auth/forgot-password /api/auth/reset-password \
|
|
/api/auth/invite-preview /api/auth/accept-invite /api/auth/complete-set-password \
|
|
/api/sales/contact
|
|
rate_limit @auth zone auth_login
|
|
|
|
@public path /api/public/*
|
|
rate_limit @public zone public_api
|
|
|
|
@v1 path /api/v1/*
|
|
rate_limit @v1 zone apikey_ip
|
|
|
|
reverse_proxy 10.0.1.10:28471 10.0.1.11:28471 {
|
|
header_up X-Forwarded-For {http.request.header.X-Forwarded-For}
|
|
header_up X-Real-IP {remote_host}
|
|
}
|
|
}
|
|
```
|
|
|
|
Match zone event counts to the table above. Prefer nginx if you want stock OpenResty/nginx without a custom Caddy build.
|
|
|
|
See also: [docs/security-notes.md](../../docs/security-notes.md) (ops knobs), [docs/production-readiness.md](../../docs/production-readiness.md) § edge rate limits. |