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.