Docs

Rate limits

X-RateLimit-* headers, Retry-After backoff, sliding-window defaults, and exponential retry strategy.

Rate limits

Hangar enforces per-user sliding-window limits in Postgres. The window is one minute (some surfaces are tighter). The same row that enforces the limit also returns the headers — there is no separate limiter service.

Headers

Every limit-aware response includes:

Header Meaning
X-RateLimit-Limit Requests allowed in the current window.
X-RateLimit-Remaining Requests left in the current window.
X-RateLimit-Reset Unix epoch seconds at which the window resets.
Retry-After (On 429) seconds to wait before the next attempt.

A 429 response also carries a JSON body:

{
  "error": "rate-limited",
  "message": "Too many requests. Retry after 12s.",
  "retryAfterSeconds": 12
}

Defaults

Surface Limit
/api/mcp 60 / 60s per token
/api/llm/proxy 240 / 60s per machine
/api/instance/* 30 / 60s per user
/api/wallet/topup 5 / 60s per user
/api/tokens (POST) 10 / 60s per user
/api/checkout 10 / 60s per user
/api/skills 60 / 60s per user

These are subject to change as the platform scales. Always read X-RateLimit-Limit rather than hardcoding the number.

Backoff strategy

For 429s, honor Retry-After strictly. If the header is missing (unusual), use exponential backoff capped at 32 seconds:

async function withBackoff<T>(fn: () => Promise<Response>): Promise<T> {
  let delay = 1_000
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fn()
    if (res.ok) return res.json()
    if (res.status !== 429) throw new Error(`HTTP ${res.status}`)
    const retryAfter = Number(res.headers.get('retry-after')) * 1_000
    await sleep(retryAfter || delay)
    delay = Math.min(delay * 2, 32_000)
  }
  throw new Error('exceeded retries')
}

For 5xx errors, the same exponential pattern applies; the retry is safe because every mutating Hangar endpoint is idempotent on the same Idempotency-Key header (when supplied).

Rate limits — Hangar