# Errors

> The error body shapes of the native, OpenAI and Anthropic routes, every status and code, and what to do about each one.

Errors are ordinary HTTP statuses with a JSON body. Branch on the **status**, then on the **`code`** when there is one, and show the human `message` to people but never parse it: messages are written for readers and may be reworded. Which body shape you get depends on the route family, so this page starts there.

## Error body shapes

| Routes | Shape | Example |
| --- | --- | --- |
| Native `/v1/*`, `/developer/*`, `/light/*`, `/engine/*` | `{ "error": string, "code"?: string, ...details }` | `{ "error": "This API key does not have the \"knowledge\" scope.", "code": "scope_denied" }` |
| OpenAI dialect: `/v1/chat/completions`, `/v1/proofs/*` | `{ "error": { "message", "type", "code", "param" } }` | `{ "error": { "message": "Insufficient balance", "type": "insufficient_quota", "code": "insufficient_quota", "param": null } }` |
| Anthropic dialect: `/v1/messages` | `{ "type": "error", "error": { "type", "message" } }` | `{ "type": "error", "error": { "type": "rate_limit_error", "message": "..." } }` |

> **INFO: Authentication errors are always native**
> A failed key check happens before any route runs, so a 401, a `scope_denied` 403, a key-level 429 or an `API_KEY_BUDGET_EXCEEDED` 402 uses the native shape `{ "error": "...", "code": "..." }` even on `/v1/chat/completions` and `/v1/messages`. Handle both shapes in a shared error parser: read `body.error` as a string, or `body.error.message` as an object.

**A parser that handles all three shapes**

_TypeScript_

```typescript
type ApiError = { status: number; code?: string; message: string; retryAfter?: number }

async function parseError(res: Response): Promise<ApiError> {
  const body: any = await res.json().catch(() => ({}))
  const err = body?.error
  const message = typeof err === 'string' ? err : err?.message ?? res.statusText
  const code = body?.code ?? (typeof err === 'object' ? err?.code ?? err?.type : undefined)
  const retry = Number(res.headers.get('retry-after'))
  return { status: res.status, code, message, retryAfter: retry > 0 ? retry : undefined }
}
```

_Python_

```python
def parse_error(res):
    try:
        body = res.json()
    except ValueError:
        body = {}
    err = body.get("error")
    if isinstance(err, dict):
        message = err.get("message", res.reason)
        code = body.get("code") or err.get("code") or err.get("type")
    else:
        message = err or res.reason
        code = body.get("code")
    retry = res.headers.get("retry-after")
    return {"status": res.status_code, "code": code, "message": message,
            "retry_after": int(retry) if retry and retry.isdigit() else None}
```

## Catalogue

### 400 and 413: the request is wrong

| Status | Code | Meaning | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_request_error` (OpenAI and Anthropic) | `messages` missing or empty, or no `user` message. | Fix the payload. Retrying unchanged will fail again. |
| 400 | none | A native route rejected the body: a required field is missing, a value has the wrong type, `scopes` is not a list of known scopes, a limit is not a positive whole number, `Collection not found`, `file is required (multipart/form-data)`. | Read `error`; it names the field. |
| 400 | `text_required` | `POST /v1/verify` was sent an empty `text`. | Send the text to check. |
| 400 | `invalid_passages` | `passages` on `/v1/verify` is malformed (each needs `source` and `content`). | See [Le Vérificateur](https://dev.subsidia.protypa.fr/docs/verify.md). |
| 400 | `ambiguous_scope` | `/v1/verify` received both `passages` and `collectionIds`. | Send one or the other. |
| 413 | `text_too_large` | The text sent to `/v1/verify` exceeds the maximum. | Split the text and verify the parts. |
| 413 | none | An uploaded file is over 25 MB. | Split or compress the file. |

### 401 and 403: who you are, what you may do

| Status | Code | Meaning | What to do |
| --- | --- | --- | --- |
| 401 | none | `API key required...`, `Invalid or revoked API key`, `API key expired`, or `User not found`. | Do not retry. Check the key, create a new one if needed. See [Authentication](https://dev.subsidia.protypa.fr/docs/authentication.md). |
| 403 | `scope_denied` | The key does not carry the scope the route needs, or the route has no scope. | Add the scope. The message names it. |
| 403 | `read_only_key` | A read-only key tried to change something. | Use a key without `readonly` for writes. |
| 403 | `client_suspended` | The workspace is suspended by the integrator that manages it. | Contact that integrator. Keys keep working once access is restored. |
| 403 | `provider_unpaid` | The integrator behind this client workspace has an overdue invoice. | Contact your provider. |

### 402: a limit on what you have bought

A 402 never means "retry". It means a quota, a plan or a ceiling must change first. Nothing is consumed when a call is refused with a 402. See [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md) for what each limit measures.

| Status | Code | Extra fields | Meaning | What to do |
| --- | --- | --- | --- | --- |
| 402 | `insufficient_quota` | none | OpenAI dialect only: the workspace has no questions left for this call. | Add a question pack or move to a larger plan. |
| 402 | `QUESTION_LIMIT` | `required`, `available` | Native routes: fewer questions left than the request needs. `required` and `available` are counted in questions. | Add a pack or upgrade. |
| 402 | `SUBSCRIPTION_INACTIVE` | none | The trial is over or the subscription is no longer active. | Choose a plan in the console. |
| 402 | `PLAN_UPGRADE_REQUIRED` | `feature`, `requiredPlan` | The route belongs to a plan feature your plan does not include (for example the Registre needs the proof journal). | Upgrade to `requiredPlan`. |
| 402 | `DOCUMENT_LIMIT` | `limit`, `used` | The workspace reached its document volume. | Delete documents, add users, or upgrade. |
| 402 | `SEAT_LIMIT` | `seats` | The subscription covers fewer users than you tried to add. | Add a seat to the subscription. |
| 402 | `API_KEY_BUDGET_EXCEEDED` | none | This key reached its own monthly question ceiling, set below the workspace allowance. | Raise the key ceiling with `PATCH /developer/api-keys/:id`, or wait for the next month. |

### 404, 429, 5xx

| Status | Code | Meaning | What to do |
| --- | --- | --- | --- |
| 404 | none | The object does not exist in this workspace. Another workspace's object looks identical. | Check the id and the key's workspace. |
| 404 | `model_not_found` (OpenAI), `not_found_error` (Anthropic) | The `agent:<slug>` address matches no agent. | List addresses with `GET /v1/models`. |
| 404 | `conversation_not_found` | `x-pulse-conversation` names an unknown conversation. | Omit the header to start a new one. |
| 404 | `proof_not_found` | Unknown certificate id, or one from another workspace. | Use the id exactly as returned in `x-pulse-proof`. |
| 429 | `rate_limited` | The key exceeded its requests per minute. Carries `retry-after`. | Wait that many seconds, then retry. |
| 429 | `rate_limit_error` (Anthropic) | On `/v1/messages` a spent question allowance is a 429, not a 402: the Anthropic dialect has no payment status. | Treat as a quota error, not a transient one: retrying will not help until questions are added. |
| 429 | none | A route-level limit (for example 60 per minute on the Gateway and Cortex query routes, 30 on file upload). Produced by the rate limiter, so its body is not one of the shapes above. | Branch on the status and `retry-after`, not on the body. |
| 500 | `server_error` (OpenAI), `api_error` (Anthropic) | The call failed inside Subsidia or at the model provider. | Retry with backoff, at most three times. Persisting? Report the `x-pulse-proof` id if one was returned. |
| 501 | `address_not_supported` (OpenAI), `invalid_request_error` (Anthropic) | A reserved `debate:` address was used on the Gateway. | Use the conversations API for debates. |

## Retrying safely

| Status | Retry? | How |
| --- | --- | --- |
| 400, 401, 403, 404, 413, 501 | No | The request or the key must change first. |
| 402 | No | Not until a quota, plan or ceiling changes. Surface it to a human. |
| 429 with `retry-after` | Yes | Sleep for `retry-after` seconds (at most 60: the window is one minute), then resend. |
| 429 on `/v1/messages`, no `retry-after` | No | Probably a spent allowance. Check [Rate limits and quotas](https://dev.subsidia.protypa.fr/docs/rate-limits.md). |
| 500, network error | Yes | Exponential backoff with jitter (1 s, 2 s, 4 s), three attempts, then give up. |

**Retry loop**

_TypeScript_

```typescript
async function call(url: string, init: RequestInit, attempts = 3): Promise<Response> {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch(url, init)
    if (res.ok) return res
    if (res.status === 429 && res.headers.get('retry-after')) {
      await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after')) * 1000))
      continue
    }
    if (res.status >= 500 && i < attempts - 1) {
      await new Promise((r) => setTimeout(r, 2 ** i * 1000 + Math.random() * 250))
      continue
    }
    return res // 4xx: the caller decides
  }
  throw new Error('unreachable')
}
```

_Python_

```python
import random, time, requests

def call(method, url, attempts=3, **kwargs):
    for i in range(attempts):
        res = requests.request(method, url, **kwargs)
        if res.ok:
            return res
        retry = res.headers.get("retry-after")
        if res.status_code == 429 and retry:
            time.sleep(int(retry))
            continue
        if res.status_code >= 500 and i < attempts - 1:
            time.sleep(2 ** i + random.random() * 0.25)
            continue
        return res  # 4xx: the caller decides
    return res
```

> **DANGER: Streaming errors arrive in-band**
> Once a Server-Sent Events stream has started, the HTTP status is already 200. A failure afterwards is sent as an event: `data: { "error": { ... } }` followed by `[DONE]` on the OpenAI dialect, and an `event: error` with `{ "type": "error", "error": { ... } }` on the Anthropic dialect. Always look for an `error` object in the events, not only at the status code. A quota error mid-stream on `/v1/messages` has type `rate_limit_error`.

## OpenAI and Anthropic dialects side by side

| Situation | `/v1/chat/completions` | `/v1/messages` |
| --- | --- | --- |
| Empty or missing `messages` | 400 `invalid_request_error` | 400 `invalid_request_error` |
| Question allowance spent | 402 `insufficient_quota` | 429 `rate_limit_error` |
| Unknown `agent:` address | 404 `model_not_found` | 404 `not_found_error` |
| Reserved `debate:` address | 501 `address_not_supported` | 501 `invalid_request_error` |
| Internal failure | 500 `server_error` | 500 `api_error` |
| Bad or missing key | 401, native shape | 401, native shape |

**Does a failed call cost me a question?**

A refusal before the model runs (400, 401, 402, 403, 404) consumes nothing. For a 500, report it with the proof id if one came back.

**How do I tell a transient 429 from a spent allowance?**

A transient 429 carries a `retry-after` header and, from a key limit, `code: "rate_limited"`. A spent allowance on `/v1/messages` has `error.type: "rate_limit_error"` and no `retry-after`.
