Rate limits
Per-IP quotas on the hosted API, why they're set the way they are, and how to tune them when self-hosting.
Hosted (navii-api.uxderrick.com)
| Route | Limit (per IP) | Window |
|---|---|---|
/avatar/:seed (SVG) | 600 req/min | 60s sliding |
/avatar/:seed.png | 600 req/min (shared bucket) | 60s sliding |
/group | unlimited | — |
/cast.svg | unlimited | — |
/build/render (SVG + PNG) | unlimited | — |
/builder, /docs, /, /api | unlimited | — |
/healthz, /gallery, icons, /og.png | unlimited | — |
Why this is more than enough
Avatar responses ship the strongest cache header a CDN respects:
Cache-Control: public, max-age=31536000, immutable
That means every browser, every proxy, every CDN caches the exact (seed, params) bytes for a year. Same user, same device → one request, then zero forever. A given seed's response is byte-identical (it's deterministic), so the cache is always valid.
Realistic monthly traffic for 1 000 active users:
1 000 users × ~3 devices avg × ~5 cache-miss loads/mo ≈ 15 000 req/mo
That's ~21 requests/hour. The 600/min/IP ceiling exists only to swat abusers — normal apps won't notice it.
When you hit it
HTTP/1.1 429 Too Many Requests
Retry-After: 23
Content-Type: text/plain
Rate limit exceeded
Retry-After is seconds until your IP's window resets. Back off, retry. No exponential backoff math needed — the server already tells you when to come back.
Common triggers:
- Loading an avatar gallery with hundreds of unique seeds in one go (use
/groupor/cast.svginstead — both unlimited) - Server-side rendering that fetches per request instead of caching
- Crawlers / scrapers
How to stay under it
- Respect the cache headers. Don't strip them in your reverse proxy.
- Bundle calls. If you need 5 teammates, hit
/group?seeds=a,b,c,d,e(one request, unlimited route) instead of five/avatar/*calls. - Self-host the API container if you ever expect uncached bursts above 600/min from a single IP — the docker image is the same one we run.
Self-hosting? Tune it
The limit is just an env var on the API container:
# /opt/navii/.env or docker-compose env block
RATE_LIMIT_PER_MIN=600 # bump as needed
TRUST_PROXY=1 # enable X-Forwarded-For reading (Caddy/Nginx)
Set to 0 to disable rate-limiting entirely. See Deployment for the full env reference.
Don't enable TRUST_PROXY behind a raw CDN — clients can spoof X-Forwarded-For. Use it only behind a proxy you control.
Implementation notes
- Sliding window, in-memory
Map<ip, { count, resetAt }>, pruned every 60s. - Stateless across container restarts — a deploy resets the limiter.
- Per-process. If you scale to N replicas, each replica has its own bucket — effective limit becomes
N × RATE_LIMIT_PER_MIN. Swap for Redis when this matters. - IP attribution comes from
X-Forwarded-Forfirst whenTRUST_PROXY=1, else falls back to "unknown" (single bucket for all — fail-open behind a misconfigured proxy, fail-safe otherwise).