Skip to content

AI Gateway Rate Limits

Varity TeamCore Contributors

The AI Gateway (https://ai.varity.app/v1) is a separate surface from the Varity platform API and enforces its own budgets. Nothing on Rate Limits applies here, and nothing here applies there.

Two different mechanisms are at work, and they return different headers. Read the one that covers the route you are calling.

RoutesBoundWhere it comes from
Inference (/v1/chat/completions, /v1/embeddings, /v1/images/*, /v1/audio/*)120 requests per minute and 31 concurrent requests on the standard tierAdmission, per account
Inference reads (/v1/inference/policy, /requests, /usage, /performance)120 requests per 60-second windowA request budget, per account
GET /v1/models, GET /v1/openapi.jsonNo credential and no request budgetPublic discovery
Account routes (/v1/keys*, /v1/billing/*, PUT /v1/inference/policy)See Account routesA request budget, per account

Do not hardcode the defaults. GET /v1/inference/policy publishes the numbers your account is actually held to:

Terminal window
curl https://ai.varity.app/v1/inference/policy \
-H "Authorization: Bearer $VARITY_API_KEY"
{
"object": "inference.tenant_policy",
"privacy_floor": "anonymized",
"limit_tier": "standard",
"requests_per_minute": 120,
"concurrency_limit": 31,
"limits_scope": "account"
}
FieldMeaning
limit_tierThe named tier your account’s bounds belong to. Self-service accounts are standard. Absent when the account’s bounds do not match a named tier.
requests_per_minuteInference requests admitted in any sliding 60-second window.
concurrency_limitInference requests that may be in flight at once.
limits_scopeAlways account. Both bounds are counted globally for the account.
credential_requests_per_minutePresent only when you authenticate with a scoped key that carries its own lower rpm bound; null when it does not.
credential_concurrency_limitThe same, for concurrency.

requests_per_minute, concurrency_limit, and limits_scope are absent when the account has no admission policy yet. Absent means “no bound to state”, not “unlimited” — treat it as unknown and pace conservatively.

Higher limits are available on an enterprise agreement.

A key can carry its own requests_per_minute or concurrency_limit. When it does, the account bound is checked first and the key’s bound second, so the key is held to the smaller of the two. Neither number alone is the whole truth, which is why the policy route reports them side by side rather than merging them.

Inference: 120 Per Minute And 31 In Flight

Section titled “Inference: 120 Per Minute And 31 In Flight”

The standard tier is 120 requests per minute and 31 concurrent in-flight requests. A request that crosses either bound is refused at admission, before any model provider is called, so a refused request costs nothing.

The per-minute bound is counted over a sliding 60-second window. It never resets all at once: one request leaves the window when it turns 60 seconds old, and that frees one request.

The concurrency bound is sized from the per-minute one: 31 is the number of requests a client pacing at exactly 120 per minute has in flight when each request takes the gateway’s measured 95th-percentile chat duration.

The practical consequences:

  • Both bounds are account-scoped, so extra keys on the same account share the same budget and buy nothing.
  • Read both numbers from GET /v1/inference/policy rather than assuming the defaults; an account may be provisioned differently.

An admitted inference request carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and RateLimit-Policy before the first byte of the response, so you can pace yourself before you are refused. When a scoped key carries its own lower per-minute bound, the headers report whichever of the account and key bounds has fewer requests left.

Both are 429, and both pace you — but not with the same header set, because they are not the same kind of bound:

Headerrate_limit_exceededconcurrency_limit_exceededupstream_rate_limited
Retry-Afteryes: seconds until the oldest request leaves the windowyes: the expected wait for a slotonly if the provider published its own reset
retry-after-msyes: the same wait in millisecondsyes: the same wait in millisecondswith Retry-After
RateLimit-Limityesyesno
RateLimit-Remaining00no
RateLimit-Resetyes — same value as Retry-Afteryes — same value as Retry-Afterno
RateLimit-Policyyes — 120;w=60no — the bound has no windowno

Every wait the gateway publishes is at most 60 seconds, inside the retry ceilings of the stock OpenAI SDKs, which read retry-after-ms first and Retry-After second.

Each bound also exists at two scopes. The account’s bound is reported with the message The tenant request rate limit is exhausted. / The tenant concurrency limit is exhausted.; a key carrying its own lower override reports The API key … instead. The code is identical either way, so branch on code and read GET /v1/inference/policy to see which bound applies to you.

{
"error": {
"message": "The tenant request rate limit is exhausted.",
"type": "rate_limit_error",
"code": "rate_limit_exceeded",
"param": null,
"request_id": "…",
"diagnostic_code": "ai_gateway.rate_limit_exceeded",
"failure_cause": "rate_limit_exceeded",
"failure_domain": "varity_projection",
"lifecycle_stage": "admission",
"action": "Retry after the credential rate-limit window resets.",
"retryable": true
}
}

Status 429. It carries the full set of pacing headers, including the policy, because this bound is counted over a window:

retry-after: <seconds until the oldest request leaves the window>
retry-after-ms: <the same wait in milliseconds>
ratelimit-limit: 120
ratelimit-remaining: 0
ratelimit-reset: <same as retry-after>
ratelimit-policy: 120;w=60

The values track your account’s own requests_per_minute, so a key with a lower override reports that lower number here. Because the window slides, the wait is the time until the oldest request in it turns 60 seconds old, not a fixed 60.

Fire more requests at once than the bound allows and the surplus is refused:

retry-after: <expected seconds until a slot frees>
retry-after-ms: <the same wait in milliseconds>
ratelimit-limit: 31
ratelimit-remaining: 0
ratelimit-reset: <same as retry-after>
{
"error": {
"message": "The tenant concurrency limit is exhausted.",
"type": "rate_limit_error",
"code": "concurrency_limit_exceeded",
"param": null,
"request_id": "…",
"diagnostic_code": "ai_gateway.concurrency_limit_exceeded",
"failure_cause": "concurrency_limit_exceeded",
"failure_domain": "varity_projection",
"lifecycle_stage": "admission",
"action": "Retry after an active inference request finishes.",
"retryable": true
}
}

Retry-After and RateLimit-Reset name the same instant: the expected moment a slot frees. The gateway estimates it from the oldest request in flight and the typical chat duration, never reports less than 1 second, and never reports later than the nearest in-flight deadline: the instant the serving replica aborts that request and reclaims its slot. Read the header every time; the value changes from one refusal to the next.

If no wait could be estimated, Retry-After, retry-after-ms, and RateLimit-Reset are omitted rather than guessed. Fall back to your own backoff when they are not present.

A 429 can also come from the model provider rather than from your Varity budget. Its code is upstream_rate_limited, its failure_domain is supplier_observed, and it is retryable. It carries Retry-After only when the provider published its own reset; when the provider did not, no Retry-After is sent. Branch on error.code, not on the status, to tell a Varity budget refusal from provider backpressure.

GET /v1/inference/policy, /v1/inference/requests, /v1/inference/usage, and /v1/inference/performance share one budget of 120 requests per 60-second window, keyed to the authenticated account.

These four routes emit standard headers on every response that passes the limiter, not only on a refusal:

HeaderMeaning
RateLimit-Policy120;w=60 — the budget and its window.
RateLimit-LimitRequests allowed in the current window.
RateLimit-RemainingRequests left in the current window.
RateLimit-ResetSeconds until the window resets.
Retry-AfterOn the 429 only.

Legacy X-RateLimit-* headers are not emitted here. Do not depend on them.

A refusal is 429 with code: "rate_limit_exceeded" and the message Too many managed inference requests.

GET /v1/models and GET /v1/openapi.json take no credential and the gateway applies no request budget to them. They return no RateLimit-* headers, so there is nothing to program against there:

Terminal window
curl -sSI https://ai.varity.app/v1/models
HTTP/2 200
content-type: application/json; charset=utf-8
vary: accept-encoding
x-request-id: …

Poll the catalog as often as you need; it is served from the gateway’s own state, not from a provider.

The key, billing, and policy-change routes on this host take a Developer Portal session token, not an inference API key. Their budgets are separate again:

RoutesBudgetWindow
GET /v1/keys, GET /v1/keys/audit300 requests1 minute
POST /v1/keys (mint a key)30 requests1 hour
POST /v1/keys/{id}/revoke120 requests1 minute
/v1/billing/balance, /transactions, /settings, /top-up120 requests1 minute
PUT /v1/inference/policyShares the 120-per-minute inference-reads budget1 minute

Revocation gets its own generous per-minute budget on purpose: a leaked key you cannot revoke stays live, so a safety control is never throttled into unavailability by the hourly creation budget.

These are enforced by the request contract, not by a budget, and they return 413 or 400 rather than 429.

RouteBody ceilingPer-part ceiling
/v1/chat/completions, /v1/embeddings2 MB JSON—
/v1/images/generations, /v1/images/edits68 MB JSONSource image or mask must be under 25 MB decoded
/v1/audio/transcriptions~34 MB JSONAudio must be under 25 MB decoded
/v1/audio/speech1 MB JSON—
/v1/chat/completions documents per message—capabilities.max_documents_per_message on /v1/models (refused with 400 too_many_documents)

The image and audio body ceilings are derived from the per-part ceiling rather than picked independently: base64 inflates by 4/3, and an image-edit request may carry both a source image and a mask.

  • A body over the ceiling returns 413 with code: "invalid_request".
  • A body that is not valid JSON returns 400 with code: "invalid_request".
  • A message carrying more document parts than the model’s capabilities.max_documents_per_message returns 400 with code: "too_many_documents" and param: "$.messages[i].content". The bound is per message, so send the rest in a later message. A model without the field publishes no bound.
import time, random, requests
def call(url, **kw):
for attempt in range(6):
r = requests.post(url, **kw)
if r.status_code != 429:
return r
# Every 429 states its own wait when it knows one: the time until a
# request leaves the sliding window for the per-minute bound, the
# expected wait for a slot for the concurrency bound, the provider's
# reset for upstream_rate_limited. Fall back to local backoff only when
# the header is absent.
wait = float(r.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait + random.uniform(0, 1)) # jitter
raise RuntimeError("rate limit not cleared")

Three rules that matter in practice:

  1. Add jitter. A fleet that all reads the same Retry-After and wakes together re-exhausts the budget instantly.
  2. Bound your own concurrency. The standard tier is 31 in flight for the whole account. A worker pool wider than that will spend its surplus requests collecting concurrency_limit_exceeded.
  3. Branch on error.code, never on message. Messages are written for people and can be reworded; code, retryable, and the HTTP status are the contract.