Error Handling in Zigmund
Error handling is one of the areas where Zig and Python differ most
fundamentally. Python uses exceptions -- objects thrown at runtime that unwind
the call stack until a matching except block catches them. Zig uses error
unions -- a type-level mechanism where functions declare the errors they can
return as part of their return type. There is no hidden control flow, no stack
unwinding, and no performance penalty for code that does not produce errors.
Zigmund bridges these two models by providing exception handlers that map Zig errors to HTTP responses, following patterns that will feel familiar to FastAPI developers.
Error Unions: !zigmund.Response
In Zig, a function that can fail returns an error union. The ! syntax is
shorthand for an error union where the error set is inferred:
fn handler(allocator: std.mem.Allocator) !zigmund.Response {
return zigmund.Response.json(allocator, .{ .ok = true });
}
The return type !zigmund.Response means "either a Response on success, or an
error value on failure." This is conceptually similar to Python's approach of
either returning a value or raising an exception, but with a critical difference:
the error is part of the type system and must be handled explicitly by every
caller.
Comparison with Python
# Python -- exceptions are invisible in the type signature
def handler():
if not found:
raise HTTPException(status_code=404, detail="Not found")
return {"ok": True}
// Zig -- errors are visible in the return type
fn handler(allocator: std.mem.Allocator) !zigmund.Response {
if (!found) {
return error.NotFound;
}
return zigmund.Response.json(allocator, .{ .ok = true });
}
In the Python version, nothing in the function signature tells you it can raise
HTTPException. In the Zig version, the ! in the return type makes it
explicit that this function can return an error.
Custom Error Sets
Zig lets you define named error sets to categorize the errors your application can produce:
const InventoryError = error{ItemUnavailable};
This declares an error set with a single member, ItemUnavailable. You can
have multiple members:
const UserError = error{
UserNotFound,
EmailAlreadyExists,
InvalidCredentials,
};
Error sets are types. You can use them in error unions to specify exactly which errors a function can return:
fn findUser(id: u32) UserError!User {
// Can only return UserNotFound, EmailAlreadyExists, or InvalidCredentials
}
When using ! (inferred error set), the compiler figures out the error set
automatically from all possible error paths in the function body.
Returning Errors from Handlers
To return an error from a handler, use the return keyword with an error value:
const InventoryError = error{ItemUnavailable};
fn readInventoryItem(
item_id: zigmund.Path(u32, .{ .alias = "item_id" }),
allocator: std.mem.Allocator,
) !zigmund.Response {
if (item_id.value.? == 0) {
return InventoryError.ItemUnavailable;
}
return zigmund.Response.json(allocator, .{
.item_id = item_id.value.?,
.available = true,
});
}
When a handler returns an error, Zigmund checks its registered exception handlers for one that matches the error set. If no handler matches, the framework returns a generic 500 Internal Server Error response.
Exception Handlers
Exception handlers in Zigmund work similarly to FastAPI's exception handlers. You register a function that will be called when a specific error set is returned from a handler:
fn inventoryErrorHandler(
req: *zigmund.Request,
err: anyerror,
allocator: std.mem.Allocator,
) !zigmund.Response {
_ = req;
_ = err;
var response = try zigmund.Response.json(allocator, .{
.detail = "Item is unavailable",
});
return response.withStatus(.not_found);
}
pub fn buildExample(app: *zigmund.App) !void {
try app.addExceptionHandler(InventoryError, inventoryErrorHandler);
try app.get("/items/{item_id}", readInventoryItem, .{});
}
How addExceptionHandler works
The addExceptionHandler method takes two arguments:
- An error set type -- tells Zigmund which errors this handler covers. The framework extracts the error names at compile time and matches against them at runtime.
- A handler function -- called when a matching error is returned. It
receives the request, the error value, and an allocator, and must return a
Response.
The handler function signature is:
fn(req: *zigmund.Request, err: anyerror, allocator: std.mem.Allocator) !zigmund.Response
Comparison with FastAPI
# FastAPI
class ItemUnavailableError(Exception):
pass
@app.exception_handler(ItemUnavailableError)
async def item_unavailable_handler(request: Request, exc: ItemUnavailableError):
return JSONResponse(
status_code=404,
content={"detail": "Item is unavailable"},
)
// Zigmund
const InventoryError = error{ItemUnavailable};
fn inventoryErrorHandler(
req: *zigmund.Request,
err: anyerror,
allocator: std.mem.Allocator,
) !zigmund.Response {
_ = req;
_ = err;
var response = try zigmund.Response.json(allocator, .{
.detail = "Item is unavailable",
});
return response.withStatus(.not_found);
}
// In setup:
try app.addExceptionHandler(InventoryError, inventoryErrorHandler);
The pattern is structurally identical. Define an error type, write a handler that converts it to a response, and register the association.
HTTPException
For cases where you want to return an HTTP error directly without registering a
custom exception handler, Zigmund provides HTTPException. This is a struct
(not an error value) that carries a status code and detail message:
pub const HTTPException = struct {
status_code: std.http.Status,
detail: []const u8,
headers: []const std.http.Header = &.{},
};
You can use HTTPException in exception handlers or anywhere you need to
construct a structured error response. This mirrors FastAPI's HTTPException:
# FastAPI
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Item not found")
ProblemDetail: RFC 7807 Responses
For structured error responses that follow the RFC 7807 "Problem Details for
HTTP APIs" standard, Zigmund provides the ProblemDetail struct and convenience
functions:
pub const ProblemDetail = struct {
type_uri: []const u8 = "about:blank",
title: []const u8,
status: u16,
detail: ?[]const u8 = null,
instance: ?[]const u8 = null,
};
Use problemResponse to create a response from a ProblemDetail:
fn handler(allocator: std.mem.Allocator) !zigmund.Response {
return zigmund.problemResponse(allocator, .{
.title = "Not Found",
.status = 404,
.detail = "The requested item does not exist",
.instance = "/items/42",
});
}
This produces a response with:
- Status code: 404
- Content-Type: application/problem+json
- Body:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "The requested item does not exist",
"instance": "/items/42"
}
Convenience constructors
Zigmund provides shorthand functions for common HTTP error statuses:
// 404 Not Found
return zigmund.problemNotFound(allocator, "Resource not found");
// 400 Bad Request
return zigmund.problemBadRequest(allocator, "Invalid input");
// 401 Unauthorized
return zigmund.problemUnauthorized(allocator, "Authentication required");
// 403 Forbidden
return zigmund.problemForbidden(allocator, "Access denied");
// 409 Conflict
return zigmund.problemConflict(allocator, "Resource already exists");
// 422 Unprocessable Entity
return zigmund.problemUnprocessableEntity(allocator, "Validation failed");
// 500 Internal Server Error
return zigmund.problemInternalServerError(allocator, "Something went wrong");
Each of these accepts an allocator and an optional detail string (pass null
for no detail).
Comparison with FastAPI
FastAPI does not have built-in RFC 7807 support. You would typically implement it manually or use a library. In Zigmund, it is built into the framework:
# FastAPI -- manual RFC 7807
from fastapi.responses import JSONResponse
@app.exception_handler(NotFoundException)
async def not_found_handler(request, exc):
return JSONResponse(
status_code=404,
content={
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": str(exc),
},
media_type="application/problem+json",
)
// Zigmund -- built-in RFC 7807
fn notFoundHandler(
req: *zigmund.Request,
err: anyerror,
allocator: std.mem.Allocator,
) !zigmund.Response {
_ = req;
_ = err;
return zigmund.problemNotFound(allocator, "Resource not found");
}
Error Propagation with try
The try keyword in Zig is shorthand for "if this returns an error, return
that error from the current function." It replaces Python's pattern of letting
exceptions propagate up the call stack:
fn handler(allocator: std.mem.Allocator) !zigmund.Response {
// If Response.json fails (e.g., out of memory), the error
// propagates to the caller automatically.
return try zigmund.Response.json(allocator, .{ .ok = true });
}
Without try, you would need to handle the error explicitly:
fn handler(allocator: std.mem.Allocator) !zigmund.Response {
const response = zigmund.Response.json(allocator, .{ .ok = true }) catch |err| {
// Handle the error...
return err;
};
return response;
}
try is equivalent to the above catch |err| { return err; } pattern. It is
used pervasively in Zigmund handlers:
fn createItem(
item: zigmund.Body(ItemPayload, .{}),
allocator: std.mem.Allocator,
) !zigmund.Response {
const payload = item.value.?;
// `try` propagates any serialisation error
return try zigmund.Response.json(allocator, .{
.name = payload.name,
.price = payload.price,
});
}
Comparison with Python
In Python, exceptions propagate automatically unless caught:
def handler():
# If json.dumps fails, the exception propagates automatically
return JSONResponse(content={"ok": True})
In Zig, error propagation is always explicit. The try keyword makes it
concise, but you always see where errors can escape:
fn handler(allocator: std.mem.Allocator) !zigmund.Response {
// `try` makes propagation explicit and visible
return try zigmund.Response.json(allocator, .{ .ok = true });
}
Putting It All Together
Here is a complete example showing multiple error handling patterns:
const std = @import("std");
const zigmund = @import("zigmund");
// 1. Define custom error sets
const ItemError = error{
ItemNotFound,
ItemOutOfStock,
};
const AuthError = error{
InvalidToken,
TokenExpired,
};
// 2. Write handlers that return errors
fn getItem(
item_id: zigmund.Path(u32, .{ .alias = "item_id" }),
allocator: std.mem.Allocator,
) !zigmund.Response {
if (item_id.value.? == 0) {
return ItemError.ItemNotFound;
}
if (item_id.value.? == 999) {
return ItemError.ItemOutOfStock;
}
return try zigmund.Response.json(allocator, .{
.id = item_id.value.?,
.name = "Widget",
});
}
// 3. Write exception handlers
fn itemErrorHandler(
req: *zigmund.Request,
err: anyerror,
allocator: std.mem.Allocator,
) !zigmund.Response {
_ = req;
return switch (err) {
error.ItemNotFound => zigmund.problemNotFound(allocator, "Item not found"),
error.ItemOutOfStock => zigmund.problemConflict(allocator, "Item is out of stock"),
else => zigmund.problemInternalServerError(allocator, null),
};
}
fn authErrorHandler(
req: *zigmund.Request,
err: anyerror,
allocator: std.mem.Allocator,
) !zigmund.Response {
_ = req;
_ = err;
return zigmund.problemUnauthorized(allocator, "Invalid or expired token");
}
// 4. Register everything
pub fn setup(app: *zigmund.App) !void {
try app.addExceptionHandler(ItemError, itemErrorHandler);
try app.addExceptionHandler(AuthError, authErrorHandler);
try app.get("/items/{item_id}", getItem, .{});
}
Summary
| Concept | Python / FastAPI | Zig / Zigmund |
|---|---|---|
| Error mechanism | Exceptions (raise / except) | Error unions (!T, return error) |
| Error visibility | Not in type signature | Part of the return type |
| Error propagation | Automatic (bubbles up) | Explicit (try keyword) |
| Custom errors | Exception classes | Error sets (error{Name}) |
| Error-to-HTTP mapping | @app.exception_handler |
app.addExceptionHandler |
| Quick HTTP errors | raise HTTPException(...) |
HTTPException struct |
| Structured errors | Manual JSON | ProblemDetail (RFC 7807) |
| Performance cost | Stack unwinding, object creation | Zero cost for non-error path |
Zig's error handling model eliminates an entire class of bugs -- unhandled exceptions, missing error checks, and silent failures -- by making every error path visible in the type system. Zigmund builds on this foundation by providing familiar patterns for mapping application errors to HTTP responses.