Every Sirannon error extends SirannonError and includes 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. catch-errors.ts inserts an order with no total, which the column's NOT NULL constraint refuses.
mkdir -p dataimport { QueryError, Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
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')SQL failed [QUERY_ERROR]: NOT NULL constraint failed: orders.total INSERT INTO orders (id) VALUES (?)Some errors add context alongside the code: sql on QUERY_ERROR, table, rowId, and cause 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 use the same shape:
{ "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 | A caller registered an ID already in use. |
| - | DATABASE_CLOSED | An operation ran against a closed database. |
| - | DATABASE_OPEN_FAILED | A database failed to open. |
ReadOnlyError | READ_ONLY | A write, or a live query, ran against a read-only database. |
QueryError | QUERY_ERROR | SQLite failed to prepare or execute a statement, on this node or, for a forwarded write, on the primary. |
ForbiddenSqlError | FORBIDDEN_SQL | A statement reached a _sirannon table, modified the sqlite_ catalogue, or used ATTACH, DETACH, or PRAGMA writable_schema. |
TransactionError | TRANSACTION_ERROR | Every statement in a transaction succeeded and SQLite then refused the commit, as it does for a deferred foreign key that still fails. The error's cause holds SQLite's own error. |
HookDeniedError | HOOK_DENIED | A before-hook rejected the operation. |
RequestDeniedError | the code you supply | The authenticate hook 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 failed for a reason none of the codes below name, such as a refused path or a piece size a run could not use. |
| - | BACKUP_UNSUPPORTED | The driver provides no backup engine, the connection offers no stepped copy call, the node driver runs on a Node.js build whose node:sqlite exports no backup function, or a database asked for backups without a file in write-ahead logging mode. |
| - | BACKUP_RESTARTED | SQLite returned the copy to page one more times than restartLimit allows, or the copy reached no new page across noProgressStepLimit steps. |
| - | BACKUP_STALLED | The copy completed no step inside stallTimeoutMs. |
| - | BACKUP_ABORTED | A whole write deadline passed with no step of a copy on the writer worker, so the host cancelled it and the worker stopped it part-way. |
| - | BACKUP_DESTINATION_ERROR | Your destination refused a call, failed to answer inside destinationTimeoutMs, or holds pieces that do not match the run that wrote them. |
| - | BACKUP_LOG_REWOUND | The write-ahead log restarted before a capture read it, so the writes those frames held are in no backup. |
| - | BACKUP_CHAIN_BROKEN | No full copy goes back as far as the moment asked for, a chain is missing a piece a restore needs, or no chain record states the name given to verifyBackup. |
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 | A caller supplied an unknown synchronous level. |
| - | INVALID_DURABILITY | A load passed a durability other than 'off' or 'normal'. |
| - | DURABILITY_RESTORE_FAILED | The load committed, then the writer failed before it restored durability. |
| - | SNAPSHOT_IN_PROGRESS | A read or write ran while a device-sync snapshot 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 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 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 the worker shed a queued write 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 failed. | 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. |
Backups
A backup writes to storage you own, so whether a failure is worth retrying depends on what that failure left behind. Sirannon removes a partial local file itself, and a destination that follows the contract lets a later run replace any piece an earlier one stored.
| Code | Retry? |
|---|---|
BACKUP_RESTARTED | Yes, once the connection writing to the source file has stopped. The copy stored nothing usable and Sirannon removed the file it was writing. |
BACKUP_STALLED | Yes. Sirannon removes the partial file once SQLite stops writing it, so the next run starts clean. |
BACKUP_ABORTED | Yes. Sirannon removed the partial file, so the next run starts clean. Raise the worker's writeTimeoutMs where a large copy trips it repeatedly. |
BACKUP_DESTINATION_ERROR | Yes, once your storage answers again. A cycle keeps the capture staged and offers it on a later turn by itself. |
BACKUP_LOG_REWOUND | No. The writes those frames held reach no backup, and the cycle starts a fresh chain with a full copy. |
BACKUP_CHAIN_BROKEN | No. Ask for a moment an intact chain reaches, or let the cycle start a fresh one. |
BACKUP_UNSUPPORTED | No. Change the driver, the runtime, or the database's options. |
BACKUP_RESTORE_IN_PROGRESS | Yes, once the running restore finishes. |
BACKUP_RESTORE_NOT_ACCEPTED | No. The operator has to open the route with acceptBackupRestore. |
Migrations
| Code | When |
|---|---|
MIGRATION_ERROR | A migration 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 named a migration with no down statement. |
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 in place of 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. |
INVALID_BACKUP_RESTORE | acceptBackupRestore is true and the server has no authenticate hook, so it refuses to start. |
INVALID_DEVICE_SYNC | acceptDeviceSync is true and the server has no authenticate hook, so it refuses to start. |
BACKUP_RESTORE_NOT_ACCEPTED | A caller reached POST /db/:id/backup/restore on a server that leaves acceptBackupRestore off. |
BACKUP_RESTORE_IN_PROGRESS | A restore of that database is already under way and keeps its file to itself. |
BULK_LOAD_UNSUPPORTED | The execution target provides no bulk load. |
INVALID_MESSAGE | A WebSocket message lacks a required field or holds a value of the wrong type. |
UNSUPPORTED_SUBPROTOCOL | A WebSocket upgrade offered subprotocols, none of them sirannon.v1. |
UNKNOWN_TYPE | A WebSocket message names 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, and each one names a specific mistake in the request.
| 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 supplies none. |
REGISTRY_MISMATCH | A live query echoed a registry digest this server does not serve. |
SQL_NOT_ACCEPTED | The server accepts no SQL over the network, or it refuses a subscription to a table's changes because it runs without acceptSql and its registry has no onBeforeSubscribe hook. |
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 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 | A conflict resolver threw while Sirannon applied a change. table and rowId name the row, and cause holds what the resolver threw. |
TransportError | TRANSPORT_ERROR | A peer was unreachable or a send failed. A primary that refuses a forwarded write with a Sirannon code, such as QUERY_ERROR for a statement that SQLite rejects, sends that code back to the replica unchanged. |
BatchValidationError | BATCH_VALIDATION_ERROR | A batch failed its checksum, broke the schema allowlist, passed maxClockDriftMs, or included unsafe DDL. |
WriteConcernError | WRITE_CONCERN_ERROR | The group did not meet the write concern inside the timeout. |
ReadConcernError | READ_CONCERN_ERROR | The requested 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. |
SNAPSHOT_UNSUPPORTED | A snapshot was requested for an in-memory database. |
SNAPSHOT_CHECKSUM_MISMATCH | A downloaded snapshot page failed checksum verification. |
DEVICE_SYNC_NOT_ACCEPTED | The server leaves acceptDeviceSync off, so it serves no device sync. SyncController.start() raises the same code against such a server. |
DEVICE_CLOCK_AHEAD | A pushed change carried a stamp more than 300,000 milliseconds ahead of the server clock. The device keeps those writes and pushes them again later. |
DEVICE_NOT_SUBSCRIBED | An acknowledgement named a device for which the connection holds no subscription. |
Optional packages
Sirannon raises these codes in your own process when it starts a server, a replication transport, or a coordinator whose package it cannot load. The message names the missing package and the command that installs it.
| Code | When |
|---|---|
SERVER_DEPENDENCY_MISSING | server.listen() could not load uWebSockets.js. |
TRANSPORT_DEPENDENCY_MISSING | The gRPC transport could not load @grpc/grpc-js, @bufbuild/protobuf, or grpc-health-check as the engine started. |
COORDINATOR_DEPENDENCY_MISSING | The etcd coordinator could not load etcd3 when the engine first called it. |
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 support 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 discovered no usable primary or read endpoint. |
NO_SAFE_PRIMARY | Topology routing in coordinator mode found no current primary for a write or a linearizable read. |
INVALID_ARGUMENT | A client argument failed validation, such as a per-call read concern on the topology transport. |
UNKNOWN_ERROR | An error response named 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_OVERLOADEDreports load shedding, because the write never ran, the response includesRetry-After, and the same request is safe to repeat.WRITER_WORKER_UNAVAILABLEandWRITER_WORKER_POST_FAILEDreport the same about a write that never reached the writer. - Indeterminate outcomes need reconciling first.
WRITER_WORKER_TIMEOUTandWRITER_WORKER_EXITleave 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, andCONNECTION_ERRORmean the client's view of the cluster is out of date. The topology client refreshes its routing metadata on all five in coordinator mode and runs a failed read once more, while it raises a failed write for you to reconcile.
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, DEVICE_CLOCK_AHEAD |
| 401 | IDENTITY_REQUIRED |
| 403 | READ_ONLY, FORBIDDEN_SQL, HOOK_DENIED, SQL_NOT_ACCEPTED, BACKUP_RESTORE_NOT_ACCEPTED, DEVICE_SYNC_NOT_ACCEPTED |
| 404 | DATABASE_NOT_FOUND, NOT_FOUND, UNKNOWN_QUERY |
| 409 | STALE_PRIMARY, PROTOCOL_VERSION_MISMATCH, MIGRATION_REQUIRED, SCHEMA_AHEAD, REGISTRY_MISMATCH, BACKUP_CHAIN_BROKEN, BACKUP_RESTORE_IN_PROGRESS |
| 413 | PAYLOAD_TOO_LARGE |
| 500 | INTERNAL_ERROR, HOOK_ERROR, WRITER_WORKER_TIMEOUT |
| 501 | BULK_LOAD_UNSUPPORTED, SYNC_UNSUPPORTED, BACKUP_UNSUPPORTED |
| 502 | BACKUP_DESTINATION_ERROR |
| 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, and not to 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 and subscription resumption both recover from. |
4401 | The authenticate hook refused the upgrade with status 401, and the close reason states 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.