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