# Errors

Two refusal kinds ride HTTP status codes on REST and inside the tool
result on MCP. Nothing else fails silently.

## REST status codes

| status | body | when |
|---|---|---|
| 401 | `{"detail": "invalid api key"}` | bearer key malformed, unknown, or rotated out |
| 401 | `{"detail": "not authenticated"}` | session cookie missing, unknown, or user gone |
| 404 | `{"detail": "unknown method"}` | method name not in the api's method list |
| 404 | `{"detail": "record not found"}` | records id missing or foreign; deletes answer this too when the caller may not delete |
| 400 | `{"detail": "invalid cursor"}` | records list cursor is not an offset |
| 422 | validation error list | records id is not a UUID |
| 422 | validation error list | records create body misses `text` |
| 422 | validation error list | records list `limit` is not an integer |
| 422 | `{"detail": "field 'schema' must be a built-in schema name"}` | records create `schema` not built-in |
| 413 | `{"detail": "body too large"}` | records create `text` field over 64 KiB in UTF-8 bytes |
| 429 | `{"detail": "rate limited"}` | request rate over the limit; never billed |
| 429 | `{"detail": {"error": "balance empty", "upgrade_url": "/billing"}}` | the included pool is spent and the balance is zero; the link opens billing |
| 429 | `{"detail": "too many requests"}` | auth-surface throttle (`throttle()` in auth policy: login, register, reset); carries `Retry-After`, never billed |
| 502 | `{"detail": "model unavailable"}` | model surface only: model unreachable or at capacity; the call is refunded |

## Classify errors

`POST /v1/classify/{classifier_id}` adds its own rows on top of the
shared ones. It uses the same bearer keys and the same two 429 kinds
as the model surface.

| status | body | when |
|---|---|---|
| 404 | `{"detail": "unknown classifier"}` | classifier id missing or owned by another tenant |
| 410 | `{"detail": "classifier archived"}` | the classifier was archived |
| 409 | `{"detail": "classifier not published"}` | no ready version sits behind the endpoint |
| 422 | `{"detail": "field 'text' must be a non-empty string"}` | `text` missing, empty, or not a string |
| 413 | `{"detail": "text exceeds 64 KiB"}` | `text` over 64 KiB UTF-8 bytes |
| 502 | `{"detail": "model unavailable"}` | model service down; the debit is refunded |

There is no classifier-specific quota 429. Classify calls draw the
same tenant pool as every product: one included call each until the
pool is spent, then the wallet pays per call. When the pool is spent
and the balance is zero, the call answers the `balance empty` 429
from the shared table above. See [classifiers](/docs/classifiers).

A low-confidence answer is not an error: it is a 200 with
`label: null`, `status: low_confidence`, and the candidate labels.

## Records errors with a classifier id

`POST /records/create` with `classifier_id` routes through your
classifier. Routing failures answer before any store:

| status | body | when |
|---|---|---|
| 404 | `{"detail": "unknown classifier"}` | classifier id missing or foreign |
| 410 | `{"detail": "classifier archived"}` or `{"detail": "classifier not published"}` | archived, or nothing published to route through |
| 429 | `{"detail": {"error": "balance empty", "upgrade_url": "/billing"}}` | the pool is spent and the balance is zero |
| 502 | `{"detail": "model unavailable"}` | model service down; the call is refunded |

Without `classifier_id`, records create keeps its own rules: a down,
slow, or unconfigured classifier never fails the create, it stores a
fallback record with 200 and refunds to net zero.

Records routes always need the session cookie, so they return the
`not authenticated` 401. Reads and deletes (`GET /records/get`, `GET
/records/get/{id}`, `DELETE /records/delete/{id}`) ignore the bearer
key entirely and never return the `invalid api key` 401. Only `POST
/records/create` reads the bearer, to pick the billed tenant, so only
create can return `invalid api key` when a bad key is sent. Only
create can return the `balance empty` 429. The `rate limited` 429
fires on create only when a records bucket is configured.

An upstream model error passes through verbatim with its own status
code and body on the model surface only (`routers/model_api.py`,
`mcp.py`). The call still bills, because the model answered.
Records never passes through: a down, slow, unconfigured, or
bad-answer classifier stores a fallback record with 200 and refunds
to net zero.

## The two kinds of 429

`rate limited` carries headers:

| header | meaning |
|---|---|
| `Retry-After` | seconds to wait before the next call |
| `x-mivia-ratelimit-limit` | your current requests/second rate |
| `x-mivia-ratelimit-remaining` | calls left in this window |

`balance empty` carries none of those. It means money, not speed:
the month's included pool is spent and the balance is zero. Top up
and the same call goes through. See [pricing](/docs/pricing).

## Response headers on every billed call

| header | meaning |
|---|---|
| `x-mivia-debit` | the tier that billed you: `included` (the pool) or `paid` (the wallet) |
| `x-mivia-free-remaining` | included pool calls left this month; the name predates the pool, the meaning is pool-wide |

## MCP refusals

MCP never turns a business refusal into a protocol error. A refused
`tools/call` answers HTTP 200 with the refusal inside the result:

```json
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "isError": true,
    "content": [{"type": "text", "text": "rate limited"}]
  }
}
```

The text is one of `rate limited`, `balance empty; top up at
/billing`, or `model unavailable`. Agents should read `isError` and
retry or stop on the same rules as the REST table above.
