Skip to content

Error Handling

The error handler middleware catches any Zig error returned by downstream middleware or route handlers and converts it into a 500 Internal Server Error response. Without it, an unhandled error would propagate up and crash the server.

Place errorHandler as the first middleware in your pipeline so it wraps everything downstream:

const App = zzz.Router.define(.{
.middleware = &.{
zzz.errorHandler(.{}), // catches all errors below
zzz.logger,
zzz.bodyParser,
},
.routes = &.{
zzz.Router.get("/", index),
},
});

The ErrorHandlerConfig struct has one option:

OptionTypeDefaultDescription
show_detailsboolfalseWhen true, the error name is included in the response body. When false, a generic message is returned.

In development, enable show_details to see which error was thrown:

zzz.errorHandler(.{ .show_details = true }),

If a handler returns error.DatabaseTimeout, the response body will be:

DatabaseTimeout

with status 500 Internal Server Error and Content-Type: text/plain; charset=utf-8.

When a handler or middleware returns an error, the chain unwinds back through each middleware’s ctx.next() call. The error handler catches the error at the top of the chain:

fn errorHandler(ctx: *zzz.Context) !void {
ctx.next() catch |err| {
ctx.response.status = .internal_server_error;
ctx.response.body = if (show_details) @errorName(err) else "500 Internal Server Error";
return; // error is swallowed -- response is sent
};
}

Middleware that runs before the error handler (i.e., listed before it in the array) will not have their errors caught. This is why the error handler should be first.

Any handler can return a Zig error to trigger the error handler:

fn riskyHandler(ctx: *zzz.Context) !void {
_ = ctx;
return error.SomethingWentWrong;
}

With show_details = true, the client sees SomethingWentWrong in the response body. With show_details = false, the client sees 500 Internal Server Error.

When no error occurs, the error handler is invisible. The response from the downstream handler is returned unchanged:

fn healthCheck(ctx: *zzz.Context) !void {
ctx.json(.ok, "{\"status\": \"ok\"}");
}

This returns 200 OK with the JSON body, exactly as written.

The logger middleware measures response time and logs the result. When combined with the error handler, it logs the 500 status for failed requests:

.middleware = &.{
zzz.errorHandler(.{ .show_details = true }),
zzz.logger, // sees the 500 set by errorHandler
zzz.bodyParser,
},

The logger runs after ctx.next() returns (since errorHandler swallows the error), so it logs:

GET /risky -> 500 (12us)

If you need more control than the built-in error handler provides (for example, returning JSON errors or rendering HTML error pages), write your own middleware:

fn customErrorHandler(ctx: *zzz.Context) !void {
ctx.next() catch |err| {
const is_api = std.mem.startsWith(u8, ctx.request.path, "/api");
if (is_api) {
ctx.json(.internal_server_error, "{\"error\": \"internal_server_error\"}");
} else {
ctx.html(.internal_server_error,
\\<html><body><h1>Something went wrong</h1></body></html>
);
}
std.log.err("Unhandled error: {s} on {s}", .{@errorName(err), ctx.request.path});
return;
};
}

Then use it in place of the built-in:

.middleware = &.{
customErrorHandler,
zzz.logger,
zzz.bodyParser,
},
  • Middleware — the full middleware pipeline
  • Context — setting response status and body
  • Routing — define your route handlers