# Errors

Every error response uses the same envelope:

```json
{
  "error": "short_machine_code",
  "message": "Human-readable detail"
}
```

- `error` is a stable, machine-friendly code. Clients branch on this.
- `message` is free-form English text. Clients show to developers,
  not to end users.

## HTTP status mapping

| Status | When | `error` codes |
| ------ | ---- | ------------- |
| `200`  | Success with body | — |
| `304`  | `If-None-Match` matched current ETag | — |
| `400`  | Malformed request (bad path/body/query) | `bad_request`, `validation_failed` |
| `401`  | Missing `X-API-Key` | `missing_api_key` |
| `403`  | Invalid `X-API-Key` | `invalid_api_key` |
| `404`  | Resource not found OR outside client bulkhead | `not_found`, `client_not_found` |
| `422`  | AI output failed schema validation | `output_validation_failed` |
| `429`  | Rate limit OR AI budget exceeded | `rate_limited`, `budget_exceeded` |
| `500`  | Unhandled upstream or logic error | `internal_error` |
| `503`  | Upstream data missing (e.g. betting-patterns index) | `upstream_unavailable` |

## Never-invent-data errors

When a compute step can't produce a result because its critical input
is missing, the response is `503` with:

```json
{
  "error": "upstream_unavailable",
  "message": "betting-patterns index not loaded for market=SE"
}
```

This is how the [data policy](data-policy.md) surfaces at the
transport layer: rather than returning an empty or plausible-looking
payload, we fail fast and the frontend hides the widget.

## Budget errors

All AI generation routes (`/articles/generate`, auto-article
scheduler) enforce hard cost caps via `src/generate/costGuard.ts`.
Hitting the cap returns:

```json
{
  "error": "budget_exceeded",
  "message": "Daily cap of $5.00 reached",
  "budget": { "limit": 5.0, "spent": 5.02, "resetAt": "2026-04-12T00:00:00Z" }
}
```

The `budget` field is not part of the standard envelope — it's
specific to this error class.
