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)

RouteLimit (per IP)Window
/avatar/:seed (SVG)600 req/min60s sliding
/avatar/:seed.png600 req/min (shared bucket)60s sliding
/groupunlimited
/cast.svgunlimited
/build/render (SVG + PNG)unlimited
/builder, /docs, /, /apiunlimited
/healthz, /gallery, icons, /og.pngunlimited

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 /group or /cast.svg instead — 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-For first when TRUST_PROXY=1, else falls back to "unknown" (single bucket for all — fail-open behind a misconfigured proxy, fail-safe otherwise).