> Complete content index: https://sirannon.sondelali.com/llms.txt

# Error codes

> Every code Sirannon can raise, grouped by the part that raises it, with what each one means, which ones are safe to retry, and how each maps to an HTTP status.

URL: https://sirannon.sondelali.com/docs/error-codes
Section: Operations
Version: 0.2 (@delali/sirannon-db@0.2.2, latest)

Every Sirannon error extends `SirannonError` and carries a machine-readable `code`. Match on the code. The message explains what happened to a human reading a log and changes between releases, so treat it as prose rather than an identifier. `catch-errors.ts` inserts an order with no total, which the column's NOT NULL constraint refuses.

```bash
mkdir -p data
```

```ts title="catch-errors.ts"

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

const db = await sirannon.open('shop', './data/shop.db')

await db.execute('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, total REAL NOT NULL)')

try {
  await db.execute('INSERT INTO orders (id) VALUES (?)', [1])
} catch (err) {
  if (err instanceof QueryError) {
    console.error(`SQL failed [${err.code}]: ${err.message}`, err.sql)
  }
}

await sirannon.close('shop')
```

```txt result open
SQL failed [QUERY_ERROR]: NOT NULL constraint failed: orders.total INSERT INTO orders (id) VALUES (?)
```

Some errors carry extra context alongside the code: `sql` on `QUERY_ERROR`, `table` and `rowId` on `CONFLICT_ERROR`, `version` on a migration error, `limit` and `retryAfterMs` on `WRITE_OVERLOADED`, `requestId` on `SYNC_ERROR`, and `serverVersion` on `MIGRATION_REQUIRED` and `SCHEMA_AHEAD`.

Over the network, an HTTP response and a WebSocket `error` message carry the same shape:

```json
{ "error": { "code": "ERROR_CODE", "message": "Human-readable description", "details": {} } }
```

A dash in a class column below means Sirannon raises the base `SirannonError` with that code, so match on `err.code` for those.

## Core engine

| Class | Code | When |
| --- | --- | --- |
| `DatabaseNotFoundError` | `DATABASE_NOT_FOUND` | The database ID is not registered and cannot be resolved. |
| `DatabaseAlreadyExistsError` | `DATABASE_ALREADY_EXISTS` | An ID already in use was registered again. |
| - | `DATABASE_CLOSED` | An operation ran against a closed database. |
| - | `DATABASE_OPEN_FAILED` | A database could not be opened. |
| `ReadOnlyError` | `READ_ONLY` | A write, or a [live query](/docs/live-queries), ran against a read-only database. |
| `QueryError` | `QUERY_ERROR` | SQLite failed to prepare or execute a statement. |
| `ForbiddenSqlError` | `FORBIDDEN_SQL` | A statement reached a `_sirannon` table, modified the `sqlite_` catalogue, or used `ATTACH`, `DETACH`, or `PRAGMA writable_schema`. |
| `TransactionError` | `TRANSACTION_ERROR` | A transaction could not commit, or it rolled back. |
| `HookDeniedError` | `HOOK_DENIED` | A before-hook rejected the operation. |
| `RequestDeniedError` | the code you supply | The [`authenticate` hook](/docs/security#identify-every-caller) refused the request with a status and code of its own. |
| `CDCError` | `CDC_ERROR` | The change-data-capture pipeline failed, or a statement cannot back a live query. |
| `BackupError` | `BACKUP_ERROR` | A [backup](/docs/backups) failed. |
| - | `BACKUP_UNSUPPORTED` | The driver provides no backup engine. |
| `ConnectionPoolError` | `CONNECTION_POOL_ERROR` | The pool is closed, exhausted, or misconfigured. |
| `MaxDatabasesError` | `MAX_DATABASES` | Opening a database would pass the configured cap. |
| `ExtensionError` | `EXTENSION_ERROR` | A native SQLite extension could not be loaded. |
| - | `INVALID_DRIVER` | Driver configuration failed validation. |
| - | `INVALID_SYNCHRONOUS` | An unknown `synchronous` level was supplied. |
| - | `INVALID_DURABILITY` | A [load](/docs/bulk-load) passed a `durability` other than `'off'` or `'normal'`. |
| - | `DURABILITY_RESTORE_FAILED` | The load committed, then the writer failed before durability was restored. |
| - | `SNAPSHOT_IN_PROGRESS` | A read or write ran while a [device-sync snapshot](/docs/device-sync-recovery) load was replacing the database. |
| - | `SHUTDOWN` | An operation ran after registry shutdown. |
| - | `SHUTDOWN_ERROR` | One or more databases failed to close during shutdown. |
| - | `LIFECYCLE_DISPOSED` | A resolve ran after the [lifecycle manager](/docs/hooks-metrics-and-lifecycle) was disposed. |
| - | `INTERNAL_SCHEMA_ERROR` | An internal-table identifier, column type, or default failed validation, or a schema version fell outside `PRAGMA user_version`. |

## Writer worker

The [writer worker](/docs/writer-worker) separates a rejected write from one whose outcome is unknown, which is the difference between retrying safely and reconciling first.

| Code | When | Retry? |
| --- | --- | --- |
| `WRITE_OVERLOADED` | More writes were pending than `maxPendingWrites` allows, or a queued write was shed when an earlier deadline expired. HTTP returns 503 with `Retry-After`. | Yes. The write never applied. |
| `WRITER_WORKER_TIMEOUT` | The writer gave no outcome within twice `writeTimeoutMs`. | Only after reconciling. The outcome is indeterminate. |
| `WRITER_WORKER_EXIT` | The writer crashed or exited, and every write in flight was rejected. | Only after reconciling. A write in flight may have committed before the crash. |
| `WRITER_WORKER_FATAL` | The writer passed its restart budget, so writes now fail permanently. | No. Restart the process. |
| `WRITER_WORKER_UNAVAILABLE` | A write arrived while no writer was available. | Yes. The write never reached the writer. |
| `WRITER_WORKER_CLOSED` | A write arrived after the writer closed, or the writer closed while it was in flight. | Reconcile first when the write was already in flight. |
| `WRITER_WORKER_POST_FAILED` | The host could not hand the operation to the writer. | Yes. The write never reached the writer. |
| `WRITER_WORKER_NO_PORT` | The writer entry point started outside a worker thread. | No. Fix the configuration. |
| `WRITER_WORKER_UNSUPPORTED` | `writerWorker` was enabled on a driver with no worker entry, so the database refuses to open. | No. Change the driver or the option. |
| `INVALID_WRITER_WORKER` | A `writerWorker` value is out of range. | No. Fix the configuration. |

## Migrations

| Code | When |
| --- | --- |
| `MIGRATION_ERROR` | A [migration](/docs/migrations) step failed while running. |
| `MIGRATION_VALIDATION_ERROR` | A migration definition failed validation. |
| `MIGRATION_DUPLICATE_VERSION` | Two migrations share a version. |
| `MIGRATION_NO_DOWN` | A rollback was requested for a migration carrying no `down`. |
| `MIGRATION_SOURCE_INVALID` | The registry migration source returned something other than a list. |
| `MIGRATION_CHECKSUM_MISMATCH` | An applied migration's stored checksum no longer matches its SQL. |
| `MIGRATION_BASELINE_GAP` | A history below a baseline lacks the bridging migrations. |
| `MIGRATION_CONCURRENT` | Two migration runs overlapped and could not be resolved. |
| `MIGRATION_ROLLBACK_ERROR` | A rollback step failed. |

## Server and requests

| Code | When |
| --- | --- |
| `INVALID_REQUEST` | The request body structure is invalid. |
| `INVALID_JSON` | The body or WebSocket message is not valid JSON. |
| `EMPTY_BODY` | The request body is empty. |
| `PAYLOAD_TOO_LARGE` | The body or message passed `maxBodyBytes`. |
| `INTERNAL_ERROR` | Something unexpected failed while handling the request. |
| `HOOK_ERROR` | The `authenticate` hook or `authorizeClusterStatus` threw, or `authenticate` returned a refusal object rather than an identity. |
| `NOT_FOUND` | The route does not exist, or cluster status is absent or refused. |
| `INVALID_MAX_BODY_BYTES` | `maxBodyBytes` is not a positive integer the transport can enforce exactly. |
| `INVALID_WS_BACKPRESSURE` | `maxWebSocketBackpressureBytes` failed validation or fell below `maxBodyBytes`. |
| `BULK_LOAD_UNSUPPORTED` | The execution target provides no bulk load. |
| `INVALID_MESSAGE` | A WebSocket message lacks a required field or carries a wrong type. |
| `UNSUPPORTED_SUBPROTOCOL` | A WebSocket upgrade offered subprotocols, none of them `sirannon.v1`. |
| `UNKNOWN_TYPE` | A WebSocket message carries an unrecognised type. |
| `HANDLER_CLOSED` | The WebSocket handler is shutting down. |
| `DUPLICATE_SUBSCRIPTION` | A subscription with the same ID already exists on the connection. |
| `SUBSCRIPTION_NOT_FOUND` | An unsubscribe named a subscription that does not exist. |
| `CDC_UNSUPPORTED` | Subscriptions need a file-based database, and this one is in memory. |

## Registered operations

Every code here comes from [registered operations](/docs/registered-operations), and each names a specific mistake in the request rather than a general failure.

| Code | When |
| --- | --- |
| `UNKNOWN_QUERY` | No operation of that name is registered for the database. |
| `MISSING_ARGUMENT` | A declared argument was absent from the request. |
| `ARGUMENT_NOT_ALLOWED` | The caller supplied an undeclared argument, or one the server fills from identity. |
| `IDENTITY_REQUIRED` | An operation fills an argument from identity and the request carries none. |
| `REGISTRY_MISMATCH` | A [live query](/docs/live-queries) echoed a registry digest this server does not serve. |
| `SQL_NOT_ACCEPTED` | The server accepts no SQL over the network. |

## Replication

Every class here is exported from `@delali/sirannon-db/replication`, and `FailoverError` is the shared base of `NoSafePrimaryError` and `UnsafeRecoveryRequiredError`.

| Class | Code | When |
| --- | --- | --- |
| `ReplicationError` | `REPLICATION_ERROR` | Base class for [replication](/docs/distributed-replication) failures. |
| `SyncError` | `SYNC_ERROR` | First sync failed: the node was not ready, the transfer timed out, or a manifest or batch order did not match. |
| `ConflictError` | `CONFLICT_ERROR` | Conflict resolution failed for a table and row. |
| `TransportError` | `TRANSPORT_ERROR` | A peer was unreachable or a send failed. |
| `BatchValidationError` | `BATCH_VALIDATION_ERROR` | A batch failed its checksum, broke the schema allowlist, passed `maxClockDriftMs`, or carried unsafe DDL. |
| `WriteConcernError` | `WRITE_CONCERN_ERROR` | The write concern was not met within the timeout. |
| `ReadConcernError` | `READ_CONCERN_ERROR` | The requested [read concern](/docs/distributed-replication#read-concern) cannot be satisfied. |
| `TopologyError` | `TOPOLOGY_ERROR` | A write reached a replica without forwarding, no primary was available, or a peer was unauthorised. |
| `CoordinatorError` | `COORDINATOR_UNAVAILABLE` | The coordinator cannot be reached or cannot prove quorum authority. |
| `AuthorityError` | `AUTHORITY_LOST` | A node lost primary or controller authority while handling work. |
| `StalePrimaryError` | `STALE_PRIMARY` | A request, batch, sync message, or forwarded write used a stale primary term. |
| `NoSafePrimaryError` | `NO_SAFE_PRIMARY` | No eligible in-sync replica can be promoted safely. |
| `NodeNotInSyncError` | `NODE_NOT_IN_SYNC` | The node is alive but outside the group's in-sync set. |
| `NodeDrainingError` | `NODE_DRAINING` | The node is in maintenance drain mode. |
| `ProtocolVersionMismatchError` | `PROTOCOL_VERSION_MISMATCH` | Node compatibility metadata is incompatible with the cluster. |
| `UnsafeRecoveryRequiredError` | `UNSAFE_RECOVERY_REQUIRED` | Automatic recovery needs explicit operator action. |

## Device sync

| Code | When |
| --- | --- |
| `MIGRATION_REQUIRED` | The device schema version is behind the server, so the device migrates before it syncs. |
| `SCHEMA_AHEAD` | The device schema version is ahead of the server, so the server migrates first. |
| `SYNC_UNSUPPORTED` | The execution target applies no changes, or the server predates [device sync](/docs/device-sync). |
| `SNAPSHOT_UNSUPPORTED` | A snapshot was requested for an in-memory database. |
| `SNAPSHOT_CHECKSUM_MISMATCH` | A downloaded snapshot page failed checksum verification. |

## Client

| Code | When |
| --- | --- |
| `CONNECTION_ERROR` | The client failed to connect to the server. |
| `UNAUTHORIZED` | The server refused the WebSocket upgrade as unauthenticated and closed with 4401. |
| `FORBIDDEN` | The server refused the WebSocket upgrade as not permitted and closed with 4403. |
| `TIMEOUT` | A request passed the configured `requestTimeout`. |
| `TRANSPORT_ERROR` | The current transport does not carry this operation, such as a live query over HTTP. |
| `INVALID_RESPONSE` | The server returned a response the client could not parse. |
| `ROUTING_ERROR` | [Topology routing](/docs/topology-routing) discovered no usable primary or read endpoint. |
| `NO_SAFE_PRIMARY` | Topology routing found no current primary for a write. |
| `INVALID_ARGUMENT` | A client argument failed validation, such as a per-call read concern on the topology transport. |
| `UNKNOWN_ERROR` | An error response carried no recognisable code. |

## Deciding whether to retry

Three groups behave differently, and treating them alike is how a retry loop corrupts data.

- **Definite rejections are safe to send again.** `WRITE_OVERLOADED` is load shedding: the write never ran, the response carries `Retry-After`, and the same request is safe to repeat. `WRITER_WORKER_UNAVAILABLE` and `WRITER_WORKER_POST_FAILED` say the same thing about a write that never reached the writer.
- **Indeterminate outcomes need reconciling first.** `WRITER_WORKER_TIMEOUT` and `WRITER_WORKER_EXIT` leave the outcome unknown, so read the state back before resending anything that is not idempotent.
- **Stale routing needs a refresh, not a retry.** `STALE_PRIMARY`, `AUTHORITY_LOST`, `COORDINATOR_UNAVAILABLE`, `NO_SAFE_PRIMARY`, and `CONNECTION_ERROR` mean the client's view of the cluster is out of date. The [topology client](/docs/topology-routing#when-the-clients-view-goes-stale) refreshes its routing metadata on all five, and raises a failed write rather than resending it.

A validation code such as `INVALID_WRITER_WORKER`, `INVALID_MAX_BODY_BYTES`, `INVALID_DRIVER`, or `INVALID_ARGUMENT` reports a configuration or call-site mistake. Fix the configuration; retrying reproduces it.

`UNAUTHORIZED` and `FORBIDDEN` report a refused WebSocket upgrade, and the server refuses that credential every time. The client leaves the connection closed, so issue a fresh credential and build a new client.

## HTTP status codes

| Status | Codes |
| --- | --- |
| 400 | `INVALID_REQUEST`, `INVALID_JSON`, `EMPTY_BODY`, `QUERY_ERROR`, `TRANSACTION_ERROR`, `INVALID_DURABILITY`, `INVALID_SYNCHRONOUS`, `BATCH_VALIDATION_ERROR`, `MISSING_ARGUMENT`, `ARGUMENT_NOT_ALLOWED`, `UNSUPPORTED_SUBPROTOCOL` |
| 401 | `IDENTITY_REQUIRED` |
| 403 | `READ_ONLY`, `FORBIDDEN_SQL`, `HOOK_DENIED`, `SQL_NOT_ACCEPTED` |
| 404 | `DATABASE_NOT_FOUND`, `NOT_FOUND`, `UNKNOWN_QUERY` |
| 409 | `STALE_PRIMARY`, `PROTOCOL_VERSION_MISMATCH`, `MIGRATION_REQUIRED`, `SCHEMA_AHEAD`, `REGISTRY_MISMATCH` |
| 413 | `PAYLOAD_TOO_LARGE` |
| 500 | `INTERNAL_ERROR`, `HOOK_ERROR`, `WRITER_WORKER_TIMEOUT` |
| 501 | `BULK_LOAD_UNSUPPORTED`, `SYNC_UNSUPPORTED` |
| 503 | `DATABASE_CLOSED`, `SHUTDOWN`, `READ_CONCERN_ERROR`, `COORDINATOR_UNAVAILABLE`, `AUTHORITY_LOST`, `NO_SAFE_PRIMARY`, `NODE_NOT_IN_SYNC`, `NODE_DRAINING`, `UNSAFE_RECOVERY_REQUIRED`, `WRITE_OVERLOADED` |

A code outside the table maps to 500, and a `RequestDeniedError` uses the status you gave it. `WRITER_WORKER_TIMEOUT` maps to 500 rather than 503 precisely because its outcome is indeterminate, so no proxy in front of the server treats it as a routine retry.

## WebSocket close codes

A status code reaches an HTTP caller, and a close code reaches a socket. No WebSocket client can read the status of a refused handshake, so the server reports those refusals in the close code.

| Close code | When |
| --- | --- |
| `1008` | The database named in the URL is absent or closed. |
| `1013` | The server is shutting down. |
| `4290` | The connection's outbound buffer passed `maxWebSocketBackpressureBytes`, which [device sync](/docs/device-sync) and [subscription resumption](/docs/subscription-resumption) both recover from. |
| `4401` | The `authenticate` hook refused the upgrade with status 401, and the close reason carries your code and message. |
| `4403` | The hook refused the upgrade with status 403. |

The client reconnects after `4290` and resumes each subscription from its cursor. It reads any code from 4000 to 4099 as a refusal and leaves that connection closed, because the server refuses that credential every time.
