# Errors and limits

Every non-2xx response from every endpoint has the same shape:

```json
{"error": "insufficient_credits"}
```

Some errors add fields, and any call that was already metered also carries a
`request_id`. The `error` string is the stable contract — **branch on it, not on
the status code**, because several distinct conditions share a status.

## Every error code

### 400 — the request was malformed

| Code | Meaning |
| --- | --- |
| `invalid_json` | The body was not valid JSON. |
| *field-specific* | A field failed validation. The code names the field and the problem, for example `system_prompt_required`, `system_prompt_too_long`, `agent_config_must_be_object`, `label_required`. |
| `unsupported_content_type` | `uploads` only. The requested `content_type` is not on the allowlist; the response includes an `allowed` array of the accepted values. |

Nothing is charged for a `400`. Validation runs before metering.

### 401 — authentication failed

| Code | Meaning |
| --- | --- |
| `missing_token` | No `Authorization` header, or it was not a `Bearer` header. |
| `invalid_token` | The token did not start with `qlk_`, or no such key exists. |
| `key_revoked` | The key exists but is no longer active. |

`invalid_token` deliberately does not distinguish "malformed" from "unknown" —
that distinction would let someone probe which keys exist.

### 402 — out of credits

```json
{"error": "insufficient_credits"}
```

Your key's balance was lower than the endpoint's cost. The decrement is atomic
and conditional, so **nothing was charged and the endpoint did not run**.

Do not retry a `402`. Nothing about your key changes on its own, so a retry loop
will spin until it is topped up. Treat it as an alerting condition.

Unmetered endpoints (`uploads`, `/v1/keys`, `/mcp` itself) never return `402`.

### 403 — wrong scope

```json
{"error": "insufficient_scope", "required_scope": "admin"}
```

The key is valid but lacks a scope the endpoint requires. In practice you will
only see this from `POST /v1/keys`, which requires `admin`.

### 429 — rate limited

```json
{"error": "rate_limit_exceeded", "limit": 60, "window_seconds": 60, "retry_after": 23}
```

Limits are per key, per endpoint, over a fixed window. The response also sets
headers, which are usually easier to consume:

| Header | Meaning |
| --- | --- |
| `Retry-After` | Seconds to wait before retrying. |
| `X-RateLimit-Limit` | Calls permitted per window. |
| `X-RateLimit-Remaining` | Calls left in the window. Always `0` on a `429`. |
| `X-RateLimit-Reset` | Unix time at which the window resets. |

**The rate check runs before the credit decrement, so a `429` is never billed.**
Honour `Retry-After` rather than backing off on your own schedule — the window is
fixed rather than sliding, so waiting exactly that long is both sufficient and
optimal.

### 503 — temporarily unavailable

| Code | Meaning |
| --- | --- |
| `endpoint_disabled` | The endpoint has been administratively disabled, or is not deployed. |
| `rate_limiter_unavailable` | The rate limiter could not be reached while configured to fail closed. Includes `retry_after`. |

Both are safe to retry after a delay. Nothing is charged for either.

`endpoint_disabled` is checked first, before authentication — an endpoint that has
been taken down goes offline immediately rather than waiting on a key lookup.

### 500 — something broke on our side

```json
{"error": "internal_error", "request_id": "…"}
```

**Credits spent on the call are refunded automatically.** A `5xx` is never your
bill. If you have a `request_id`, quote it.

## Errors through MCP

A tool call over `/mcp` returns HTTP `200` with the failure inside the JSON-RPC
result:

```json
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "isError": true,
    "content": [{"type": "text", "text": "{\"error\":\"insufficient_credits\"}"}]
  }
}
```

Check `result.isError`, then parse `content[0].text` as JSON to get the same error
body documented above. Two failures are specific to the MCP layer:

- `Unknown tool: <name>` — the tool does not exist.
- `{"error": "endpoint_disabled", "tool": "<name>"}` — the tool exists but its
  endpoint is disabled or not deployed. You will see this if your client is
  holding a cached tool list; re-run `tools/list` to refresh it.

The MCP endpoint itself returns a transport-level `503` when the whole gateway is
disabled, rather than wrapping that in a JSON-RPC response.

## Summary: what to retry

| Status | Retry? |
| --- | --- |
| `400`, `401`, `403` | No. Fix the request or the key. |
| `402` | No. Alert — the key needs credits. |
| `429` | Yes, after `Retry-After`. |
| `503` | Yes, with backoff. |
| `500` | Yes, once or twice. Already refunded. |
