# Errors

When you call an SDK method, two things can happen:

1. **Success** — `{ data, meta? }` returned (typed).
2. **Throw** — the SDK throws the server's response body verbatim after
   the HTTP call returned a non-2xx status.

The SDK does **not** validate request bodies on the client. Validation is
server-authoritative: a malformed body is rejected by the platform and
surfaced to you as a thrown **422** body. There is no synchronous
client-side validation error to catch — every validation failure comes back
from the server in the standardized envelope below.

## The thrown value (post-HTTP)

When the platform returns a non-2xx status, **the SDK throws the parsed
response body verbatim** as the thrown value — not an `Error` subclass, just
the body object (decorated with an `_httpStatus` field so you can branch on
the status). Narrow it with a type guard:

```typescript
try {
  const { data } = await api.tools.create(
    tenantSlug, datalakeSlug, body,
  )
} catch (err) {
  if (
    typeof err === 'object' && err !== null &&
    'errors' in err && Array.isArray((err as any).errors)
  ) {
    const { errors } = err as {
      errors: Array<{
        source: { pointer: string }
        title?: string
        detail?: string
      }>
    }
    for (const e of errors) {
      // e.source.pointer is "/field_name" or "/embed/field"
    }
    return
  }
  throw err
}
```

The thrown body is typed as `AlveraApiError` (exported from the package root)
— the JSON:API `errors[]` envelope the platform returns on 4xx. **Both 422
and 409 carry it** (same shape, different status). A few endpoints declare the
older field-keyed `ErrorResponse` map (`{ errors: { field: string[] } }`) for a
500 instead.

## The error envelope shape (422 and 409)

The platform standardizes ALL 422 response bodies on a single shape —
regardless of which validation layer fired — and **409 Conflict bodies use the
identical shape** (see below for which failures are 409, not 422):

```json
{
  "errors": [
    {
      "source": { "pointer": "/<field_path>" },
      "title":  "Invalid value",
      "detail": "<specific reason this field failed>"
    },
    ...
  ]
}
```

- `errors` is always an ARRAY of error objects (never a field-keyed map).
- `source.pointer` is an RFC 6901 JSON Pointer (`/field_name` for top-level
  fields; `/nested/field` for embeds) naming the offending field on the request
  body.
- `detail` is the human-readable reason. `title` is a **machine-readable
  category, not a per-field summary**: the constant `"Invalid value"` for every
  validation error (422 and the 409 conflict cases), the status reason phrase
  (`"Conflict"`, `"Not Found"`, …) for status-only errors, and a stage code
  (`"transform_failed"`, `"template_body_missing"`, …) for `/run` pipeline
  errors. Pattern-match on `title` for dispatch; show `detail` to a human.
- Multiple errors may share the same `source.pointer` if more than one check
  fired on the same field.

## Two layers behind the standardized envelope

The platform validates a request in two layers; both surface through the
same envelope above:

**Layer 1 — OpenAPI cast (structural)**

Runs in the platform's request-validation layer before the request is
handled. Catches: missing required fields, wrong types, invalid enums,
unknown keys, format violations (UUID patterns, etc.). This is the *same*
OpenAPI schema the SDK's types are generated from, so a body that satisfies
the TypeScript types almost always clears layer 1.

**Layer 2 — server-side semantic validator (post-cast)**

Runs after the structural cast succeeds. Catches: uniqueness, cross-field
rules (e.g. `auth_method = "iam_role"` combined with conflicting credential
fields), foreign-key constraints, custom platform validations. Layer-2
errors are normalized into the same envelope shape as layer 1 — consumers
don't distinguish layers from the JSON.

**But layer 2 is not always a 422.** The envelope is identical; the *status*
depends on the failure. Ordinary field validation (`"can't be blank"`,
`"is invalid"`, a custom `add_error` rule) is **422**. A failure that collides
with the current state of the resource is **409 Conflict**: a **uniqueness
collision** (`unique_constraint` / `unsafe_validate_unique`), a **delete
blocked by dependants** (a foreign-key/assoc constraint firing on delete), or
an **action status updater refresh the updater's state refuses**: it is
halted, or a poll for it is already queued or running
(`action_status_updaters.md` §7). So do not assume every semantic conflict is
a tidy 422 — read `_httpStatus`, not just the body. Read `source.pointer`, fix
the field (or resolve the collision), resubmit.

## Concrete examples

**Layer 1 — missing required fields** (`workflows.create()` with an empty
body):

```json
{
  "errors": [
    { "source": { "pointer": "/name" },
      "detail": "Missing field: name" },
    { "source": { "pointer": "/dataset_type" },
      "detail": "Missing field: dataset_type" },
    { "source": { "pointer": "/tags" },
      "detail": "Missing field: tags" }
  ]
}
```

`tags` is the easy one to miss: it is required with no default, so
omitting it 422s even though an empty list is a perfectly ordinary
workflow. Send `tags: []` (see `workflows.md` §2).

**Layer 1 — invalid enum value** (`dataset_type: "nonexistent_type"`):

```json
{
  "errors": [
    { "source": { "pointer": "/dataset_type" },
      "detail": "Invalid value: not in enum" }
  ]
}
```

**Layer 2 — semantic rule → 422** (`delete()` on a default-flagged data
activation client). A custom `add_error` rule (not a uniqueness/FK constraint)
stays a **422**, unlike the uniqueness case below:

```json
{
  "errors": [
    { "source": { "pointer": "/is_default" },
      "detail": "Default data activation clients cannot be deleted" }
  ]
}
```

**Layer 2 — uniqueness → 409, not 422** (`invitations.create()` for an
already-invited email). Same `errors[]` envelope, but a uniqueness collision
comes back as a **409 Conflict** (`_httpStatus === 409`):

```json
{
  "errors": [
    { "source": { "pointer": "/email" },
      "title": "Invalid value",
      "detail": "is already invited" }
  ]
}
```

**State refusal → 409, no field** (`actionStatusUpdaters.refresh()` while a
poll for that updater is already queued). The refusal is about the resource,
not a field in your body, so `source.pointer` is empty and `title` is the
status phrase:

```json
{
  "errors": [
    { "source": { "pointer": "" },
      "title": "Conflict",
      "detail": "A poll for this updater is already queued, so this refresh was not. Retry once it finishes." }
  ]
}
```

A halted updater answers the same way with `Updater is halted: status is
<status>. Set status to active to resume polling.`, plus ` Last run error:
<text>` when one is recorded. Nothing was queued in either case.

## HTTP status codes (under the hood)

The SDK abstracts HTTP responses behind typed methods. Consumers mostly see
typed data or thrown bodies; the thrown body carries `_httpStatus`, and raw
status is also visible via the SDK's `--debug`-style interceptor. The
platform maps:

| Code | When it fires                                                  |
|------|----------------------------------------------------------------|
| 200  | `get()` / `list()` / `update()` / `delete()` successful        |
| 201  | `create()` successful                                          |
| 400  | Request malformed (rare; the SDK serializes before send)        |
| 401  | Missing or invalid Bearer / `x-api-key`                         |
| 403  | Authenticated but role lacks permission                         |
| 404  | Resource doesn't exist OR cross-tenant access (404, not 403)    |
| 409  | Uniqueness collision, delete blocked by dependants, URL/body id mismatch, or an updater `refresh()` refused (halted, or a poll already queued) — same `errors[]` envelope |
| 422  | Validation failed; envelope above is the thrown body            |
| 5xx  | Platform-internal failure; safe to retry with backoff           |

## Retry semantics

Treat 5xx as infra noise — retry with exponential backoff. Treat 422 (and the
409 conflict cases) as a real consumer-correctable error — fix the body, or
resolve the collision, then resubmit. **Never retry a 422 or 409 with the same
body** — the failure won't clear on its own.

One 409 does clear on its own: `actionStatusUpdaters.refresh()` refused because
a poll for that updater is already queued or running. Retry it once that poll
finishes (the updater's `last_run_at` advances). The halted refusal does not
clear on its own: set the updater's `status` back to `'active'` first.

## Mapping `source.pointer` back to your TypeScript type

A `source.pointer` like `/db_reader_user` maps directly to the
`db_reader_user` field on `DatalakeRequestWritable`. For
nested embeds, the pointer carries the embed path:
`/cloud_storage/access_key_id`.

For polymorphic embeds, check the discriminator field's pointer first — if
`/tool_body_type` (or `/cloud_storage_type`, etc.) is in the errors array,
fix that BEFORE chasing other fields on the embed. The wrong discriminator
value cascades into spurious required-list failures on the wrong branch's
fields. See `mutations.md` for the discriminator naming convention.

Each per-resource MD names its `<Resource>RequestWritable` TypeScript type —
the corpus's resource pages are the source of truth for which fields exist on
which embed.
