Error handling

July 10, 2026 ยท View on GitHub

Every procedure in the Odin client returns a Mongrel_Error alongside its result. .None_ means success; every other variant is a failure category. This is the complete reference: the error variants, the HTTP-status mapping, the daemon's error envelope, and recovery patterns for each category.


The error model

Mongrel_Error is a single typed enum you switch on to branch on the category of failure:

Mongrel_Error :: enum {
	None_,
	Http,
	Json,
	Auth,
	Not_Found,
	Conflict,
	Query,
	Response_Too_Large,
	Already_Committed,
}

mongrel_error_string(e) returns a short human-readable label for an error variant (handy for logging). The daemon's structured error code is not decoded separately by the client; it is part of the response body, which is not surfaced on the error path. Branch on the category.

Throughout this guide the client is imported as import m "mdb:mongreldb" and all calls use the free-function form (e.g. m.schema_for(db, ...), m.mongrel_error_string(err)).

Error variant reference

VariantMeaningTypical cause
.None_success-
.Httptransport failure or 3xx/5xxConnection refused, timeout, DNS failure, redirect, daemon crash
.Jsonmalformed JSON responseThe daemon returned a body the client's parser could not decode
.AuthHTTP 401 or 403Missing/bad credentials against an auth-enabled daemon
.Not_FoundHTTP 404Missing table, missing schema, dropped resource
.ConflictHTTP 402 or 409Unique, foreign-key, check, or trigger violation at commit
.QueryHTTP 400 or other non-2xxMalformed request, server-side failure, everything else
.Response_Too_Largebody > 256 MBA response exceeded the max_response_bytes cap
.Already_Committedclient-sidecommit/rollback on a spent transaction

The daemon's error envelope

When the daemon rejects a request, it returns a JSON envelope like:

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

Structured codes you will commonly see:

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

The client maps the HTTP status to the variant above; the message body itself is not surfaced on the error path (the failed response body is discarded), so branch on the variant and correlate with server logs when you need the detail.

HTTP status -> variant mapping

HTTP statusVariantNotes
2xx.None_Success
3xx.HttpRedirects are treated as transport errors (the client does not follow them)
401, 403.AuthBad/missing credentials
402, 409.ConflictConstraint violation at commit
404.Not_FoundResource not found
400.QueryMalformed request / bad query
5xx.HttpDaemon-side failure
other non-2xx.QueryCatch-all

Discriminating errors

switch on the returned error:

_, err := m.schema_for(db, "missing_table")
switch err {
case .None_:       // ok
case .Not_Found:   fmt.eprintln("table does not exist")
case .Conflict:    fmt.eprintln("unexpected conflict on a read")
case .Auth:        fmt.eprintln("bad credentials")
case .Query:       fmt.eprintf("server error: %s\n", m.mongrel_error_string(err))
case .Http:        fmt.eprintf("can't reach daemon: %s\n", m.mongrel_error_string(err))
}

Recovery patterns

Auth failure - do not retry blindly

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

if err == .Auth {
	// Refresh credentials from your secret store, or fail fast.
	return
}

Not found - fall back, do not crash

For lookups by primary key, a 404 may be a normal "absent" result (when the table itself is missing). Treat it accordingly.

if err == .Not_Found {
	// table missing - treat as empty
}

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

Constraint conflict - the engine already rolled back

if err == .Conflict {
	// The engine already discarded the whole batch. Nothing to undo.
}

Transient failure - retry with an idempotency key

.Http (for 5xx and transport failures) and .Query (for 400s) cover transport and transient server failures. With an idempotency key, retrying a transaction is safe (see transactions.md).

for attempt in 0..<3 {
	results, err = m.commit(&txn, "stable-key")
	if err == .None_ { break }
	if err == .Auth || err == .Conflict { break } // not transient
	// sleep and retry
}

Already committed - a programming bug

.Already_Committed means commit or rollback was called twice on the same Transaction. Fix the caller rather than catching it at runtime.

Network failure - check connectivity

.Http wraps the libcurl error on the transport layer. Check whether the daemon is running and reachable on the configured URL.

Response too large

.Response_Too_Large means a response body exceeded the 256 MB max_response_bytes cap. Narrow your query (projection, limit, range predicate) or page the result set.

Quick reference

// Category checks:
if err == .Not_Found       { /* ... */ }
if err == .Conflict        { /* ... */ }
if err == .Auth            { /* ... */ }
if err == .Query           { /* ... */ }
if err == .Http            { /* ... */ }
if err == .Response_Too_Large { /* ... */ }
if err == .Already_Committed { /* ... */ }

// Human-readable label:
label := m.mongrel_error_string(err)

Next steps