# Errors

> Failures on JSON ops/proxy return an envelope with `data: null` and an `error` object. HTTP status matches the failure class.

## Envelope error body

```json
{
  "data": null,
  "meta": {
    "schema": "v1",
    "request_id": "req_…",
    "outcome": "failed",
    "cache": "bypass",
    "latency_ms": { "hub": 0.3 }
  },
  "error": {
    "code": "rate_limited",
    "message": "rate limit exceeded (61/60 in 60s)",
    "retryable": true,
    "request_id": "req_…",
    "retry_after_ms": 1000
  }
}
```

When `retry_after_ms` is present the response also includes an HTTP `Retry-After` header (seconds, rounded up). Prefer `retry_after_ms` in SDKs; use the header for generic HTTP clients.

## Catalog

| Code | HTTP | Retry? | Meaning | What to do |
|---|---|---|---|---|
| `unauthorized` | 401 | no | Missing/invalid Bearer key | Check `Authorization`; republish LKG after mint/rotate |
| `forbidden_scope` | 403 | no | Key lacks connector/op/category scope | Widen scopes; republish LKG |
| `forbidden_role` | 403 | no | Key role cannot access this route | Use an app/admin key with the right role |
| `forbidden_ip` | 403 | no | Caller IP not on allowlist | Update key IP policy |
| `forbidden_plan` | 403 | no | Plan cannot use this thinking tier | Upgrade plan or pick a lower tier (`anybody` / `thinker` / `expert`) |
| `forbidden_model` | 403 | no | Model not allowed for this key | Request model allowlist change or pick an allowed model |
| `invalid_input` | 400 | no | Bad path/body/unknown op | Fix request; include `Idempotency-Key` when required |
| `invalid_request` | 400 | no | Malformed form / request parse | Fix encoding / content-type |
| `body_too_large` | 413 | no | Request body over limit | Shrink payload |
| `pii_blocked` | 400 | no | Payload failed PII policy | Redact fields or change data policy |
| `region_unavailable` | 403 | no | No egress for requested region | Pick a supported `SB-Region` / cell |
| `rate_limited` | 429 | yes | RPM/TPM, lane contention, or anomaly throttle | Wait `retry_after_ms` (default 1000); reuse `Idempotency-Key` |
| `budget_exceeded` | 402 | no\* | Soft/hard spend cap blocked admit | Raise tenant budget or wait; backoff uses `retry_after_ms` (default 60000) so clients do not hammer |
| `insufficient_credits` | 402 | no\* | Prepaid balance empty | Top up credits; same backoff hint as budget |
| `dedupe_unavailable` | 503 | yes | Hub idempotency store down | Retry with the **same** `Idempotency-Key` after `retry_after_ms` |
| `provider_down` | 502/503 | yes | Upstream/provider failure or empty ladder | Fail over cells; retry with same idempotency key |
| `approval_denied` | 403 | no | Operator denied the side-effect tool call | Do not retry; change the action or get approval |
| `approval_pending` | 200 (MCP) | poll | Side-effect tool awaiting approve/deny | Poll approval status; do not re-issue a new idempotency key |
| `unavailable` | 503 | no | Feature not loaded (e.g. thinking tiers) | Operator must load config; retry later |
| `internal` | 500 | sometimes | Unexpected hub error | Retry only if `retryable: true` |

\*Not auto-retryable for spend — raising budget/credits is required — but `retry_after_ms` + `Retry-After` are still set so clients back off cleanly.

## Retry rules for agents and SDKs

- Honor `error.retry_after_ms` / `Retry-After` on 429, budget, lane, and retryable 5xx.
- Retry **connection errors** and **503** across the cell endpoint list; reuse the same `Idempotency-Key`.
- Do **not** retry non-idempotent 4xx (`forbidden_scope`, `invalid_input`, `unauthorized`, `pii_blocked`, `forbidden_plan`, `forbidden_model`).
- `@switchboard/sdk` `SwitchboardClient` already fails over ordered endpoints on connection/503.

## MCP errors

MCP uses JSON-RPC error objects (`code` integer + `message`), not the HTTP envelope, on the MCP response body. HTTP auth failures may still return the envelope. Approval flows surface `approval_pending` / `approval_denied` in the MCP result / error string.
