Files
descrybe/deploy/examples/edge-rate-limit.md
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

5.4 KiB

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

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 §1f · docs/cutover.md.


nginx (shared limit_req_zone across upstreams)

# 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)

# 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 (ops knobs), docs/production-readiness.md § edge rate limits.