Error handling

July 8, 2026 · View on GitHub

Every non-2xx response from the daemon is mapped to a typed Swift error. This is the complete reference: the error hierarchy, the HTTP-status mapping, the daemon's error envelope, and recovery patterns for each category.


The error model

All client errors conform to Error via the MongrelDBError base class. The client throws a specific subclass for each failure category:

TypeMeaningTypical cause
MongrelDBErrorBase class for all client errors(catch this to handle any failure)
AuthErrorHTTP 401 or 403Missing/bad credentials against an auth-enabled daemon
NotFoundErrorHTTP 404Missing table, schema, or resource
ConflictErrorHTTP 409Unique, foreign-key, check, or trigger violation at commit
QueryErrorHTTP 400 or 5xx, plus transportMalformed request, server failure, transport error

Every MongrelDBError carries:

PropertyMeaning
e.messageThe human-readable message from the daemon
e.statusThe HTTP status code, or -1 when unknown (transport failure)
e.codeThe server's structured error code (e.g. "UNIQUE_VIOLATION"); nil when absent
e.opIndexThe offending op index within a batch, when reported; nil otherwise
e.causeThe underlying error for transport failures, when applicable

The daemon's error envelope

{
  "status": "aborted",
  "error": {
    "code": "UNIQUE_VIOLATION",
    "message": "duplicate key in column 1",
    "op_index": 0
  }
}

Structured codes you will commonly see in code:

codeMeaning
UNIQUE_VIOLATIONA unique/PK constraint rejected the commit
FK_VIOLATIONA foreign-key reference was missing
CHECK_VIOLATIONA check constraint or trigger rejected the commit
NOT_FOUNDA named resource (table, schema) does not exist

HTTP status → error mapping

HTTP statusTypeNotes
401, 403AuthErrorBad/missing credentials
404NotFoundErrorResource not found
409ConflictErrorConstraint violation at commit
400QueryErrorMalformed request / bad query
5xxQueryErrorDaemon-side failure
other non-2xxQueryErrorCatch-all
2xx(no error)Success

Transport failures (unreachable host, broken pipe, decode errors) are also mapped to QueryError, with status == -1 and the underlying error in cause.

Discriminating errors

By category - typed catch

do {
    _ = try await db.schemaFor("missing_table")
} catch let e as NotFoundError {
    print("table does not exist")
} catch is ConflictError {
    print("unexpected conflict on a read")
} catch is AuthError {
    print("bad credentials")
} catch is QueryError {
    print("server error or malformed request")
} catch {
    print("other error: \(error)")
}

By details - read the fields

do {
    _ = try await txn.commit()
} catch let e as ConflictError {
    print("status=\(e.status) code=\(e.code ?? "nil") op=\(e.opIndex.map(String.init) ?? "n/a") msg=\(e.message)")
}

Recovery patterns

Auth failure - do not retry blindly

A retry will not fix bad credentials. Surface the error to the caller or operator.

catch let e as AuthError {
    throw AuthError(message: "credentials rejected; refresh token: \(e.message)", status: e.status)
}

Not found - fall back, do not crash

For lookups by primary key, a 404 may be a normal "absent" result.

do {
    _ = try await db.schemaFor(tableName)
} catch is NotFoundError {
    return [:] // table missing - treat as empty
}

Note: a pk query against an existing table returns zero rows, not a 404; NotFoundError here means the table itself is missing.

Constraint conflict - report the offending op

do {
    _ = try await txn.commit()
} catch let e as ConflictError {
    if let i = e.opIndex {
        print("op \(i) violated \(e.code ?? "?"): \(e.message)")
    } else {
        print("conflict \(e.code ?? "?"): \(e.message)")
    }
    throw e
}

The engine already rolled back the whole batch - there is nothing to undo.

Transient failure - retry with an idempotency key

QueryError covers transport and 5xx failures. With an idempotency key, retrying a transaction is safe (see transactions.md).

func run(db: MongrelDBClient, build: (MongrelDBClient) -> Transaction, key: String) async throws {
    // build is a closure that returns a fresh Transaction with the same ops.
    do {
        _ = try await build(db).commit(idempotencyKey: key)
    } catch is AuthError, is ConflictError {
        throw MongrelDBError(message: "not transient") // caller must not retry
    } catch {
        throw error // QueryError / network - caller may retry with the same key
    }
}

Transaction-state error

Calling commit or rollback twice on the same Transaction traps with a precondition failure. That is a programming bug - fix the control flow rather than catching it.

Quick reference

// Category checks (most specific first):
do { /* ... */ }
catch let e as AuthError      // 401/403
catch let e as NotFoundError  // 404
catch let e as ConflictError  // 409
catch let e as QueryError     // 400/5xx/network
catch let e as MongrelDBError // base

// Detail extraction:
catch let e as ConflictError {
    // e.code    // String?, e.g. "UNIQUE_VIOLATION"
    // e.opIndex // Int?
    // e.message // String
    // e.status  // Int
}

Next steps