Errors & Debugging

Errors & Debugging

This page is the complete reference for failures on the Nexotao API: the shape of an error body, what each code means, which ones are worth retrying, and how to trace a single request after the fact.

Base URL: https://api.nexotao.com.

The error envelope

Every API error is returned as a single JSON object with the same shape, regardless of the endpoint.

{
  "error": "invalid api key",
  "code": "auth.api_key_invalid",
  "type": "api_key_invalid",
  "request_id": "8997bda3-65c4-45c3-ab38-c909f905fc4d",
  "retryable": false
}
FieldTypeMeaning
errorstringHuman-readable message. The wording may change — do not branch on it
codestringStable dotted identifier, e.g. auth.api_key_invalid. This is the contract — branch on this
typestringThe segment after the last dot of code (e.g. api_key_invalid). Kept for older client compatibility
request_idstringThe request id, identical to the X-Request-Id header
retryablebooltrue when retrying the same request is sensible
hintstringOptional. Present on some errors only, with actionable guidance

Two headers accompany every error response:

HeaderContents
X-Error-CodeSame as the code field. Readable without parsing the body
X-Request-IdSame as the request_id field

type is derived mechanically from code, so it never carries extra information. If you are writing a new client, use code (or the X-Error-Code header) and ignore type.

Reading the error code

curl -sS -D /tmp/h.txt https://api.nexotao.com/v1/chat/completions \
  -H "Authorization: Bearer sk-nexo-FAKE-EXAMPLE" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}]}'
 
grep -i '^x-error-code\|^x-request-id' /tmp/h.txt

request_id

Every request — successful or not — gets a unique request_id, sent in the X-Request-Id header and recorded server-side.

  • On error responses the same id also appears in the body as request_id.
  • Log it in your application. When reporting a problem, one request_id is far more useful than a screenshot of the error message.
  • For streaming requests, X-Request-Id is sent before the first stream byte, so it is still readable when a stream fails mid-flight.
curl -sS -D - -o /dev/null https://api.nexotao.com/v1/messages \
  -H "Authorization: Bearer sk-nexo-FAKE-EXAMPLE" \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-sonnet-4-6","max_tokens":16,
       "messages":[{"role":"user","content":"hi"}]}' \
  | grep -i x-request-id

Error code catalogue

Grouped by HTTP status. The Retry column is the retryable field in the body.

400 — Invalid request

codeWhen it happensWhat to doRetry
request.invalidBody unreadable, model missing or not a string, empty input on /v1/responses, model not available on that endpoint, or images sent to a model without vision supportFix the request bodyfalse
request.invalid_requestRequest shape rejected before it reaches a backend — e.g. body is not a JSON object, a user message has empty content, or tools is not an array of objects whose input_schema has a root typeFix the message/tool structurefalse
request.unsupported_featureThe requested feature is not served for that model on that endpoint — e.g. Claude structured outputs or web search outside /v1/messages, or tool use on a DeepSeek modelMove to a supporting endpoint/model; check hint if presentfalse
model.unknownmodel is not in the catalogueCheck the model id via GET /v1/models or Models & Pricingfalse
upstream.context_length_exceededThe prompt exceeds the model’s context windowReduce input or enable compaction — see Context Windowfalse
upstream.invalid_requestThe model backend rejected the request shapeCheck unusual parameters (e.g. sampling values out of range)false
upstream.unsupported_featureA parameter/feature is not supported by that model backendDrop the parameter or switch modelsfalse

401 — Authentication failed

codeWhen it happensWhat to doRetry
auth.api_key_missingNeither Authorization: Bearer nor x-api-key was sentSend an API key — see Authenticationfalse
auth.api_key_invalidThe key is not recognisedUse a valid key, or create one in the dashboardfalse
auth.api_key_revokedThe key existed but has been revokedCreate a new keyfalse
auth.unauthorizedDashboard session token missing or invalid (dashboard endpoints, not the API-key path)Sign in again in the dashboardfalse
⚠️

The three auth.* codes above will never succeed on retry with the same credential. A client that retries them automatically only burns its own RPM quota and slows down its own recovery.

402 — Balance

codeWhen it happensWhat to doRetry
billing.insufficient_balanceBalance is not enough to cover the estimated input plus a minimal answerTop up, then resendfalse

This check runs before the request is forwarded to a model, so a request rejected with 402 is never charged.

403 — Forbidden

codeWhen it happensWhat to doRetry
auth.account_deletedThe account owning the key is marked deletedContact supportfalse
auth.forbiddenAccess denied on a dashboard endpointContact supportfalse

404 / 405 — Routing

codeStatusWhen it happensWhat to doRetry
route.not_found404The path does not exist. This includes /v1/images/generations and /v1/audio/transcriptions, which are not servedCheck the endpoint list in the API Referencefalse
resource.not_found404The model id in GET /v1/models/{id} does not existCheck the model idfalse
usage.not_found404Unknown request_id on GET /usage/{request_id} — a dashboard-session endpoint, not reachable with an API keyVerify the id and that it belongs to the same accountfalse
request.method_not_allowed405Right path, wrong method (e.g. GET on /v1/messages)Use POSTfalse

413 — Body too large

codeWhen it happensWhat to doRetry
request.too_largeThe request body exceeds the size limitRun /compact or start a new session; in agent integrations this usually means the conversation history has grown too longfalse

429 — Rate limits and quotas

codeWhen it happensWhat to doRetry
rate_limit.rpm_exceededThe key’s requests-per-minute limit was exceededBack off and retrytrue
rate_limit.tpm_exceededThe key’s tokens-per-minute limit was exceededBack off, or shrink the prompt/max_tokenstrue
gateway.capacityThe Nexotao concurrency gate is full. Carries a short Retry-AfterRetry after Retry-Aftertrue
upstream.rate_limitedThe model backend throttled the call. Retry-After is forwarded when the backend sends oneRetry after Retry-After; check hint if presenttrue
quota.key_limit_exceededThe key’s spending limit was reached — a budget cap, not a rate windowRaise or adjust the key limit in the dashboard, or wait for the next periodfalse
⚠️

quota.key_limit_exceeded is a 429 whose retryable is false. It is the one 429 you must not retry automatically: waiting a few seconds does not give the budget back. For daily/monthly limits, Retry-After carries the seconds until the period rolls over; an all-time total limit never resets and carries no Retry-After.

On the API-key path, responses also carry rate observability headers when the relevant limit is active for that key:

HeaderMeaning
X-RateLimit-Limit / -Remaining / -ResetRequests-per-minute quota
X-RateLimit-Limit-Tokens / -Remaining-Tokens / -Reset-TokensTokens-per-minute quota
X-Key-Limit-Micro / X-Key-Usage-Micro / X-Key-Remaining-Micro / X-Key-Limit-PeriodPer-key spending limit, in micro-rupiah

5xx — Server-side failures

codeStatusWhen it happensWhat to doRetry
internal.error500An unexpected failureRetry with backofftrue
internal.panic500An unhandled failure; fully recorded server-sideRetry; report with the request_id if it repeatstrue
dependency.upstream_unavailable502The model backend failed, exhausted its attempts, or the connection dropped. Also the catch-all class for unrecognised backend failuresRetry with backoff; consider a different modeltrue
dependency.database_unavailable503The database is temporarily unreachable (authentication, model catalogue, balance, or usage lookups)Retry with backofftrue
dependency.upstream_unconfigured503No backend is configured for that modelUse another model; report if it persiststrue
billing.reconcile_failed503Billing bookkeeping failed after the model callRetry; no balance is deducted in this casetrue

Model backend responses are never passed through verbatim. The status, body, and backend identity are classified into one of the codes above with a fixed message, and the raw detail stays in server logs only. Do not build logic on the assumption that you will see the model provider’s original message.

In-band streaming errors

This is the easiest thing to miss in the whole API.

⚠️

For streaming requests, status === 200 is not a success signal. Once the gateway starts sending heartbeats, HTTP 200 has already been committed and cannot be taken back. Any failure after that point is delivered inside the stream as an error event. A client that only checks response.ok will read a failed request as an empty answer.

The error event’s wire shape depends on the endpoint:

A single data: chunk containing an error object, then data: [DONE].

data: {"error":{"message":"upstream service unavailable after retries; please retry","type":"server_error","code":"upstream_unavailable","request_id":"8997bda3-65c4-45c3-ab38-c909f905fc4d"}}

data: [DONE]

Detection: a data: chunk with a top-level error key (rather than choices).

The message is a fixed string chosen from the failure classification — for example “upstream capacity exhausted after waiting; please retry” for a throttle, “upstream timed out before starting the stream; please retry” for a timeout, or a specific message for an exceeded context window. When actionable guidance applies (e.g. a model-selection steer), it is appended to the message, because a stream has no room for a separate hint field.

⚠️

The same trap applies to non-streaming /v1/responses. That endpoint sends keepalives so long calls are not dropped by intermediaries, which means HTTP 200 can be committed before the outcome is known. If it then fails, you receive HTTP 200 with a JSON body of {"status":"failed","error":{...}}. Always check status in the /v1/responses body, not just the HTTP code.

Detection examples

import json, httpx
 
payload = {
    "model": "claude-sonnet-4-6",
    "messages": [{"role": "user", "content": "hi"}],
    "stream": True,
}
 
with httpx.stream(
    "POST", "https://api.nexotao.com/v1/chat/completions",
    headers={"Authorization": "Bearer sk-nexo-FAKE-EXAMPLE"},
    json=payload, timeout=None,
) as r:
    request_id = r.headers.get("x-request-id")
    r.raise_for_status()  # necessary, but NOT sufficient
 
    for line in r.iter_lines():
        if not line.startswith("data: "):
            continue
        data = line[6:]
        if data == "[DONE]":
            break
        chunk = json.loads(data)
        if "error" in chunk:                     # in-band failure
            err = chunk["error"]
            raise RuntimeError(
                f"{err.get('code')}: {err.get('message')} (request_id={request_id})"
            )
        # ... handle a normal chunk

Retry guidance

Check retryable first

Retry only when the error body says "retryable": true. Codes with retryable: false — the whole auth.* family, billing.insufficient_balance, model.unknown, route.not_found, request.*, quota.key_limit_exceeded — will never succeed with an identical request.

Honour Retry-After when present

If the response carries a Retry-After header, use it as the minimum delay instead of your own backoff schedule. Values coming from a backend are validated and clamped to a maximum of 3600 seconds; implausible values are discarded so the header is simply not sent.

Exponential backoff with jitter

Without Retry-After, double the delay on each attempt and add randomness so many clients do not stampede together after a single incident.

Cap the attempts

Four or five attempts is plenty. After that, return the error to the caller along with its request_id.

import json, random, time, httpx
 
NON_RETRYABLE_HTTP = {400, 401, 402, 403, 404, 405, 413}
 
def call_with_retry(client, url, headers, payload, max_attempts=5):
    delay = 1.0
    for attempt in range(max_attempts):
        r = client.post(url, headers=headers, json=payload)
        if r.status_code < 400:
            return r
 
        body = {}
        try:
            body = r.json()
        except json.JSONDecodeError:
            pass
 
        # The contract is `retryable`, not the HTTP status.
        if not body.get("retryable", r.status_code not in NON_RETRYABLE_HTTP):
            raise RuntimeError(
                f"{body.get('code')}: {body.get('error')} "
                f"(request_id={r.headers.get('x-request-id')})"
            )
 
        wait = delay
        if ra := r.headers.get("retry-after"):
            try:
                wait = max(wait, min(int(ra), 3600))
            except ValueError:
                pass
        time.sleep(wait + random.uniform(0, 0.5 * wait))   # jitter
        delay *= 2
 
    raise RuntimeError("attempts exhausted")
⚠️

Never wrap auth.api_key_invalid (or auth.api_key_missing / auth.api_key_revoked) in a retry loop. A wrong key is still wrong on the 100th attempt, and every attempt still counts against that key’s RPM quota.

Cost and post-hoc debugging

Non-streaming responses carry the actual cost in headers:

HeaderMeaning
X-Cost-RpCost of this request in Rupiah
X-Request-IdThe request id

On SSE (streaming) responses, X-Cost-Rp is deliberately omitted: headers must be written before the first byte, while the cost is only known once the stream ends.

So for a streaming call, the way to find out what it cost is:

Save the X-Request-Id from the response headers

It is sent before the first stream byte, so it is available even when the stream later fails. Log it alongside the call.

Look the request up on the dashboard Usage page

Find the matching request id there to see the reconciled figure — the model, token counts, and the exact charge that moved your balance.

⚠️

There is a GET /usage/{request_id} endpoint, but it is not part of the API-key surface. It authenticates with a dashboard session token, so sending Authorization: Bearer sk-nexo-... to it fails authentication with a 401 auth.unauthorized rather than returning usage. If you hold only an API key, use the dashboard Usage page.

Whichever route you use, the reconciled record contains the fields below. Two of them are worth knowing:

FieldMeaning
charge_microTotal charge in micro-rupiah (Rp 1 = 1,000,000 micro)
turnsNumber of charged upstream turns. Normally 1; greater than 1 when the gateway ran a server-side tool loop — which is what explains a charge larger than a single turn’s tokens would suggest

The record’s status tells you the final outcome, and in particular whether a failed request was charged at all:

statusMeaning
okSucceeded and charged
upstream_errorThe model backend failed; nothing charged
aborted_streamThe stream opened then dropped before its terminal event with no usable output; not charged
usage_missingThe backend replied 2xx but token counts could not be read; flagged and not charged
billing_errorThe answer was delivered but billing bookkeeping failed; recorded as 0
fallback_429 / fallback_oversizeZero-charge markers written when a request was rerouted to the next model in the chain

Troubleshooting

401 auth.api_key_missing — the auth header never arrived. With curl, check your quoting and line continuations (\). With an SDK, make sure base_url/baseURL points at https://api.nexotao.com/v1 and that the key is actually set (not an empty environment variable).

401 auth.api_key_invalid — the key is not recognised. Most common causes: a stray space or newline got copied along, the key belongs to a different environment, or it was deleted. Create a new key in the dashboard. See Authentication.

402 billing.insufficient_balance — the balance cannot cover the estimated input plus a minimal answer. Nexotao checks this before calling a model, so a rejected request costs nothing. Top up in the dashboard; see Billing & Pricing.

429 rate_limit.rpm_exceeded / rate_limit.tpm_exceeded — the key’s per-minute limit was exceeded. Lower concurrency, apply backoff, or shrink the prompt/max_tokens. The X-RateLimit-* headers show the remaining quota and seconds until the window rolls over.

429 quota.key_limit_exceeded — the key’s spending limit was reached. That is a budget cap you set yourself in the dashboard, not a rate throttle. Raise it or turn it off there.

400 model.unknown — the model id is misspelled or no longer active. Fetch the valid list:

curl -sS https://api.nexotao.com/v1/models \
  -H "Authorization: Bearer sk-nexo-FAKE-EXAMPLE"

404 route.not_found — the path is not served. The most frequent case is a generic OpenAI client calling /v1/images/generations or /v1/audio/transcriptions. Neither endpoint is available on Nexotao — see the served endpoint list in the API Reference.

A stream ends with no text and no error — this is almost always an in-band error your client did not parse. Revisit In-band streaming errors above, then look the request id up on the dashboard Usage page: a status of aborted_stream or upstream_error confirms the request failed and was not charged.

Still stuck? Collect the request_id, the code, and the time it happened, then contact support through the dashboard. Without a request_id, a single request is nearly impossible to find in the logs.