# Errors

A failure from the client is an `OkmError`. Import it from `okmodel`. The field list, kinds, categories, and every OKM code live on [Errors](/docs/reference/errors). This page is what to do once you have one.

## Reading one

| Field | What it is for |
| --- | --- |
| `code` | An `OKM1xxx` guard, or the kind name when that kind has no number. Stable to branch on. |
| `kind` | Kind name (`unique`, `serialization`, `outcome_unknown`, …). Selects the category. |
| `category` | One of `input`, `conflict`, `not_found`, `forbidden`, `transient`, `internal`. Drives `toHttp` status. |
| `retryable` | Whether a *declared* retry may run this error again. See [Retry](#retry). |
| `fix` | `{ summary, suggestion? }`. What to change. `suggestion` is a nearer accepted name when the input was a typo. |
| `issues` | Validation checks (`path` + message key). Present on [OKM1200](/docs/reference/errors#okm1200) only. |

There is no `docs` field on the error. Open the matching row on [Errors](/docs/reference/errors), or `docs/errors/<code>.md` in the installed package.

```ts title="read.ts"
import { OkmError } from "okmodel";

export function describe(error: unknown) {
  const err = OkmError.from(error);
  return {
    code: err.code,
    kind: err.kind,
    category: err.category,
    retryable: err.retryable,
    fix: err.fix.summary,
    issues: err.issues,
  };
}
```

`summary` is the same string as `message`. `fields()` maps each named column to a reason and never includes row values. `log()` is the structured record for a log line.

## Branching

Branch on `code` or `kind`, not on `message`. Message text can name values the database sent and is not a contract.

```ts title="branch.ts"
import { OkmError } from "okmodel";

export function taken(error: unknown): boolean {
  return OkmError.is(error, "unique");
}

export function badLimit(error: unknown): boolean {
  return error instanceof OkmError && error.code === "OKM1101";
}

export function label(error: unknown): string {
  return OkmError.from(error).match({
    conflict: (err) => `conflict:${err.code}`,
    _: (err) => `other:${err.code}`,
  });
}

export function reasons(error: unknown) {
  return OkmError.from(error).fields();
}
```

A duplicate unique on `notes.title` prints:

```text title="unique.out"
{
  code: "unique",
  kind: "unique",
  category: "conflict",
  retryable: false,
  isUnique: true,
  fields: { title: "title" },
  matched: "conflict:unique",
  fix: "Change the conflicting value, or update the existing row."
}
```

`OkmError.is(error, kind)` and `OkmError.is(error, kind, table)` narrow the same way. `error.match({ input, conflict, _, … })` dispatches by category. `fields()` returns the column map once; later calls reuse it.

## toHttp()

`toHttp()` returns `{ status, body }` for an HTTP response. The body is `{ code, reason, fields }`. `reason` is the summary. Row values stay out.

| Category | Default status |
| --- | --- |
| `input` | 422 |
| `conflict` | 409 |
| `not_found` | 404 |
| `forbidden` | 403 |
| `transient` | 503 |
| `internal` | 500 |

```ts title="http.ts"
import { OkmError } from "okmodel";

export function respond(error: unknown) {
  return OkmError.from(error).toHttp();
}
```

The unique error above maps to status `409` and body `{ code: "unique", reason: "Unique violation on notes.title (title)", fields: { title: "title" } }`.

## Retry

`retryable` marks whether a *declared* retry may run the call again. It does not start a retry by itself.

| Rule | Behaviour |
| --- | --- |
| `tx({ retry })` | Retries only when the error kind is `serialization` or `deadlock`. Every other kind is thrown as it is. |
| [OKM1401](/docs/reference/errors#okm1401) | Commit was sent and the result never arrived (`outcome_unknown`). Never retried. |
| `cancelled` | Never retryable. The caller aborted the call. |
| Other `transient` kinds | `retryable` is true by default (`timeout`, `lock_timeout`, `unavailable`, …). Your own loop may use that flag. `tx({ retry })` still ignores them. |

### `tx({ retry })`

Two serializable transactions that collide retry until one wins. Full options are [Transactions](/docs/queries/transactions#retry).

```ts title="retry-tx.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";

const accounts = table("accounts", {
  id: t.text().primaryKey(),
  balance: t.integer(),
});
export const app = schema({ tables: [accounts] });

export async function retry(url: string): Promise<void> {
  await using db = connect(url, { schema: app });
  await db.accounts.insert([
    { id: "a", balance: 100 },
    { id: "b", balance: 100 },
  ]);
  const attempts = { a: 0, b: 0 };
  let waiting = 0;
  let open = (): void => undefined;
  const both = new Promise<void>((resolve) => {
    open = resolve;
  });
  let releaseB = (): void => undefined;
  const aCommitted = new Promise<void>((resolve) => {
    releaseB = resolve;
  });
  const run = (me: "a" | "b") =>
    db.tx({ isolation: "serializable", retry: 3 }, async (tx) => {
      attempts[me] += 1;
      const rows = await tx.accounts.find({ limit: 10 });
      const total = rows.reduce((sum, row) => sum + row.balance, 0);
      if (attempts[me] === 1) {
        waiting += 1;
        if (waiting === 2) open();
        await both;
      }
      if (me === "b" && attempts.b === 1) await aCommitted;
      await tx.accounts.update({ where: { id: me }, set: { balance: total } });
    });
  const a = run("a").finally(() => {
    releaseB();
  });
  await Promise.all([a, run("b")]);
  console.log(String(attempts.a + attempts.b));
  const rows = await db.accounts.find({ orderBy: { id: "asc" }, limit: 2 });
  console.log(JSON.stringify(rows.map((row) => ({ id: row.id, balance: row.balance }))));
}
```

```text title="retry-tx.out"
3
[{"id":"a","balance":200},{"id":"b","balance":300}]
```

### Custom loop on retryable

For kinds `tx({ retry })` ignores, read `retryable` yourself:

```ts title="retry-loop.ts"
import { OkmError } from "okmodel";

export async function withRetry<T>(run: () => Promise<T>, times = 3): Promise<T> {
  let last: unknown;
  for (let attempt = 1; attempt <= times; attempt += 1) {
    try {
      return await run();
    } catch (error) {
      last = error;
      const err = OkmError.from(error);
      console.log(`attempt ${String(attempt)}: retryable=${String(err.retryable)} kind=${err.kind}`);
      if (!err.retryable || attempt === times) throw error;
    }
  }
  throw last;
}
```

```text title="retry-loop.out"
attempt 1: retryable=true kind=serialization
attempt 2: retryable=true kind=serialization
```

### outcome_unknown

[OKM1401](/docs/reference/errors#okm1401) means the commit was sent and the result never arrived. Do not run the write again blindly. Give the write an idempotency key (a unique column you control), then `one` or `find` for that key. If the row is there, the write landed; if it is not, run the write once more.

## Write errors (OKM1120)

[OKM1120](/docs/reference/errors#okm1120) refuses an unknown field or option on a write. The message lists accepted names. On a tenant-scoped write those names omit the tenant key: the scope sets it, so it is not a field you pass. A close typo can still suggest the tenant key (`Did you mean \`tenantId\`?`), and the accepted-names list still leaves it out. Reads keep the tenant key in their accepted names. `toHttp().body.fields` is empty for this guard.

## Connect options

Set statuses, whether row values are kept, and the error logger once on `connect`.

| Field | Result |
| --- | --- |
| `errors.http` | Statuses for `OkmError.toHttp`. `toHttp()` uses them when the caller passes no argument. `toHttp(statuses)` replaces them for that call. A category absent from the map keeps the default. |
| `errors.includeValues` | `true` stores row values for `values()` and keeps bound parameters on the cause. Off unless set. A `.sensitive()` column stays redacted either way. Values are never logged. |
| `logger.error` | Receives `{ code, summary }` only. |

```ts title="db.ts"
import { connect } from "okmodel/pg/postgresjs";
import { schema, table, t } from "okmodel/pg";

const notes = table("notes", {
  id: t.identity(),
  title: t.text(),
});

const app = schema({ tables: [notes] });

export const db = connect(process.env.DATABASE_URL!, {
  schema: app,
  errors: {
    http: { conflict: 409 },
    includeValues: true,
  },
  logger: {
    error(entry) {
      void entry.code;
      void entry.summary;
    },
  },
});
```

<Callout type="info" title="Never logged">
The log record has no row values. `logger.error` receives `{ code, summary }`. The `toHttp` body is `{ code, reason, fields }`.
</Callout>

Index: [/llms.txt](/llms.txt)
