Rate limits and budgets
How the gateway meters you: rate_limited versus budget_exhausted, the rate-limit headers, spend caps, and reading your live headroom.
Two separate controls can refuse a request with 429: a rate limit on how
fast you send requests, and a budget on how much you spend. The body's
code tells you which one you hit, and they need different handling. Your
account's limits are shown in your Console, and GET /api/v1/limits returns
them, with live headroom, to your code.
rate_limited or budget_exhausted
| rate_limited | budget_exhausted | |
|---|---|---|
| What ran out | Request rate: too many requests in the current window. | Allowance: the plan's monthly cap, its 12-hour burst pool, or the monthly mira request cap is spent. |
| What to do | Back off and retry after Retry-After. | Stop retrying. No immediate retry succeeds. |
| Clears | When the rate window resets. | At reset_at, when the spent window resets. |
| Headroom | rate_limits.chat on GET /api/v1/limits | token_pool on GET /api/v1/limits |
Spend caps refuse with 429 too, under their own codes: a monthly cap on a
key (key_spend_cap_exceeded), a monthly cap on a workspace
(workspace_spend_cap_exceeded), and the credit pool (credits_exhausted).
Like budget_exhausted, they mean an allowance is spent, and they clear only
at reset_at or when the cap changes. Every 429 carries Retry-After; on
these codes and on budget_exhausted it counts down to reset_at, and
retrying sooner fails the same way.
POST /api/v1/moderations never returns budget_exhausted: moderation is
exempt from the subscription pool, so a spent allowance cannot switch it off.
It can still return rate_limited or a spend-cap code.
Rate-limit headers
Metered responses carry the state of the bucket the route is metered on:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per window. |
X-RateLimit-Remaining | Requests left in the current window, never below 0. |
X-RateLimit-Reset | When the current window ends, as an absolute Unix time in seconds, not a duration. |
Retry-After | On every 429, the seconds to wait. On a budget or spend-cap refusal, the seconds until reset_at. |
The X-RateLimit-* headers always describe the per-minute request window, so
a budget refusal can still show requests remaining.
The X-RateLimit-* headers are absent on a 401, because authentication runs
before the limiter, and on a 403 from the key check (key_scope_forbidden,
key_read_only). They are also absent when the limiter cannot reach its own
storage; that request is served unmetered rather than refused.
# -D - prints the response headers; look for X-RateLimit-* and, on a 429, Retry-After.
curl -s -D - -o /dev/null https://api.cruxy.ai/api/v1/limits \
-H "Authorization: Bearer $CRUXY_API_KEY"// These clear only at reset_at; every other 429 clears after Retry-After.
const ALLOWANCE_SPENT = new Set([
'budget_exhausted',
'key_spend_cap_exceeded',
'workspace_spend_cap_exceeded',
'credits_exhausted',
])
const res = await fetch('https://api.cruxy.ai/api/v1/chat', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CRUXY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mira',
stream: false,
messages: [{ role: 'user', content: 'Hello' }],
}),
})
const remaining = res.headers.get('X-RateLimit-Remaining')
const resetAt = res.headers.get('X-RateLimit-Reset') // Unix seconds
if (res.status === 429) {
const { code, reset_at, request_id } = await res.json()
const waitSeconds = Number(res.headers.get('Retry-After'))
if (ALLOWANCE_SPENT.has(code)) {
// No retry succeeds before reset_at.
console.log(`allowance spent: ${code}, resets at ${reset_at} (request_id ${request_id})`)
} else {
// rate_limited, or a transient headless check: wait Retry-After, then retry.
console.log(`${code}; retry in ${waitSeconds}s (request_id ${request_id})`)
}
} else if (remaining !== null) {
console.log(`${remaining} requests left until ${new Date(Number(resetAt) * 1000).toISOString()}`)
}Pace yourself before you reach 0: when X-RateLimit-Remaining runs low,
slow down until X-RateLimit-Reset rather than waiting for a 429.
Read your limits
GET /api/v1/limits returns the limits that apply to your key, with live
headroom. Every value comes from the same limiter and budget state that chat
enforces, so remaining is exactly what your next request is checked against.
The call costs nothing, writes nothing, and is rate-limited like any other
route.
curl https://api.cruxy.ai/api/v1/limits \
-H "Authorization: Bearer $CRUXY_API_KEY"const res = await fetch('https://api.cruxy.ai/api/v1/limits', {
headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}` },
})
if (!res.ok) throw new Error(`limits request failed: ${res.status}`)
const limits = await res.json()
const perKey = limits.rate_limits.chat.per_key
console.log(`chat: ${perKey.remaining} of ${perKey.limit} left this window`)
if (limits.token_pool) {
console.log(`token pool: ${limits.token_pool.state}`) // healthy, low, or blocked
}Everything comes from the key you send; nothing is read from the request.
Which blocks appear depends on your billing bucket:
| Field | Meaning |
|---|---|
object | limits. |
bucket | How this key is billed, and so which kind of key it is: subscription (a signed-in credential, with token_pool), apikey (a developer API key, billed per token), or headless (billed from prepaid credits). Memory and standing rules are on by default only for subscription. |
metered | true for the apikey bucket only, and omitted otherwise: no token pool applies, and every request is billed per token. A key or workspace spend cap still binds it. |
tier | Your account's subscription plan, which sizes token_pool. This is not a model tier. |
rate_limits.chat.per_key | The chat request-rate window for this key: limit, remaining, resets_at, and window_seconds. resets_at is null until a request opens the window. |
rate_limits.chat.per_org | The same window, for your whole organization. |
rate_limits.chat.max_concurrent_requests | How many chat requests your account may have in flight at once, across all three chat endpoints. One more is refused at once, not queued: 429 rate_limited with reason: concurrency and Retry-After: 1. |
token_pool | Subscription bucket only. The weighted-token budget behind budget_exhausted. |
credits | Headless bucket only. The dollar-credit pool: granted, adjustment, used, remaining, and resets_at. |
key_spend_cap | Present only when the key has a monthly spend cap: cap, spent, remaining, and resets_at, in USD. |
workspace_spend_cap | Present only when the key's workspace has a monthly spend cap, in the same shape. |
token_pool has two windows, each with cap, used, remaining,
resets_at, and a state of healthy, low, or blocked:
monthly: the calendar-month cap.burst: the trailing 12-hour sliding cap.
It also carries an overall state, blocked_models, the unit
(weighted_tokens), and mira, a monthly request cap on the mira tier with
its own cap, used, remaining, resets_at, and state.
There is no tokens-per-minute limit and no per-model daily cap. The gateway enforces neither, so neither appears here.
Budgets and auto
A request with model: "auto" may be budget-downgraded to mira. Read
cruxy.routed_tier on the response to see the tier that served it, rather than
assuming the router's first choice; see
Auto routing.
Next steps
- Errors and retries: every
429code, and a retry loop that honoursRetry-After. - Features overview: the tiers and
auto. - Get your API key: key restrictions, including spend caps.