Skip to main content

Error Handling

Best practices for handling API errors gracefully.

Error Categories

CategoryStatusRecovery
Client errors4xxFix request
Billing blocks402Settle, raise the limit, or wait for a daily or monthly cap to reset. Never retry unchanged.
Rate limits429Retry with backoff
Server errors5xxRetry

Error Envelope

All error responses use an OpenAI-style envelope:

{
"error": {
"message": "Human-readable explanation.",
"type": "invalid_request_error",
"param": null,
"code": null
}
}

error.message is always a plain string. error.param is always present (null unless a specific parameter is at fault). error.code is a stable machine-readable code where applicable, and null otherwise.

error.type is one of:

error.typeStatusesMeaning
invalid_request_error400, 402, 404, 409, 410, 413, 422The request itself is wrong. Fix it before retrying.
authentication_error401, 403The key is missing, invalid, expired, or lacks the scope.
insufficient_quota402A spend or balance limit blocked the request. See below.
rate_limit_error429A rate limit was hit. Retry with backoff.
api_error502The model produced an unusable result -- synthesis_truncated and synthesis_incomplete on text to speech.
service_unavailable503A named dependency the platform already knows is down: a model it has marked unavailable, the audio path, or the billing path. Retry shortly.
timeout_error504The request passed a processing deadline (audio_timeout). Send a smaller request.
server_error500, 503, and any 5xx the platform did not classify furtherOur side. Retry with backoff. Most 503s are this type, including every upstream model failure.

402 appears on two rows deliberately. A money block -- a balance, a credit limit, a spend cap -- is typed insufficient_quota. A model with no pricing configured is also a 402, but typed invalid_request_error, because the request named something the platform cannot sell you. Both are covered below.

Most 503s are not typed service_unavailable. The status-to-type table the platform applies has no 503 entry at all, so a 503 falls through to server_error unless the code raising it builds its envelope by hand and says otherwise. Only three places do that: the audio router, the realtime router, and the model-endpoint cache. Everything else is server_error -- a supporting service being down (the auth service, the rate-limit service), and every failure reaching a model's upstream, which is the most common 503 on the inference path. Branch on the status first, on error.code second, and on error.type last.

Note that 403 Forbidden responses use authentication_error (same as 401), and 404 Not Found responses use invalid_request_error -- there are no separate permission_error or not_found_error types. Note also that a 5xx is never typed authentication_error: an outage must not look to your SDK like a bad key.

Some responses add platform-specific fields next to the four standard ones (limit_type, limit, current, retry_after, reset_at). SDKs ignore unknown fields, so they are safe to read when present and safe to ignore when absent. They are also genuinely optional: a numeric field the platform could not read is omitted rather than sent as 0.0, so treat a missing limit or current as "unknown", never as "zero".

error.code values

error.codeStatuserror.type
context_length_exceeded400invalid_request_error
invalid_request400invalid_request_error
model_limit_exceeded400invalid_request_error
api_key_spend_cap_exceeded402insufficient_quota
credit_limit_exceeded402insufficient_quota
daily_cost_cap_exceeded402insufficient_quota
insufficient_credits402insufficient_quota
model_not_priced402invalid_request_error
monthly_cost_cap_exceeded402insufficient_quota
no_credit_line402insufficient_quota
payg_spend_cap_exceeded402insufficient_quota
tenant_suspended403authentication_error
request_too_large413invalid_request_error
too_many_requests429rate_limit_error
upstream_rate_limited429rate_limit_error
model_unavailable503service_unavailable
upstream_unavailable503server_error

invalid_request, upstream_rate_limited and upstream_unavailable are the three codes the platform uses when the failure came from a model's upstream rather than from the platform itself. Note that invalid_request (a code) and invalid_request_error (a type) are different strings for different fields; do not match one against the other.

Common 4xx Errors

400 Bad Request: empty messages array

/v1/chat/completions and /v1/completions reject requests with an empty messages array (messages: []) or messages with unknown roles. Valid roles are user, system, assistant, tool, and function.

{
"error": {
"message": "'messages' field is required and must contain at least one message",
"type": "invalid_request_error",
"param": null,
"code": null
}
}

This validation runs before any token is consumed, so a malformed request does not bill against your quota.

400 Bad Request: a document the platform cannot send

A file content part is converted into page images before the request is forwarded, and that is true on every model, whichever engine serves it. Two refusals follow from it, and both now behave identically on self-hosted and provider-served models:

ConditionStatuserror.message
The model's capabilities do not include vision400"This model does not support document inputs. Use a vision-capable model to process PDF files."
The file part is not a PDF400"Only PDF files are supported for document uploads. Convert the document to PDF and try again."

PDF is the only document format the platform accepts. Provider-served models used to have their file parts forwarded untouched, so any type was accepted here and refused upstream, with the provider's wording rather than the platform's. There is now no model anywhere on the platform that takes a non-PDF document; convert it, or extract the text yourself and send it as a text part.

Images are unaffected: an image_url part goes to a vision model as it always did.

413 Payload Too Large: the converted document is too big

The size of a request carrying documents is measured twice — once on the body you sent, and once after the pages have been converted to images. Page images are considerably larger than the PDF they came from, so a document that passes the first check can still fail the second:

{
"error": {
"message": "Total file size (14.6MB) exceeds provider's limit (10MB). Use smaller files or a different provider."
}
}

The figure is the total of every inline attachment in the request, reported to one decimal place. Send fewer pages, a lower-resolution source, or a model with a larger body limit.

402 Payment Required: spend and balance blocks

A 402 means the request was refused for a money reason, not a request-shape reason. Every dollar-denominated ceiling on the platform now answers a 402 -- there is no longer one spelling for a credit block and a different one for a spend cap. Branch on error.code, not on the status alone: the seven blocks below need different actions, and the wait that clears one does nothing for another.

error.codeWhat happenedWhat to do
api_key_spend_cap_exceededThe API key you presented reached the daily spend cap set on that key.Raise or remove the cap on the key -- that lifts the block straight away -- or wait for the daily reset at 00:00 UTC. Other keys on the same account are unaffected.
credit_limit_exceededLive outstanding exposure -- unpaid invoices plus the accrual in the currently open billing period -- reached the account's credit limit.Settle the outstanding balance, or ask for a higher limit. Service resumes about a minute after the payment lands.
daily_cost_cap_exceededA daily cost ceiling was reached: either your own personal daily limit or the account-wide one. limit_type says which.Wait for the reset at 00:00 UTC. If it was your personal limit, you can also raise it yourself on the portal's Profile page and carry on immediately.
insufficient_creditsA prepaid wallet cannot cover this request -- either it is empty, or what is left will not cover the request's estimated cost.Add credits, or ask for less output: a lower max_tokens needs less balance. See below.
monthly_cost_cap_exceededA monthly cost ceiling was reached: either the account-wide one or the one set on your own user. limit_type says which.Wait for the reset at 00:00 UTC on the 1st, when the next billing month begins, or contact support to raise it. This ceiling is set by an administrator, so there is nothing to raise on the portal yourself: the portal's own spending limit is a daily one and does not govern this refusal.
no_credit_lineThe account is postpaid with a credit limit of exactly 0, so no usage is authorised at all.Waiting does not help. The account needs funds (switching it to prepaid) or an approved credit line.
payg_spend_cap_exceededThe pay-as-you-go spend cap for the current billing period was reached.An owner or admin can raise or remove the cap on the portal's Billing page and access resumes within about a minute. Otherwise it clears when the billing period ends.

All seven carry error.type of insufficient_quota. limit_type names the exact ceiling:

error.codelimit_type
api_key_spend_cap_exceededapi_key_spend_cap
credit_limit_exceededcredit_limit
daily_cost_cap_exceededcost_per_day (account-wide) or cost_per_day_user (your own)
insufficient_creditswallet_balance
monthly_cost_cap_exceededcost_per_month (account-wide) or cost_per_month_user (your own)
no_credit_linecredit_limit
payg_spend_cap_exceededpayg_spend_cap

error.message names the amounts involved and the control that lifts the block, so it is worth showing to a human as it stands rather than replacing it with your own wording. insufficient_credits is the exception: its message is a fixed string that names no amount at all, so read the balance out of error.current instead.

limit and current are sent in the money unit when the platform could read them, and omitted when it could not -- a missing field means "unknown", not "zero". no_credit_line is the clearest case of this: it reports limit as 0.0 and omits current entirely, because there is no meaningful spend figure to compare against a zero ceiling.

retry_after (seconds) is mirrored in the Retry-After response header, and its value is a real hint about how long the condition lasts: 60 for credit_limit_exceeded, because a settled balance clears within about a minute, and 3600 for no_credit_line, because nothing changes until a human changes it. The two daily caps count down to the next 00:00 UTC and also carry a reset_at at that instant, and the monthly cap counts down to 00:00 UTC on the 1st and carries its reset_at there. A monthly retry_after is therefore large, up to about four weeks: it is a real instant to schedule against, not a hint to loop on. The credit-limit block carries no reset_at -- a debt figure has no reset instant, and it does not clear on a calendar boundary.

{
"error": {
"message": "Credit limit reached. Settle the outstanding balance to continue.",
"type": "insufficient_quota",
"param": null,
"code": "credit_limit_exceeded",
"limit_type": "credit_limit",
"limit": 500.0,
"current": 500.0,
"retry_after": 60
}
}

Three of the seven clear on a clock; four do not. daily_cost_cap_exceeded and api_key_spend_cap_exceeded are per-UTC-day accumulators and monthly_cost_cap_exceeded is a per-UTC-month one, so a client that waits until reset_at genuinely recovers. For the other four, retrying an unchanged request is pointless until the underlying balance or limit changes -- treat them as non-retryable in your client and surface them to a human.

The per-window cost ceilings carry your budget headers

A 402 for cost_per_day, cost_per_day_user, api_key_spend_cap, cost_per_month or cost_per_month_user carries the same x-ratelimit-* cost and window headers the response would have carried as a 429, so a client that reads its remaining budget off a refusal keeps working. credit_limit and payg_spend_cap deliberately carry none: a credit ceiling is not a per-window cost budget, and publishing x-ratelimit-limit-cost for one would advertise a rate limit that does not exist. See Rate Limiting.

A prepaid wallet is checked before the request runs

On a prepaid account, admission is no longer decided from a balance the platform read a moment ago. Before the request is forwarded, the platform prices the request's worst case and reserves that amount against the wallet. The reservation is released when the request finishes, and you are charged for the usage actually measured -- nothing is ever billed from the estimate. If the wallet cannot cover the estimate, the request is refused before it runs, with the same 402 / insufficient_quota / insufficient_credits / limit_type: wallet_balance envelope documented above.

{
"error": {
"message": "Insufficient credits. Your wallet balance is depleted \u2014 add credits to continue using this model.",
"type": "insufficient_quota",
"param": null,
"code": "insufficient_credits",
"limit_type": "wallet_balance",
"current": 0.41,
"limit": 0
}
}

Four things to design for:

  • A refusal does not prove the wallet is empty. The message is a fixed string: it names no amount, no shortfall and no model. error.current is the balance still available at the instant of refusal -- what remains after the reservations held by requests already in flight -- and error.limit is 0. A wallet holding real money will refuse a request whose estimate is larger than that balance, so show current to a human rather than the wording.
  • max_tokens decides how much balance a request needs. The estimate prices your input plus the output ceiling you asked for: max_tokens (or max_completion_tokens) taken from the request itself, and a platform default output cap when you send neither. The same prompt on the same balance can be admitted with a small max_tokens and refused with a large one. Streaming is not exempt -- a streamed completion reserves exactly as a buffered one does.
  • Concurrent requests no longer share one reading of the balance. Each in-flight request holds its own reservation, so a burst is admitted only as far as the balance covers it and the remainder is refused immediately. Retrying a refused request unchanged, at the same moment, will be refused again.
  • The audio endpoints are covered too. /v1/audio/transcriptions, /v1/audio/translations and /v1/audio/speech hold their estimated charge before the request runs and refuse with this same 402; previously the balance was not consulted on those paths at all. A realtime session that runs short of balance mid-session is closed on a spend-limit close code rather than failing as a fault -- see Realtime Transcription.

An account on a plan is not refused this way while it is still inside its included quota: the reservation only applies where the wallet is the payer for that request.

How this differs from the other 402s

Every other refusal on this page is a ceiling somebody configured, and each has a control that lifts it -- raise the cap, settle the balance, or wait for a reset. This one has no ceiling: it is the money in the wallet weighed against the cost of the request in your hand. It carries no retry_after and no reset_at, because nothing about it clears on a clock. Adding credits, or asking for less output, is the whole remedy. For the wallet itself -- what the balance means and how to top it up -- see Billing.

Migrating your error handling: a spend stop is a 402, not a 429

The personal daily cost limit, the per-API-key daily spend cap and the monthly cost ceiling used to be refused with 429 rate_limit_error. They now answer 402 insufficient_quota, in the same shape the credit limit and the pay-as-you-go cap have always used. The monthly ceiling is the last of them to change, so an integration that was updated for the daily ones still has this one refusal left to handle.

This breaks except RateLimitError

An OpenAI-compatible SDK maps 429 to RateLimitError and has no mapping for 402. Code that caught a spend stop as a rate-limit error and backed off will now see an unhandled APIStatusError instead. Add an explicit 402 branch before you upgrade.

from openai import APIStatusError, RateLimitError

MONEY_CODES_THAT_CLEAR_ON_A_CLOCK = {
"daily_cost_cap_exceeded",
"api_key_spend_cap_exceeded",
"monthly_cost_cap_exceeded",
}

try:
response = client.chat.completions.create(model=model, messages=messages)

except RateLimitError:
# Still correct, and still only about requests, tokens and concurrency.
# These clear on their own -- back off and retry.
backoff_and_retry()

except APIStatusError as e:
if e.status_code != 402:
raise

body = e.body if isinstance(e.body, dict) else {}
code = (body.get("error") or {}).get("code")

if code in MONEY_CODES_THAT_CLEAR_ON_A_CLOCK:
# A per-window money ceiling. The daily ones clear at 00:00 UTC and
# the monthly one at 00:00 UTC on the 1st, which can be four weeks
# out. Read error.reset_at and wait; do not hammer it meanwhile.
pause_until_reset(code)
else:
# credit_limit_exceeded, insufficient_credits, no_credit_line,
# payg_spend_cap_exceeded -- nothing but a person clears these.
alert_a_human(code)

The gates measured in requests, tokens or concurrency -- requests_per_minute, tokens_per_minute, tokens_per_day and concurrent_requests -- are unchanged: they still answer 429 and your SDK still raises RateLimitError for them. See the Rate Limiting guide.

Which of these you can actually hit

insufficient_credits is reachable only on a prepaid wallet. The platform default is postpaid, so no tenant on the default arrangement ever sees it.

credit_limit_exceeded measures live exposure, and one half of that exposure is currently always zero: no invoices are being issued yet, so "unpaid invoices" contributes nothing and only the open period's accrual counts. Accounts with credit_limit unset have no ceiling to breach at all.

monthly_cost_cap_exceeded is reachable only where the billing path is not governing your period. On an account with a wallet or a credit limit, that ceiling governs instead and the monthly one is skipped, so you would see insufficient_credits or credit_limit_exceeded rather than this.

One 402 is not a money block

There is one other 402: the model you named has no pricing configured. It carries error.code of model_not_priced and error.type of invalid_request_error -- your account is fine, the request simply named a model the platform cannot bill for. Contact your administrator; retrying will not help. The audio endpoints return the same code, documented under Speech to Text.

401 or 503: a rejected key, or the auth service itself

These two look similar in a log and mean opposite things, so the platform keeps them strictly apart.

Invalid keyAuth service unavailable
Status401503
error.message"Invalid API key""Authentication service unavailable" or "Authentication service error"
error.typeauthentication_errorserver_error
error.codenullnull
Retry-Afterabsentpresent

The presence of Retry-After is the reliable discriminator. A credential problem never carries one, because no amount of waiting fixes a wrong key; a dependency outage always does. An outage is never dressed up as a bad key: a 5xx is never typed authentication_error, so an SDK will not raise AuthenticationError and send your users off to regenerate perfectly good credentials.

When the upstream supplies its own Retry-After, the platform honours it. Only the delta-seconds form is accepted -- the HTTP-date form is rejected rather than misparsed -- and the value is clamped to between 1 and 300 seconds, so a hostile or nonsensical hint cannot park your client for hours.

Browsers cannot read an auth failure

CORS headers are attached by middleware that these responses never reach. A 402 or 429 from the rate-limit middleware echoes Access-Control-Allow-Origin and Vary: Origin and is readable in the browser. A 401, a 403, and the auth-service 503 are built outside that middleware and carry no CORS headers, so a browser refuses to expose them to your JavaScript.

The practical effect: a browser client with a bad key sees an opaque CORS or network error, not a 401. If in-browser calls fail with nothing useful in the error object, check the key from a server or a terminal before debugging your fetch code. Better still, keep API keys server-side and call the platform from your own backend.

403 Forbidden: the organization is suspended

When an organization is suspended, its credentials are refused for inference -- and the refusal now names that as the reason. An API key belonging to a suspended organization used to be told "API key expired or revoked", which sent people off to rotate a perfectly good key.

Status403
error.typeauthentication_error
error.codetenant_suspended

Branch on error.code rather than on the wording. The same 403 status also covers a key that is missing a scope, and the two demand opposite remedies: nothing about the credential needs changing here -- an owner or admin has to bring the account back into good standing. Both an API key and a portal session receive the same code.

503 Service Unavailable: model not serving

Two different failures both mean "the model did not answer", and they carry different types. Branch on error.code, not on error.type.

Platform already knows the model is downThe model's upstream refused or failed
error.codemodel_unavailableupstream_unavailable
error.typeservice_unavailableserver_error
Retry-After3030
Decidedbefore the request is forwardedafter the upstream answered, or failed to

model_unavailable carries a message naming the model and its current state. The model is registered but is not accepting traffic, and the platform refuses the request without forwarding it.

upstream_unavailable is what you get when the request was forwarded and the model's upstream then refused it or failed. An upstream 402, 403, or any 5xx all collapse into this one code, as do a transport failure and a timeout, because none of them is something the caller can fix. For a model routed to an external provider, an upstream 404 joins them -- see 400 Bad Request: the model rejected the request. The message names the model and says nothing further, deliberately -- upstream error text is never passed through:

{
"error": {
"message": "Model gpt-oss-120b is temporarily unavailable.",
"type": "server_error",
"param": null,
"code": "upstream_unavailable"
}
}

In both cases the model exists and your key is fine. Retry with backoff, or switch to another model from GET /v1/models.

Nothing from the provider reaches you verbatim

Every response you receive is built by the platform, including the failures. A model's upstream can also fail in ways that are not an HTTP error at all, and those are normalised the same way:

  • A 200 whose body is not the completion JSON -- a vendor maintenance page, a CDN interstitial -- is diverted to the same 503 instead of being handed to you, and the call is recorded as a failure rather than as a success.
  • A 2xx carrying an error object in place of a completion is treated as a failure, not relayed.
  • On a streaming request, an upstream error body is never passed through mid-stream; you get the terminal error event described under Errors on a Streaming Request.
  • A successful chat completion carries exactly id, object, created, model, choices and usage. Provider identifiers and provider-specific fields appear in neither the body nor the response headers, so nothing in your integration should be keyed on them.

The reason a call failed is also recorded against the usage record for that call, so a failure you did not catch at the time is still explainable afterwards from Usage.

An upstream 402 or 403 maps to 503, deliberately. On this platform a 402 always means your wallet, so it is never used to report a provider's billing state.

Which failures change a model's availability, and which do not

The two 503s above are connected: a failure on one request can be what makes the next one a model_unavailable.

This applies to models routed to an external provider. A terminal refusal from that provider -- 402, 403 or 404 -- is recorded against the model's serviceability, because those three say the model cannot serve anyone through this platform right now, not that one request was unlucky. A model judged unserviceable keeps its place in your catalog, its grants and its pricing, but is marked unavailable and is refused before the request is forwarded, with model_unavailable. A completion that actually succeeds against the provider records the opposite. See Models.

Transient failures record nothing. A provider 429, any 5xx, a failure that appears after a stream has already opened, and transport errors and timeouts are all left out deliberately: an incident is not a statement about whether a model is serving, and none of them can take a model out of service. That is why an upstream_unavailable or an upstream_rate_limited is worth retrying, while a model already marked unavailable is not.

429 Too Many Requests: the model's upstream is rate limiting

Distinct from your own rate limits, and distinct from too_many_requests. error.code is upstream_rate_limited, error.type is rate_limit_error, and Retry-After is 60. Nothing about your quota changed: the model's upstream is refusing traffic. Retry after the hint. See the Rate Limiting guide for the limits that are yours.

400 Bad Request: the model rejected the request

When the upstream rejects the request itself, the platform normalises that to a 400 with error.code of invalid_request and this message:

The request was rejected by the model. Check your parameters and the size of your input.

Upstream 400, 413 and 422 all arrive as this single 400; the upstream's own wording is not forwarded. There is no Retry-After, because retrying an unchanged request will fail again. Check your parameters and the size of your input.

An upstream 404 depends on where the model runs

A 404 from the thing serving the model means two opposite things, and it is no longer reported as one.

Model served by the platform itselfModel routed to an external provider
Status400503
error.codeinvalid_requestupstream_unavailable
error.typeinvalid_request_errorserver_error
Retry-Afterabsent30

For a model the platform serves itself, the model name was resolved from the platform's own catalog before the request was forwarded, so a 404 really does mean the request asked for something that is not there -- it arrives as the 400 above.

For a model routed to an external provider, a 404 means the identifier the platform registered with that provider is not available there. That is a supply-side fault about a value you cannot see and cannot correct from your request, so it is answered like the provider's other terminal refusals: 503 upstream_unavailable, with the same message and the same Retry-After: 30 an upstream 402 or 403 produces. It was previously reported as a 400 telling you to check parameters that were never the problem, so an integration that logged those as client-side bugs will now see them as retryable supply failures instead.

503 Service Unavailable: an internal dependency could not answer

The platform consults its own services before it runs a request -- to price the model, to read your spend ceilings, to meter the call. An outage in one of those does not become a blanket 503 for inference. The default posture is to admit the request and record the gap internally, so a dependency having a bad minute is not something your traffic sees.

Where the platform is deliberately configured to stop rather than admit, the refusal is a 503 with "Billing service unavailable. Inference temporarily disabled for safety.". Retry with exponential backoff -- the same request usually succeeds within a few seconds. This is deliberately distinct from the 402 codes above: nothing about your account changed, so the retry is worth making.

Realtime sessions keep the opposite posture on purpose. A realtime session holds a slot for as long as it stays open, so when the platform cannot confirm there is room for it, the session is refused rather than admitted.

Back-pressure from the account API is a 503, not a 500

Requests to the account and organization endpoints (/api/v1/... on the auth service — keys, users, plans, settings) answer 503 when that service is at capacity or cannot reach its database, on every route. Previously only the internal key-verification path did this and everything else answered 500 server_error, which was indistinguishable from a defect.

Status503
error.message"Service temporarily unavailable"
error.typeservice_unavailable
error.codenull
Retry-After1

The envelope is otherwise identical to the 500 one, so branch on the status or on error.type. Retry it — the request itself is fine and usually succeeds on the next attempt. A 500 from these endpoints now means something genuinely went wrong with the request, and retrying it will fail the same way.

404 Not Found: retired billing endpoints

Four billing paths were withdrawn and answer 404: /api/v1/billing/current, /api/v1/billing/cycles, /api/v1/billing/summary and /api/v1/billing/invoices. They are also gone from the published OpenAPI schema, so nothing generated from the current schema can reach them. The 404 comes from upstream and says nothing about being retired, so it reads exactly like a request for a missing record.

Older clients keep hitting it. Deleting the four operations from the schema does not delete the client code somebody already generated from an earlier copy of it, which is why this section stays. If a generated SDK still offers one of these methods, delete the method rather than adding retry logic around a 404 that will never resolve.

Where the data lives now:

Retired pathUse instead
GET /api/v1/billing/currentGET /api/v1/billing/wallet for your balance and the usage recorded since the last settlement
GET /api/v1/billing/summaryGET /api/v1/billing/wallet, plus GET /api/v1/usage/current for the open period's usage totals
GET /api/v1/billing/invoicesGET /api/v1/billing/documents/invoices
GET /api/v1/billing/cyclesNothing. Billing cycles were retired as a concept: periods are settled into issued documents, which you read from /api/v1/billing/documents/invoices

Errors on a Streaming Request

A request sent with "stream": true can fail in two places, and the two look nothing alike. Handle both.

Before the stream opens

The failure is an ordinary HTTP error. The status is the mapped status, the body is the usual JSON envelope, and Content-Type is application/json rather than text/event-stream. A model whose upstream is down answers a streaming request with a plain 503 and Retry-After: 30, exactly as it answers a non-streaming one:

curl -sS -i -X POST https://your-gateway/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-oss-120b", "messages": [{"role": "user", "content": "hi"}], "stream": true}'
HTTP/1.1 503 Service Unavailable
retry-after: 30
content-type: application/json

{"error":{"message":"Model gpt-oss-120b is temporarily unavailable.","type":"server_error","param":null,"code":"upstream_unavailable"}}

Your SDK raises, and the ordinary error handling in this guide applies. The platform also refuses a 200 whose Content-Type is not text/event-stream instead of streaming a non-SSE body to you; that surfaces as the same 503.

After the stream opens

Once the first byte is on the wire the status line is spent: it already says 200 and cannot be taken back. The only way left to report a failure is in band, so the platform emits one terminal error event and then ends the stream:

data: {"type": "error", "error": {"message": "Model gpt-oss-120b is temporarily unavailable.", "type": "server_error", "param": null, "code": "upstream_unavailable"}}

data: [DONE]

Three things to get right:

  • It is a data: frame, not an SSE event: error frame. The discriminator is the "type": "error" key inside the JSON payload. A client that switches on the SSE event: field will never see this and will treat a failed stream as a complete one.
  • The payload carries the same error envelope a non-streaming call would have returned, alongside "type": "error". Read error.code from it exactly as you would from a normal error body.
  • [DONE] always follows, in the same write, so a client that stops at [DONE] leaves its read loop rather than hanging until a socket timeout. That also means [DONE] on its own is not proof of success: check whether an error event preceded it.

The HTTP status stays 200, so your SDK will not raise. If you accumulate a streamed response, treat any chunk carrying "type": "error" as a failed request rather than a partial success. The tokens delivered before it are real and are billed, but the completion is unfinished.

A client disconnect does not produce an error event; the stream simply ends.

Comprehensive Error Handler

Python

from openai import (
OpenAI,
APIError,
APIConnectionError,
RateLimitError,
AuthenticationError,
BadRequestError,
NotFoundError,
PermissionDeniedError,
UnprocessableEntityError
)
import time
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class APIClient:
def __init__(self, api_key, base_url):
self.client = OpenAI(api_key=api_key, base_url=base_url)
self.max_retries = 3

def chat(self, messages, **kwargs):
for attempt in range(self.max_retries):
try:
return self.client.chat.completions.create(
model=kwargs.get('model', 'your-chat-model'),
messages=messages,
**kwargs
)

except AuthenticationError as e:
logger.error(f"Authentication failed: {e.message}")
raise # Don't retry auth errors

except PermissionDeniedError as e:
logger.error(f"Permission denied: {e.message}")
raise # Don't retry permission errors

except NotFoundError as e:
logger.error(f"Resource not found: {e.message}")
raise # Don't retry 404s

except BadRequestError as e:
logger.error(f"Bad request: {e.message}")
raise # Don't retry bad requests

except UnprocessableEntityError as e:
logger.error(f"Validation error: {e.message}")
raise # Don't retry validation errors

except RateLimitError as e:
if attempt == self.max_retries - 1:
raise
wait = 2 ** attempt
logger.warning(f"Rate limited. Waiting {wait}s...")
time.sleep(wait)

except APIConnectionError as e:
if attempt == self.max_retries - 1:
raise
wait = 2 ** attempt
logger.warning(f"Connection error. Retrying in {wait}s...")
time.sleep(wait)

except APIError as e:
if attempt == self.max_retries - 1:
raise
if e.status_code >= 500:
wait = 2 ** attempt
logger.warning(f"Server error {e.status_code}. Retrying in {wait}s...")
time.sleep(wait)
else:
raise

Node.js

import OpenAI from 'openai';

class APIClient {
constructor(apiKey, baseURL) {
this.client = new OpenAI({ apiKey, baseURL });
this.maxRetries = 3;
}

async chat(messages, options = {}) {
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try {
return await this.client.chat.completions.create({
model: options.model || 'your-chat-model',
messages,
...options
});

} catch (error) {
// Non-retryable errors
if (error instanceof OpenAI.AuthenticationError) {
console.error('Authentication failed:', error.message);
throw error;
}

if (error instanceof OpenAI.PermissionDeniedError) {
console.error('Permission denied:', error.message);
throw error;
}

if (error instanceof OpenAI.NotFoundError) {
console.error('Not found:', error.message);
throw error;
}

if (error instanceof OpenAI.BadRequestError) {
console.error('Bad request:', error.message);
throw error;
}

// Retryable errors
if (error instanceof OpenAI.RateLimitError) {
if (attempt === this.maxRetries - 1) throw error;
const wait = Math.pow(2, attempt) * 1000;
console.warn(`Rate limited. Waiting ${wait}ms...`);
await this.sleep(wait);
continue;
}

if (error instanceof OpenAI.APIConnectionError) {
if (attempt === this.maxRetries - 1) throw error;
const wait = Math.pow(2, attempt) * 1000;
console.warn(`Connection error. Retrying in ${wait}ms...`);
await this.sleep(wait);
continue;
}

if (error instanceof OpenAI.APIError && error.status >= 500) {
if (attempt === this.maxRetries - 1) throw error;
const wait = Math.pow(2, attempt) * 1000;
console.warn(`Server error. Retrying in ${wait}ms...`);
await this.sleep(wait);
continue;
}

throw error;
}
}
}

sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

Error Response Parsing

def parse_error(error):
"""Extract useful information from API error."""
return {
'status_code': getattr(error, 'status_code', None),
'message': getattr(error, 'message', str(error)),
'type': type(error).__name__,
'code': getattr(error, 'code', None),
'param': getattr(error, 'param', None)
}

try:
response = client.chat.completions.create(...)
except APIError as e:
error_info = parse_error(e)
logger.error(f"API Error: {error_info}")

User-Friendly Error Messages

ERROR_MESSAGES = {
'authentication_error': 'Invalid API key. Please check your credentials.',
'rate_limit_error': 'Too many requests. Please try again in a moment.',
'insufficient_quota': 'A spending limit on this account stopped the request.',
'model_not_found': 'The requested model is not available.',
'context_length_exceeded': 'Your message is too long. Please shorten it.',
'server_error': 'Our servers are experiencing issues. Please try again later.'
}

def get_user_message(error):
"""Convert technical error to user-friendly message."""
error_type = getattr(error, 'type', type(error).__name__.lower())

if getattr(error, 'status_code', None) == 402:
# A money block. The platform's own message names the amounts and
# the control that lifts it, so prefer it over a generic string.
return getattr(error, 'message', None) or ERROR_MESSAGES['insufficient_quota']
if 'rate_limit' in error_type:
return ERROR_MESSAGES['rate_limit_error']
elif 'authentication' in error_type:
return ERROR_MESSAGES['authentication_error']
elif 'not_found' in error_type:
return ERROR_MESSAGES['model_not_found']
elif 'context_length' in str(error).lower():
return ERROR_MESSAGES['context_length_exceeded']
elif getattr(error, 'status_code', 0) >= 500:
return ERROR_MESSAGES['server_error']
else:
return 'An error occurred. Please try again.'

Circuit Breaker Pattern

from datetime import datetime, timedelta
from enum import Enum

class CircuitState(Enum):
CLOSED = 'closed'
OPEN = 'open'
HALF_OPEN = 'half_open'

class CircuitBreaker:
def __init__(self, failure_threshold=5, reset_timeout=60):
self.failure_threshold = failure_threshold
self.reset_timeout = reset_timeout
self.failures = 0
self.state = CircuitState.CLOSED
self.last_failure_time = None

def record_failure(self):
self.failures += 1
self.last_failure_time = datetime.now()

if self.failures >= self.failure_threshold:
self.state = CircuitState.OPEN

def record_success(self):
self.failures = 0
self.state = CircuitState.CLOSED

def can_execute(self):
if self.state == CircuitState.CLOSED:
return True

if self.state == CircuitState.OPEN:
if datetime.now() - self.last_failure_time > timedelta(seconds=self.reset_timeout):
self.state = CircuitState.HALF_OPEN
return True
return False

return True # HALF_OPEN allows one request

# Usage
breaker = CircuitBreaker()

def make_request():
if not breaker.can_execute():
raise Exception("Circuit breaker is open")

try:
response = client.chat.completions.create(...)
breaker.record_success()
return response
except Exception as e:
breaker.record_failure()
raise

Logging Best Practices

import logging
import json

def log_api_error(error, request_data=None):
"""Log API error with context."""
log_data = {
'error_type': type(error).__name__,
'status_code': getattr(error, 'status_code', None),
'message': str(error),
'model': request_data.get('model') if request_data else None,
'timestamp': datetime.now().isoformat()
}

# Don't log sensitive data
if request_data:
log_data['message_count'] = len(request_data.get('messages', []))

logger.error(json.dumps(log_data))

Best Practices Summary

  1. Categorize errors: Retryable vs. non-retryable
  2. Use exponential backoff: For retryable errors
  3. Set retry limits: Prevent infinite loops
  4. Log comprehensively: Include context, exclude secrets
  5. Show user-friendly messages: Translate technical errors
  6. Implement circuit breakers: For cascading failure prevention
  7. Monitor error rates: Alert on anomalies