# Errors, and how to read them

Write the error reader once. Every non-2xx on every surface of the platform is the same
object, so a client needs one function to read a refusal, and it works against endpoints
nobody has written yet.

```sh title="quickstart.sh"
refused=$(curl_json -G "${auth[@]}" "$site/cms_pages" \
  --data-urlencode 'order_by=content desc' || true)

jq '.error | {code, status, message, details}' <<<"$refused"
```

Three parts, three audiences. **`status`** is the canonical code and the only part to
branch on — it is stable, while the HTTP status alone cannot tell an `ALREADY_EXISTS`
from a lost race. **`message`** is written for the person reading the log, in English, and
never carries internal detail; do not parse it and do not show it to an end user.
**`details`** is where a form finds what to underline: a `BAD_REQUEST` entry lists field
violations by their path in the request body you sent, and a `REQUEST_INFO` entry carries
the reference id to quote when you report a `500`.

Two codes surprise people, and both are deliberate. A resource you may not see is
`NOT_FOUND`, never `PERMISSION_DENIED` — the alternative would confirm that it exists. And
a `409` is two different things: `ALREADY_EXISTS` if the id or unique key is taken,
`ABORTED` if somebody wrote first and your [`If-Match`](/concepts/concurrency) is stale.

## 13. Errors

One shape, for every non-2xx, on every surface:

```json
{
  "error": {
    "code": 400,
    "status": "INVALID_ARGUMENT",
    "message": "url_key 'search' is reserved for the search page.",
    "details": [
      {
        "type": "BAD_REQUEST",
        "field_violations": [
          { "field": "url_key", "description": "reserved for the search page" }
        ]
      },
      { "type": "REQUEST_INFO", "request_id": "0f3a…" }
    ]
  }
}
```

- `code` is the HTTP status, `status` is the canonical code, and they MUST
  agree with the table below.
- `message` is for a developer reading a log — English, no trailing period
  required, and **never** raw internal detail: an exception `what()`, a
  repository error, a mail-send failure or a storage path never reaches a
  client.
- `details` is an array of typed objects, `type` drawn from a closed set
  (D6): `BAD_REQUEST` (with `field_violations`), `PRECONDITION_FAILURE`,
  `QUOTA_FAILURE`, `RESOURCE_INFO`, `RETRY_INFO`, `REQUEST_INFO`,
  `LOCALIZED_MESSAGE`. Adding a type is a standard change, not a local one.
- `field_violations[].field` is a **field path in the request body**
  (`items[2].url_key`), so a form can attach the message to an input without
  parsing prose.

| Canonical status | HTTP | Use for |
|---|---|---|
| `INVALID_ARGUMENT` | 400 | Malformed request, bad filter, unknown sortable field |
| `FAILED_PRECONDITION` | 400 | Request valid, system state forbids it |
| `OUT_OF_RANGE` | 400 | `skip` past the cap, `page_size` negative |
| `UNAUTHENTICATED` | 401 | Missing/expired/invalid token |
| `PERMISSION_DENIED` | 403 | Authenticated, not allowed — and existence is already known to the caller |
| `NOT_FOUND` | 404 | Absent, **or** present but invisible to this caller |
| `ALREADY_EXISTS` | 409 | Create with a taken id or unique key |
| `ABORTED` | 409 | `etag` mismatch ([§14](/concepts/concurrency)) |
| `RESOURCE_EXHAUSTED` | 429 | Rate limit, quota. MUST carry `RETRY_INFO` |
| `CANCELLED` | 499 | Client disconnected |
| `INTERNAL` | 500 | Bug. MUST carry `REQUEST_INFO` |
| `NOT_IMPLEMENTED` | 501 | Declared but unbuilt |
| `UNAVAILABLE` | 503 | Dependency down, shedding load |
| `DEADLINE_EXCEEDED` | 504 | Upstream timeout |
