Error Handling

July 8, 2026 ยท View on GitHub

The Zig client surfaces failures as values from a single, small error set rather than as exception objects. Each member maps to a category of HTTP or transport failure, so you discriminate with a switch and recover precisely.

const mongreldb = @import("mongreldb");

The error set

Every public method on Client, Transaction, and QueryBuilder returns Error!T, where Error is this set:

MemberMeaning
OutOfMemoryAn allocation failed. Retry with more memory or a smaller batch.
HttpA transport error or a server status we do not map more narrowly (3xx and most 5xx).
JsonThe server returned a malformed or unexpected JSON body.
AuthAuthentication or authorization failed (HTTP 401 or 403).
NotFoundThe table or row does not exist (HTTP 404).
ConflictA constraint violation rolled back a transaction, or a payment-required response (HTTP 402 or 409).
QueryThe request was malformed: a bad condition, projection, or SQL statement (HTTP 400 and other 4xx).
AlreadyCommittedA Transaction method was called after commit or rollback.

Because Zig error sets are checked at compile time, you cannot accidentally forget a case once you write a switch over them.

How HTTP status maps to an error

Client.mapStatus converts the daemon's response into a member of the set:

HTTP statusError
200 / 2xx(success, no error)
401, 403Auth
404NotFound
402, 409Conflict
400 and other 4xxQuery
3xx, 5xx, and transport failuresHttp
Body that is not valid JSONJson

Malformed JSON (a truncated body, a missing field the decoder expects) is reported as Json regardless of the HTTP status.

Matching errors

Use catch |err| switch (err) to handle each case:

const rows = db.query(arena.allocator(), "users")
    .where("bitmap_eq", .{ .column_id = 1, .value = mongreldb.intValue(7) }) catch |err| switch (err) {
        error.Auth => {
            std.debug.print("invalid credentials\n", .{});
            return;
        },
        error.NotFound => {
            std.debug.print("table missing\n", .{});
            return;
        },
        error.Query => {
            std.debug.print("malformed query\n", .{});
            return;
        },
        else => return err, // Http, Json, OutOfMemory, etc.
    };

For the common "log and propagate" shape, a plain try is enough:

const rows = try db.query(arena.allocator(), "users").execute();

Transaction conflicts

A Transaction.commit runs all staged ops in a single atomic batch. If any op violates a unique, foreign-key, check, or trigger constraint, the daemon rolls back the entire batch and returns HTTP 409, which the client surfaces as error.Conflict.

var txn = db.begin(arena.allocator());
_ = try txn.put("orders", &.{ .{ .id = 1, .value = .{ .integer = 10 } } }, false);

const results = txn.commit("order-batch-001") catch |err| switch (err) {
    error.Conflict => {
        std.debug.print("batch rolled back - fix the data and retry\n", .{});
        return;
    },
    else => return err,
};

The idempotency key makes a safe retry possible: re-stage the same ops on a fresh transaction and commit with the same key. The daemon returns the original response on duplicate commits.

Single-use transactions

Transaction.commit and Transaction.rollback both flip an internal flag. Calling any method on the transaction afterward returns error.AlreadyCommitted. Start a new transaction for each batch:

var txn = db.begin(arena.allocator());
_ = try txn.commit("key-1");

// reuse is an error:
// _ = txn.put("orders", &.{...}, false) catch unreachable;
//        -> error.AlreadyCommitted

var next = db.begin(arena.allocator());
_ = try next.commit("key-2");

Retries and idempotency

Network glitches and daemon restarts happen. Pair an idempotency key with a retry loop for commit:

fn commitWithRetry(txn: *mongreldb.Transaction, key: []const u8) !mongreldb.Array {
    var attempt: u8 = 0;
    while (attempt < 3) : (attempt += 1) {
        return txn.commit(key) catch |err| switch (err) {
            error.Http => {
                // transport error - safe to retry with the same key
                std.time.sleep(100 * std.time.ns_per_ms);
                continue;
            },
            else => return err, // Auth, Conflict, Query, etc. are not retried here
        };
    }
    return error.Http;
}

Only retry on error.Http (transport) with the same idempotency key. Conflict and Query indicate a problem with the request itself and must be fixed before retrying.

Common pitfalls

Swallowing errors with catch. A bare catch discards the category and hides bugs. Match with switch so each branch is explicit.

Retrying Conflict. A conflict means the batch violated a constraint; replaying the same ops will fail the same way. Fix the offending op, then retry.

Forgetting AlreadyCommitted. A transaction is single-use. If you share one across function boundaries, make it obvious who calls commit or rollback.

Next steps