Errors and limits
Common client errors, their response format, and how to handle limits and failures.
Every refusal from the gateway is JSON with one error object:
{
"error": {
"code": "app_rate_limited",
"message": "Per-user rate limit exceeded",
"data": { "scope": "user" }
}
}code is stable and is what your client should switch on. message is for
logs. data is present only when the code alone is not actionable. Every
429 carries a Retry-After header in seconds.
A provider's own error is not in this format: it is passed through as the
provider sent it, with the provider's status code. Provider errors may also contain error.code, so that field alone does not
prove where an error originated. Handle known gateway codes, retain the HTTP
status and provider request ID when present, and inspect Usage and
Auth & Errors to diagnose the source.
Authentication
| Code | Status | Meaning | What to do |
|---|---|---|---|
auth_required | 401 or 403 | No usable credential, an expired gateway token, a revoked key, or a blocked user | Refresh an expired gateway token once. On iOS, withAuthorizedRequest in the Swift package 1.4.1 or later does this for you on a 401 and returns a 403 as it is; if you send a request built with authorizedRequest, call discardAccessToken() and build it again, once. If it persists, check the app credential and the user status in the console |
assertion_required | 401 | A request with an iOS app's gateway token arrived without the X-AppAttest-Proof header, on an app whose Verify App Attest on is Every request. Only that setting answers this code | On the Swift package 1.4 or later, a request built by the package sees this only when the app was switched to Every request after the client's token was issued. withAuthorizedRequest, in 1.4.1 or later, handles it: it discards the token and sends a rebuilt, signed request once. If you send a request built with authorizedRequest, call discardAccessToken() and build the request again, once. A request assembled by hand from gatewayAccessToken() must be built with the package instead. On an earlier package, update to 1.4 or later, or set Verify App Attest on to Token exchange while older builds are still in use |
attest_failed | 403 | An App Attest proof was refused. At the token exchange: the attestation or assertion did not verify. On a request: the X-AppAttest-Proof header is malformed, signed by a key that is not registered or whose environment is no longer accepted, signed over a different request, or its assertion counter was already used. The message says which | withAuthorizedRequest in the Swift package returns it to you rather than retrying it. Build a new request and send it once. If it persists, call discardAccessToken() and build the request again: the fresh exchange re-registers the key when the gateway no longer knows it. That does not help when the key's environment is no longer accepted or the request was changed after signing |
issuer_token_rejected | 403 | The sign-in token did not verify | Refresh the ID token, retry once, then treat as signed out |
issuer_claims_missing | 403 | The token verified but the required entitlement claim is not there yet | Wait and retry. Do not sign the user in again. Sync the purchase if you can |
issuer_verification_unavailable | 503 | The identity provider's keys could not be fetched | Retry with backoff |
auth_method_not_supported | 404 | The app is not configured for this call, such as a token exchange on an app without user identity | Fix the app's configuration |
invalid_request | 400 | A malformed body, a missing X-App-Version, or a missing end-user header | Fix the request |
rate_limited | 429 | Too many calls to challenge, register or token for this app from one network address. data.scope names the endpoint: app_auth_challenge, app_auth_register or app_auth_token | Wait Retry-After seconds. A real app almost never reaches this, so look for a retry loop of your own, or a lot of devices sharing one address |
rate_limited is the gateway protecting these three endpoints itself, and is
counted per app and per address. It is not one of the limits you set on your
app below, and nothing in the console changes it.
Limits
| Code | Status | Meaning | What to do |
|---|---|---|---|
app_rate_limited | 429 | A per-minute or per-day request limit set on this app refused the call. data.scope is install, user or app | Wait Retry-After seconds. An install or user scope means slow this caller down; an app scope means the whole app is busy |
app_budget_exhausted | 429 | A monthly spending budget set on this app is spent. Same data.scope | Wait for the next month, or raise the budget in the console |
billing_request_quota_exceeded | 429 | The monthly request allowance of the account that owns the app is spent. data carries limit, used and resetAt | Nothing on the device fixes it. Show a friendly message and retry after resetAt |
billing_unavailable | 503 | The gateway could not check the account, so it refused rather than guess | Retry after Retry-After. Not an account problem |
billing_payment_required | 402 | Account access needs attention in the console | Ask the person managing the gateway account to resolve it; retrying on the device will not help |
payload_too_large | 413 | The request body is over 20 MB | Send less |
app_* codes come from limits set on the app in the console. billing_*
codes come from the account and are unrelated to those limits.
Account access
These refusals concern the gateway account, not your app's end-user sign-in:
| Code | Status | What to do |
|---|---|---|
unclaimed_access_expired | 403 | The person who started the account must claim it through the CLI or MCP browser handoff. Do not ask the app's end user to sign in again. |
account_expired | 403 | The account's recovery deadline has passed. Stop automatic retries and ask the person managing the gateway account to investigate. |
email_unverified | 403 | The gateway account's owner has not verified their email address yet. They open the link in the verification email, or send a new one from the console; requests are served from then on. Do not ask the app's end user to sign in again. |
Policy
| Code | Status | Meaning | What to do |
|---|---|---|---|
path_not_allowed | 403 | The app's proxy policy does not allow this provider or path | Use an allowed path, or allow it in the console |
model_not_allowed | 403 | The model is not in the app's allowlist | Use an allowed model |
max_output_tokens_exceeded | 403 | The request asks for more output than the app allows | Lower the output limit in the request |
api_style_not_supported | 403 | This path does not exist on the route the provider uses, such as Gemini's native API through Vercel | Use a supported path or a direct provider |
pricing_not_configured | 400 | The model has no price in the catalog or the provider's custom pricing | Add a price in the console, or use a catalogued model |
endpoint_not_found | 404 | No custom endpoint has this slug | Check the slug |
app_not_found | 404 | No app has this ID | Check the app ID |
app_disabled | 403 | The app is switched off in its settings | Nothing on the device fixes it |
Providers
| Code | Status | Meaning | What to do |
|---|---|---|---|
provider_not_configured | 502 | No provider has this slug | Add one in the console |
provider_disabled | 502 | The provider with this slug is paused | Enable it in the console |
provider_unavailable | 502 | The provider's key could not be read | Check the gateway's health |
provider_error | 502 or 504 | The provider could not be reached, or sent no response headers before the timeout (120 seconds by default) | Retry with backoff |
service_unavailable | 503 | The infrastructure the gateway runs on failed or was overloaded, or a sign-in or /me call took longer than 10 seconds. Nothing about the request is known to be wrong | Retry after Retry-After with backoff |
internal_error | 500 | Something failed inside the gateway | Retry with backoff |
A sensible client policy
- Use bounded backoff for transient
429and5xxerrors, respectingRetry-After. A budget or account refusal needs the action in its row above. Avoid automatically replaying inference after a partial response or a lost connection: the provider may already have processed and charged for it. - On
app_rate_limitedwith scopeinstalloruser, back that caller off forRetry-Afterseconds. An iOS app counts request limits per install. With scopeapp, show a "busy, try again shortly" state rather than hammering. - On
issuer_claims_missing, keep the user signed in and poll gently. - Send
X-App-Versionon every request, so the console can tell you which release a problem belongs to. - Log
codeandmessage, never the request body or the token.
The Swift package's GatewayError exposes code, retryAfter, limitScope
and isRetryable for exactly this. See
iOS with the Swift package.