# Errors

> The error envelope, every status code with its type and codes, which errors to retry, refunds, and the SDK error classes.

Every error is a JSON body with a stable `type` and `code`. The `type` tells you the class of problem and maps one to one to the HTTP status. The `code` tells you the exact reason. Errors before admission never charge your balance, and server errors after admission are refunded.

## The error envelope

```json
{
  "error": {
    "type": "validation",
    "code": "state_path_not_found",
    "message": "Question 'spoed' refers to {{bericht.txt}}, which is not in the state.",
    "param": "questions.spoed.instructions",
    "request_id": "req_01M5CJXHG0M9S346Q3D25VT4F5"
  }
}
```

| Field | Always present | Meaning |
| --- | --- | --- |
| `type` | yes | The class of error. Stable, and one to one with the HTTP status. |
| `code` | yes | The machine-readable reason. Stable. New codes may be added within v1, so handle unknown codes by their `type`. |
| `message` | yes | English text for people. Do not parse it. |
| `param` | no | The input field that caused the error, as a dotted path, when one field did. |
| `request_id` | yes | The request id, the same as the `x-request-id` header. Quote it to support. |
| `balance_micro_cents` | 402 only | Your balance when the request arrived. |
| `required_micro_cents` | 402 only | What the request would have cost. |

## Status codes

| HTTP | `type` | Codes | Retry |
| --- | --- | --- | --- |
| 400 | `invalid_request` | `invalid_json`, `duplicate_key`, `unsupported_media_type`, `invalid_header` | No |
| 401 | `authentication` | `missing_api_key`, `invalid_api_key`, `revoked_api_key`, `expired_api_key`, `invalid_session`, `invalid_code`, `code_attempts_exceeded` | No |
| 402 | `insufficient_balance` | `insufficient_balance` | After a top-up |
| 403 | `permission` | `insufficient_scope`, `account_suspended`, `csrf_failed` | No |
| 404 | `not_found` | `model_not_found`, `model_retired`, `route_not_found`, `key_not_found` | No |
| 405 | `method_not_allowed` | `method_not_allowed` | No |
| 409 | `conflict` | `idempotency_key_reused`, `idempotency_in_progress` | `idempotency_in_progress` only, after `retry-after` |
| 413 | `too_large` | `body_too_large`, `state_too_long`, `question_too_long`, `request_too_long` | No |
| 422 | `validation` | `unknown_field`, `missing_field`, `invalid_type`, `invalid_value`, `field_not_allowed`, `invalid_question_id`, `too_many_questions`, `too_many_options`, `invalid_levels`, `invalid_min_confidence`, `duplicate_label`, `state_path_not_found`, `state_not_json`, `top_up_limit_exceeded` | No |
| 429 | `rate_limited` | `requests_per_minute`, `tokens_per_minute`, `concurrency`, `test_daily_quota`, `email_codes_per_hour` | Yes, after `retry-after` |
| 500 | `internal` | `internal_error` | Yes, with the same idempotency key |
| 503 | `unavailable` | `no_capacity`, `upstream_unavailable`, `billing_unavailable`, `maintenance` | Yes, after `retry-after` |

### Codes in detail

| Code | Status | When |
| --- | --- | --- |
| `invalid_json` | 400 | The body is empty, not UTF-8 or not JSON. This includes a `\u` escape that is half of a surrogate pair, such as `"\ud800"` without the low half that must follow it. A pair such as `"\ud83d\ude00"`, one emoji, is fine. |
| `duplicate_key` | 400 | An object in the body repeats a key, including a label repeated character for character in `options`. `param` is the JSON pointer of the repeated member, for example `/questions/afdeling/options/garantie`. |
| `unsupported_media_type` | 400 | A request with a body did not send `content-type: application/json`. |
| `invalid_header` | 400 | `idempotency-key` or `x-client-request-id` is empty, too long or not printable ASCII. `param` names the header. |
| `missing_api_key` | 401 | No `authorization` header. |
| `invalid_api_key` | 401 | The key is not a valid key. |
| `revoked_api_key` | 401 | The key was revoked. Create a new one in the console. |
| `expired_api_key` | 401 | The key passed its expiry date. |
| `invalid_session` | 401 | Console only: the session cookie is missing or no longer valid. |
| `invalid_code` | 401 | Console sign-in only: the code is wrong or has expired. Check the newest email, or ask for a new code. |
| `code_attempts_exceeded` | 401 | Console sign-in only: the code had 5 wrong tries and no longer works, even with the right digits. Ask for a new code. |
| `insufficient_balance` | 402 | The balance does not cover this request. Nothing was charged. |
| `insufficient_scope` | 403 | The key lacks the scope for this route, such as `usage:read`. |
| `account_suspended` | 403 | The account is suspended. |
| `csrf_failed` | 403 | Console only: the `x-dex-csrf` header is missing or wrong. |
| `model_not_found` | 404 | No model has this name. See `GET /v1/models`. |
| `model_retired` | 404 | This exact version has been retired. |
| `route_not_found` | 404 | No such route. |
| `key_not_found` | 404 | No key with this id in your account. |
| `method_not_allowed` | 405 | The path exists, but not with this method, for example `GET /v1/decide`. The `allow` header lists the methods it has. |
| `idempotency_key_reused` | 409 | This idempotency key was used in the last 24 hours with a different request body. |
| `idempotency_in_progress` | 409 | The first request with this idempotency key is still running. |
| `body_too_large` | 413 | The body is over 256 KiB. |
| `state_too_long` | 413 | The rendered state is over 16,384 tokens. |
| `question_too_long` | 413 | A rendered question is over 4,096 tokens. |
| `request_too_long` | 413 | The request is over 16,384 billable tokens in total, or its questions together are over 4,096 (then `param` is `questions`). |
| `unknown_field` | 422 | A field the API does not define, for example the typo `questons`. |
| `field_not_allowed` | 422 | A field of another question type: `options` outside a pick, `levels` outside a rate. |
| `missing_field` | 422 | A required field is absent, for example `questions`, a question's `type` or `instructions`, or the `start` query parameter of `getUsage`. |
| `invalid_type` | 422 | A value has the wrong JSON type: a number for `instructions`, an array for `questions`, a number for the state. |
| `invalid_value` | 422 | The type is right but the value is not allowed: an empty or over-long string (the state, `instructions`, `criteria`, a label, a description), a state, `instructions`, `criteria` or description of only whitespace, an empty `questions` or `options` object, an empty state object or array, a string outside its allowed values or pattern (`model`, `fallback`, an unknown question `type`, a label or a rate level with spaces at either end), a JSON state nested deeper than 32 levels or with an empty or over-long key. For a level, `param` names the level, such as `questions.spoed.levels.2`. On `getUsage`: a malformed date, `granularity` or `key_id`, an `end` not after `start`, or a range longer than the granularity allows. |
| `invalid_question_id` | 422 | A question id does not match `^[A-Za-z][A-Za-z0-9_-]{0,63}$`. |
| `too_many_questions` | 422 | More than 32 questions. |
| `too_many_options` | 422 | A pick has more than 255 options. |
| `invalid_levels` | 422 | `levels` is not 2 to 10 unique strings of 1 to 200 characters. Levels must differ by more than case or Unicode normalization: `Low` and `low` count as the same level. |
| `invalid_min_confidence` | 422 | `min_confidence` is not a number from 0 to 1. |
| `duplicate_label` | 422 | Two pick labels differ only in case or Unicode normalization, such as `Billing` and `billing`, or a precomposed and a combining `é`. A label repeated character for character is `duplicate_key` (400). |
| `state_path_not_found` | 422 | A `{{path}}` reference names a field the state does not have. |
| `state_not_json` | 422 | A `{{path}}` reference appears while the state is a string. |
| `top_up_limit_exceeded` | 422 | Console only: a top-up would take a new account past EUR 500 in its first 30 days. |
| `requests_per_minute` | 429 | The requests-per-minute limit is used up. |
| `tokens_per_minute` | 429 | The billable-tokens-per-minute limit is used up. |
| `concurrency` | 429 | Too many requests are in flight at once. |
| `test_daily_quota` | 429 | The account used its free test tokens for today. `retry-after` is the time until they reset at 00:00 UTC. The SDKs do not retry it. |
| `email_codes_per_hour` | 429 | Console sign-in only: this address was sent 5 codes in the last hour, or too many codes were asked for from one network. `retry-after` is the real wait, up to an hour. |
| `internal_error` | 500 | Something went wrong on our side. Any charge was refunded. |
| `no_capacity` | 503 | No GPU capacity, and the fallback is not allowed or not available for this request. |
| `upstream_unavailable` | 503 | The GPU path and the fallback both failed. |
| `billing_unavailable` | 503 | The balance store could not be reached. |
| `maintenance` | 503 | The service is in maintenance. |

## Which error comes first

Dex checks a request in this order and stops at the first failure:

1. Authentication (401), then scope (403).
2. Body size (413 `body_too_large`), content type (400 `unsupported_media_type`), then JSON parsing (400 `invalid_json`).
3. Duplicate keys (400 `duplicate_key`), then the schema (422, with a code per rule).
4. The model name (404).
5. Field references, and label and level uniqueness ignoring case (422).
6. Rendering and token limits (413).
7. Idempotency (409).
8. Rate limits and quotas (429).
9. Balance (402).
10. Dispatch to a serving path (503 or 500).

Steps 1 to 9 never charge your balance.

When a body breaks several schema rules at once, the most specific code wins, in this order: `unknown_field`, `field_not_allowed`, `missing_field`, `invalid_question_id`, `too_many_questions`, `too_many_options`, `invalid_levels`, `invalid_min_confidence`, `invalid_value`, `invalid_type`. For a 422, `param` is a dotted path into the body, such as `questions.spoed.instructions`, or the name of a query parameter. For `duplicate_key`, `param` is a JSON pointer.

New codes can appear within v1 under an existing `type`. Act on the `type`, and treat an unknown `code` like the other codes of its type.

## Retrying

- **429, 503 and 409 `idempotency_in_progress`** always carry a `retry-after` header in whole seconds, from 1 to 60. Wait that long, then retry. Two codes wait longer: `test_daily_quota` gives the time until 00:00 UTC, and the console's sign-in code limit gives the real wait, up to 3,600 seconds.
- **500** is safe to retry with the same idempotency key.
- **402** succeeds after you top up.
- **Every other 4xx** will fail again. Fix the request instead.
- **Send an `idempotency-key`** on `POST /v1/decide` so a retry can never charge twice. See [Idempotency](/docs/reference/idempotency/).
- **Back off** when there is no `retry-after`, for example after a network error. The SDKs retry 429 (but not `test_daily_quota`), 500, 502, 503, 504, 409 `idempotency_in_progress`, timeouts and network errors, with full-jitter exponential backoff from a 250 ms base, at most 3 retries.
- **Watch for duplicate keys** if you build JSON by hand or merge objects. A repeated key is a 400 `duplicate_key`, never silently the last value.

## Refunds

- Errors before admission (all 4xx) never charge. A 402 means nothing was charged.
- A request that fails with 500 or 503 after the charge is refunded in full before the response is sent. The tokens it took from your tokens-per-minute limit and, for a test key, from the daily test quota are given back too.
- A replay of a completed request with the same idempotency key is never charged. It returns the original response, including the original `usage`.

## SDK error classes

Until the SDKs are on PyPI and npm, install them from [Downloads](/docs/reference/sdks/#downloads).

Each SDK raises one class per error `type`. The class comes from `error.type` in the body, or from the HTTP status when the body cannot be read.

| `type` | HTTP | Class |
| --- | --- | --- |
| `invalid_request` | 400 | `InvalidRequestError` |
| `authentication` | 401 | `AuthenticationError` |
| `insufficient_balance` | 402 | `InsufficientBalanceError`, with `balance_micro_cents` and `required_micro_cents` |
| `permission` | 403 | `PermissionDeniedError` |
| `not_found` | 404 | `NotFoundError` |
| `conflict` | 409 | `ConflictError` |
| `too_large` | 413 | `TooLargeError` |
| `validation` | 422 | `RequestValidationError` |
| `rate_limited` | 429 | `RateLimitError` |
| `internal` | 500 | `InternalServerError` |
| `unavailable` | 503 | `UnavailableError` |

All of these extend `APIStatusError`, which carries `status`, `type`, `code`, `message`, `param`, `request_id`, `retry_after`, `idempotency_key` and the response `headers`. An unknown `type` raises a plain `APIStatusError`, and so does a 405 `method_not_allowed`, which no SDK call can cause.

Errors without an HTTP answer have their own classes: `APIConnectionError` (with `APITimeoutError` under it), `ResponseParseError` for a body that is not valid JSON, and `ConfigurationError`, for example when no API key is set. The TypeScript SDK adds `AbortedError` when your `AbortSignal` stops a call. Every class extends `DexError`.

```python tab="Python"
from thinqit_dex import RequestValidationError, InsufficientBalanceError

try:
    decision = client.decide(state=state, questions=questions)
except RequestValidationError as err:
    print(err.code, err.param, err.request_id)
except InsufficientBalanceError as err:
    print(err.required_micro_cents - err.balance_micro_cents, "micro-cents short")
```

```ts tab="TypeScript"
import { RequestValidationError, InsufficientBalanceError } from "@thinqit/dex";

try {
  const decision = await client.decide({ state, questions });
} catch (err) {
  if (err instanceof RequestValidationError) {
    console.log(err.code, err.param);
  } else if (err instanceof InsufficientBalanceError) {
    console.log("Top up in the console:", err.message);
  } else {
    throw err;
  }
}
```

## Getting help

Every response and every error has a request id: `req_` followed by 26 characters. Quote it when you contact support. Support asks for the request id, never for the content of your request.
