Skip to content

Errors

Failing a request

fail.notFound(...) and friends can be called from anywhere, with no Ctx in hand — from a handler, from a resolver, from a helper three calls deep, from inside nilo.blocking:

fn getUser(db: *Db, id: u32) !User {
    return db.find(id) orelse nilo.fail.notFound("no user {d}", .{id});
}

Every one of them returns error.Failed, having put the status and the message somewhere the request will find them. So a handler's signature stays !User rather than growing an error set, and a test asserts on error.Failed plus the message.

fail.badRequest(fmt, args) 400
fail.unauthorized(…) 401
fail.forbidden(…) 403
fail.notFound(…) 404
fail.conflict(…) 409
fail.tooLarge(…) 413
fail.unprocessable(…) 422
fail.tooManyRequests(…) 429
fail.internal(…) 500 — the message is logged, not sent
fail.status(code, …) any status you like

The message is formatted into a fixed slot — 240 bytes, no allocation — and one longer than that is truncated rather than refused. It is written for the person reading the response, so say what was wrong and what would work: fail.notFound("no user {d}", .{id}) beats fail.notFound("not found", .{}).

See ADR 004 and ADR 006 for how the message finds its way back without a Ctx.

Any other error

An error a handler returns that isn't error.Failed goes through a mapping table: error.FileNotFound is a 404, the JSON and number-parsing errors (error.InvalidCharacter, error.SyntaxError, error.MissingField, …) are 400s, error.BodyTooLarge is a 413, error.BodyTooSlow is a 408 — the body the client announced never finished arriving — and error.Timeout and error.Canceled are 503s. Anything unrecognised becomes a 500 whose error name is logged but not sent to the client — error.DatabaseSchemaMismatch is your business, not your caller's.

Either way the connection stays alive: a 404 is a normal thing to answer, not a reason to hang up.

The one exception is a handler that fails after it has already answered. A half-sent response can't be taken back, so the connection is closed and the log says so:

warning: handler GET /report failed after answering: WriteFailed

What the client is told

The status, and the message — as JSON, always, whatever the endpoint returns on its happy path:

$ curl -i localhost:8787/users/99
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":"no user 99","status":404}

One shape for every failure, from every source: a fail function, an error out of a handler, a body nilo refused, a request head that never finished arriving. Nothing to configure and nothing to negotiate — a frontend calls res.json() in the same catch where it shows the user what went wrong, and it works (ADR 024).

A failure with no message of its own gets the status phrase. Nothing about nilo's internals goes out — no stack trace, no file name, no Zig error name unless a fail function put it in the message on purpose. A 500 logs the error name and sends internal server error.

When your clients already read another shape

The one shape is right until the frontend in front of you already reads {"code":…,"detail":…} from three other services. Then name a struct and let nilo fill it:

const ApiError = struct {
    code: u16,
    detail: []const u8,

    pub fn nilo_failure(status: u16, message: []const u8) ApiError {
        return .{ .code = status, .detail = message };
    }
};
try app.failures(ApiError);
$ curl -i localhost:8787/users/99
HTTP/1.1 404 Not Found
Content-Type: application/json

{"code":404,"detail":"no user 99"}

The fields are the JSON and nilo_failure fills them from the status and the sentence — a nested struct for {"error":{"code":…}} works the same way. Every failure nilo assembles takes the shape, headers intact: a 405 still carries its Allow, a 401 its WWW-Authenticate, and the CORS headers still go out. The OpenAPI document's Failure schema is read from the same fields, so it describes what the wire carries. What keeps nilo's own shape is the handful of answers written before there is a request to route — a malformed head, a head too long, a shed 503 — because those are constants written in one call. The body is written into a fixed buffer with 256 bytes of room for the envelope around the sentence; a shape that needs more gets nilo's own shape instead, sentence intact, which the first failure in development shows (ADR 024).

In tests, read the field rather than matching the wire:

const parsed = try std.json.parseFromSlice(std.json.Value, gpa, body, .{});
defer parsed.deinit();
try expectEqualStrings("no user 99", parsed.value.object.get("error").?.string);

Tying a failure to its log line

Behind the proxy that nilo assumes in front (ADR 027), the one thing you cannot reconstruct afterwards is which log lines belong to the request that went wrong. Switch on request ids and the answer is on the response:

try app.use(logger.with(.{ .format = .json, .request_id = true }));
$ curl -i localhost:8787/users/99
HTTP/1.1 404 Not Found
X-Request-Id: 4f2ba81c9d3e7a05

{"method":"GET","path":"/users/99","status":404,"us":59,"request_id":"4f2ba81c9d3e7a05"}

Somebody reports "it failed around 14:02" and pastes the header; you grep for it. c.requestId() reaches the same id from inside a handler, so anything you log yourself can carry it too — and it works whether or not the logger is installed. A call the handler makes through nilo_fetch carries it as well, as X-Request-Id on the outbound request, so the service on the other end can grep for the same string (ADR 158).

If the proxy already sent an X-Request-Id, that one is used, so the id is the same on both sides. A client's id is checked, not trusted: up to 64 bytes of letters, digits, ., _ and - — which every id generator in use produces — and anything else is ignored in favour of one of nilo's own. Otherwise a newline in a header would forge a log line and split a response.

Both options are off by default: the id costs a header on every response, and the plain-text line is what a person reads in a terminal.

Errors nilo writes for you

You don't have to write any of these; they are what the request never reaching your handler looks like.

400 a path param that doesn't convert, a query param that doesn't fit, a body that isn't valid JSON, a form sent as the wrong encoding, a WebSocket upgrade that isn't one
401 an Authorization(…) argument with no header behind it, another scheme, an empty token, or Basic that will not decode — with WWW-Authenticate saying what would have done (Handlers)
403 a WebSocket handshake from an origin the route did not name — WebSocket; an Idempotent(…) whose by found nobody behind the request
404 no route, and no static file
405 the path exists under another method — with an Allow header
409 an Idempotency-Key that is still being answered — Answering once
408 a request head or a body that stopped arriving inside the deadlines
413 a body past c.body()'s megabyte, or a stream's max_bytes
422 a Bound(…) argument whose handler answered b.fail() — Forms; an Idempotency-Key reused on a different request
429 an address past its allowance, with a Retry-After
431 a request head bigger than read_buffer
500 a Session(T) asked for with no session_secret set, a header value with a control byte in it, a cookie value with a ;
503 the request was cancelled while waiting on a lock or a sleep; the health page while a service is not ready or the server is stopping

Each of them names the thing that was wrong. See Requests for what the 400s actually say.

Panics are not errors

An integer overflow or an out-of-bounds index is not an error a handler returns — it takes the whole process down, every in-flight connection with it. There is no recover middleware because there cannot be one. See Deploying and ADR 007.