> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fiveninelabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> One envelope for every non-2xx response. Program against code — every code has a page here, and every doc_url resolves to one.

Every non-2xx response body is one shape:

```json theme={null}
{
  "error": {
    "type": "payment",
    "code": "insufficient_credit",
    "message": "This session asked to reserve $1.00 but the balance is $0.44.",
    "doc_url": "https://docs.fiveninelabs.com/errors/insufficient_credit",
    "request_id": "req_8fk2m1",
    "balance_usd": 0.44,
    "open_reserves_usd": 0,
    "top_up_url": "https://platform.fiveninelabs.com/billing"
  }
}
```

Guaranteed members: `type`, `code`, `message`, `doc_url`, `request_id`. Everything
else is code-specific context, documented on each code's page. `message` is prose for
humans and may change; **program against `code`**, which is stable API surface.
`doc_url` is always `https://docs.fiveninelabs.com/errors/<code>` — the code's own
page, one URL shape for every code.

Errors scoped to a run will additionally carry `console_url` — a deep link to that
run's dashboard detail view, the same evidence we debug with. (**Not yet emitted** —
don't depend on it until the [changelog](/changelog) announces it.)

`request_id` also arrives as the `fn-request-id` header on every response (success and
error) and appears in the dashboard's API log — include it in support requests.

## Types → status codes

| `type`            | HTTP        | Meaning                                                                                                                                                                             |
| ----------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_request` | 400         | Malformed body/params; `param` names the field                                                                                                                                      |
| `authentication`  | 401         | Key missing, unknown, or revoked                                                                                                                                                    |
| `permission`      | 403         | Key lacks scope, or a cap forbids this (caps are permissions, not rates)                                                                                                            |
| `payment`         | 402         | Balance can't cover the reservation                                                                                                                                                 |
| `not_found`       | 404         | Unknown id, or not owned by this account (indistinguishable by design)                                                                                                              |
| `proposal`        | 409         | Not a failure: the turn needs confirmation                                                                                                                                          |
| `conflict`        | 409         | Idempotency conflict, or acting on a busy/terminal session                                                                                                                          |
| `declined`        | 422         | Reserved — a refusal to spend is a `201` → \$0 `abstained` run today, [not a 422](/errors/declined_to_spend)                                                                        |
| `rate_limit`      | 429         | Rate or concurrency limit; carries `Retry-After`                                                                                                                                    |
| `api_error`       | 500/501/502 | Our fault. 500 `internal` (safe to retry with the same `Idempotency-Key`), 501 [`not_implemented`](/errors/not_implemented), 502 [`engine_unavailable`](/errors/engine_unavailable) |

Two request-shape divergences, stated honestly until fixed:

* **Malformed JSON is currently misreported.** A body that fails to parse (or exceeds
  the 1 MB body cap) is treated as empty, so you get `400 invalid_param` naming the
  first missing required field (usually `query`) — not a parse error. "query is
  required" while you're sending a query means: check your JSON and your body size.
  A dedicated parse-failure code is planned. Details on
  [invalid\_param](/errors/invalid_param).
* **A wrong HTTP method on a known path currently returns `404 not_found`**, not
  `405`. If a route you're sure exists 404s, check the verb before the path.

## The code catalog

| `code`                                                 | type             | Context members                                                            |
| ------------------------------------------------------ | ---------------- | -------------------------------------------------------------------------- |
| [`invalid_param`](/errors/invalid_param)               | invalid\_request | `param`                                                                    |
| [`invalid_key`](/errors/invalid_key)                   | authentication   | —                                                                          |
| [`key_revoked`](/errors/key_revoked)                   | authentication   | `revoked_at`                                                               |
| [`missing_scope`](/errors/missing_scope)               | permission       | `required_scope`                                                           |
| [`session_cap_exceeded`](/errors/session_cap_exceeded) | permission       | `cap_usd`, `requested_usd`                                                 |
| [`monthly_cap_exceeded`](/errors/monthly_cap_exceeded) | permission       | `cap_usd`, `spent_usd`                                                     |
| [`insufficient_credit`](/errors/insufficient_credit)   | payment          | `balance_usd`, `open_reserves_usd`, `top_up_url`                           |
| [`not_found`](/errors/not_found)                       | not\_found       | —                                                                          |
| [`proposal_required`](/errors/proposal_required)       | proposal         | `proposal`                                                                 |
| [`idempotency_conflict`](/errors/idempotency_conflict) | conflict         | `original_request_id`                                                      |
| [`session_terminal`](/errors/session_terminal)         | conflict         | `status`                                                                   |
| [`session_busy`](/errors/session_busy)                 | conflict         | `status`                                                                   |
| [`declined_to_spend`](/errors/declined_to_spend)       | declined         | `parsed` — reserved; not emitted today (declines are \$0 `abstained` runs) |
| [`rate_limited`](/errors/rate_limited)                 | rate\_limit      | `retry_after_s`                                                            |
| [`concurrency_cap`](/errors/concurrency_cap)           | rate\_limit      | `retry_after_s`, `limit`, `in_flight`                                      |
| [`internal`](/errors/internal)                         | api\_error       | —                                                                          |
| [`engine_unavailable`](/errors/engine_unavailable)     | api\_error       | — (HTTP 502; nothing billed, retry with backoff)                           |
| [`not_implemented`](/errors/not_implemented)           | api\_error       | — (HTTP 501; a planned route that hasn't shipped)                          |

The catalog is an **open enum**: new codes may appear (the additive rule). Handle
unknown codes by `type`.

## Declining to spend — a \$0 `abstained` run, not an HTTP error

When the parse decides spending on a query could not honestly deliver, production
behaves like this:

1. **Pre-spend signal:** `POST /v1/sessions/estimate` returns `would_run: false` with
   the parsed intent, what's missing, and the reason — free, before any commitment.
   This is the gate to build on.
2. **If you create anyway:** `POST /v1/sessions` returns **`201`** with a running
   session, and within seconds the run ends with an `abstain` event (`reason`
   attached) → status **`abstained`**, **\$0 settled**, reservation released.

An earlier draft of this contract described a synchronous `422 declined_to_spend`;
the live API does not emit it, and clients must not gate on it — see
[declined\_to\_spend](/errors/declined_to_spend). Rehearse the real ending with the
[`FN_TEST_DECLINE` sentinel](/guides/test-mode).

## Failures that are NOT errors

A run that starts and then ends badly is a **status**, not an HTTP error: `abstained`,
`error`, and `stopped` arrive as terminal events on the stream and as `status` on the
session object, with the ledger settled only for signed receipts and the remaining
reservation released. HTTP errors are reserved for "this request did not start a run."
Rehearse the run-level endings with the
[test-mode sentinels](/guides/test-mode) (`FN_TEST_ABSTAIN`, `FN_TEST_ERROR`,
`FN_TEST_SPEND_STOP`).

## Stream-terminal codes

When a run that started dies, its stream ends with a typed terminal `error` event —
`{ "phase": "error", "message": "…", "code": "…" }`. `message` is prose and may
change; `code` is the stable surface. These are **stream codes**, a vocabulary
distinct from the HTTP catalog above — they never appear as HTTP request errors.

| `code`                                                 | Meaning                                                                                          |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| [`model_unavailable`](/errors/model_unavailable)       | The engine's model call failed in transport. Retryable once the upstream recovers                |
| [`provider_unavailable`](/errors/provider_unavailable) | A non-model upstream the run needs failed or is unconfigured                                     |
| [`parse_failed`](/errors/parse_failed)                 | The model answered but produced nothing usable. Retrying may help; rephrasing usually helps more |
| [`aborted`](/errors/aborted)                           | The run was stopped — not a fault                                                                |
| [`internal`](/errors/internal)                         | Our fault, unclassified — the conservative default                                               |

Same additive open-enum rule as the HTTP catalog: new codes may appear; handle an
unknown code as `internal`-shaped.
