# Errors

<Lead>

v4 uses the shared error rules (ERR-1 to ERR-6) unchanged and adds one: every
operation documents every error it can return (V4-ERR-1). An error is written for
the person or agent who has to act on it.

</Lead>

## <N n="1" />Problem JSON

Every `4xx` and `5xx` response is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)
problem, served as `application/problem+json` and described by the shared
`Problem` schema:

```json
{
  "type": "https://zup.fail/http-status/400",
  "title": "Bad Request",
  "status": 400,
  "detail": "The request body has 2 invalid fields.",
  "instance": "/v4/buckets/bckt_Y40ypGODCDP3S7iZxNwCAbIe/ai/pools/config_IM1F8Z4rUT6rDPhabvfnrt27/apps",
  "trace": {
    "timestamp": "2026-10-07T09:14:02.117Z",
    "requestId": "req_7f3k2mQx9LpA"
  },
  "errors": [
    { "location": "body.name", "message": "Must not be empty." },
    {
      "location": "body.budgetRules[0].limits[0].period",
      "message": "Must be one of hourly, daily, weekly, monthly."
    }
  ]
}
```

<Fields title="Anatomy of a problem">
<Field name="type">

`https://zup.fail/{slug}` for a specific Zuplo error, or
`https://zup.fail/http-status/{code}` for a generic one (ERR-3).

</Field>
<Field name="title">

The HTTP reason phrase, exactly. Never anything occurrence-specific (ERR-2).

</Field>
<Field name="status">

The HTTP status code.

</Field>
<Field name="detail">

Written for people. It can be shown in the portal or by an agent as is. If the UI
cannot show an API error, fix the API.

</Field>
<Field name="instance">

The path of the request.

</Field>
<Field name="trace.requestId">

On every error, and matches the `request-id` header (ROB-5).

</Field>
<Field name="errors">

**Every** invalid field on a validation failure, not only the first.

</Field>
</Fields>

<DoDont>
<Do title="Fixed title, specific detail">

```json
{
  "title": "Forbidden",
  "detail": "Editing an AI pool requires pool admin privileges."
}
```

</Do>
<Dont title="An occurrence-specific title">

```json
{
  "title": "You are not a pool admin",
  "detail": "Forbidden"
}
```

</Dont>
<Do title="A 500 says what to do">

```json
{
  "title": "Internal Server Error",
  "detail": "Something went wrong. Quote the request ID when you contact support."
}
```

</Do>
<Dont title="A 500 that leaks internals">

```json
{
  "detail": "duplicate key value violates unique constraint \"pools_name_key\""
}
```

No stack traces, upstream errors or SQL.

</Dont>
</DoDont>

## <N n="2" />Status codes

<Tiles cols={3}>
<Tile status="400" title="Invalid request">

Malformed JSON, schema violations, cross-field rules ("a warn limit must be below
the block limit"), unknown query parameters, and a body field that names something
that does not exist (a `poolId`). **All** validation errors are `400`.

</Tile>
<Tile status="401" title="Not authenticated">

Missing, invalid or expired credentials. Has `WWW-Authenticate`. Never used for
"not allowed".

</Tile>
<Tile status="403" title="Not allowed">

Authenticated but not allowed (an OpenFGA denial). The `detail` names the missing
role.

</Tile>
<Tile status="404" title="Not found">

The resource does not exist, **or** the caller has no access to the account it
belongs to (masking, ERR-5).

</Tile>
<Tile status="409" title="Conflict">

With the current state: name already taken, resource still in use, or
singleton already exists.

</Tile>
<Tile status="429" title="Rate limited">

Has `Retry-After`.

</Tile>
<Tile status="500" title="Unexpected">

No internal details. Quote `trace.requestId` to support.

</Tile>
</Tiles>

<DoDont>
<Do title="400 for validation">

```http
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
```

</Do>
<Dont title="422 for validation">

```http
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
```

One code to handle. v4 does not use `422`.

</Dont>
</DoDont>

## <N n="3" />Documented on every operation

<Rule id="V4-ERR-1" lint="v4-error-responses" shared="ERR-6">

Every operation documents the errors it can return, using the shared responses
in `#/components/responses`.

</Rule>

The shared rule (ERR-6) asks for `401` and `default`. v4 documents what the
operation can actually return, so agents and SDKs can plan error handling from the
spec:

| Operation | Documents |
| --- | --- |
| every operation | <Status code="401" /> <Status code="403" /> <Status code="429" /> `default` |
| the path has an ID | also <Status code="404" /> |
| there are query parameters or a body | also <Status code="400" /> |

Operations add other codes when they can occur. `DELETE` of a pool that still has
apps documents `409`, for example, and so does creating a provider assignment,
whose label must be unique.

<DoDont>
<Do title="Reference the shared responses">

```yaml
responses:
  "404":
    $ref: "#/components/responses/NotFound"
  "409":
    $ref: "#/components/responses/Conflict"
```

</Do>
<Dont title="Inline error responses">

```yaml
responses:
  "404":
    description: Not found
    content:
      application/json: …
```

</Dont>
</DoDont>

## <N n="4" />v4 error types

Generic conditions use `https://zup.fail/http-status/{code}`. Conditions that
clients act on get a registered slug in the shared catalog (`@zuplo/errors`):

<Fields>
<Field name="no-access-{action}" type="403">

`https://zup.fail/no-access-{action}`, for example `no-access-edit-ai-pool`.

</Field>
</Fields>

<EnforcedBy
  lint="v4-error-responses v4-document"
  checks="The required responses, application/problem+json, the Problem schema, WWW-Authenticate and Retry-After."
>

The wording of `title` and `detail`.

</EnforcedBy>
