Errors
Return an error from a handler and ezz writes the response. If the error is an *ezz.HTTPError, its status and message are used. Anything else becomes a 500 with a generic message, so an internal failure never leaks its text to the caller.
Return a typed error
app.GET("/users/{id}", ezz.H(func(ctx *ezz.Context, req GetUserRequest) (*User, error) {
user, err := store.Find(ctx, req.ID)
if errors.Is(err, ErrNotFound) {
return nil, ezz.NotFound("user not found")
}
if err != nil {
return nil, err // 500, message hidden
}
return user, nil
}))
Every error response has the same shape:
{
"error": true,
"message": "user not found",
"status": 404
}
Constructors
| Constructor | Status |
|---|---|
ezz.BadRequest(msg) | 400 |
ezz.Unauthorized(msg) | 401 |
ezz.Forbidden(msg) | 403 |
ezz.NotFound(msg) | 404 |
ezz.Conflict(msg) | 409 |
ezz.UnprocessableEntity(msg) | 422 |
ezz.InternalServerError(msg) | 500 |
ezz.NewHTTPError(status, msg) | Anything else |
return nil, ezz.NewHTTPError(http.StatusTeapot, "no coffee here")
Two sentinel values are also exported and used by the auth rules: ezz.ErrUnauthorized and ezz.ErrForbidden.
Keep the cause without exposing it
WithInternal attaches the underlying error for your own logging while the client still sees only the public message.
if err := payments.Charge(ctx, order); err != nil {
return nil, ezz.UnprocessableEntity("payment declined").WithInternal(err)
}
The wrapped error shows up in err.Error() as "payment declined: <cause>", which is what your logging middleware sees, while the response body carries just "payment declined".
Know which layer produced the status
| Status | Produced by |
|---|---|
| 400 | Binding failure or validation failure, before the handler runs |
| 401 | No credentials, or the auth provider rejected them |
| 403 | Authenticated, but the route's role or permission rule said no |
| 500 | An authorization resolver failed, or the handler returned a non-HTTP error |
| 500 | A panic, caught by the recovery middleware |
Auth failures are written by the app before your handler is reached, with the same JSON shape and the messages Unauthorized, Forbidden, and Authorization failed.
500, not 403. If your permission store is down, the request has not been denied, it is unknown, and a 403 would tell the caller something false. See Roles and permissions.Write an error response directly
ctx.Error does the same mapping if you want to send an error without returning one:
if !ok {
return nil, ctx.Error(ezz.Forbidden("not your document"))
}
Handle panics
ezz.New() installs Recovery, which logs the panic with the method and path and replies 500.
level=ERROR msg="Panic recovered" error="runtime error: index out of range [3]" method=GET path=/users/42
text/plain (Internal Server Error), not the JSON error body used elsewhere. If your clients require JSON on every path, install your own recovery middleware instead of relying on the default.func JSONRecovery(logger *slog.Logger) ezz.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
logger.Error("panic", "value", v, "path", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":true,"message":"internal server error","status":500}`))
}
}()
next.ServeHTTP(w, r)
})
}
}
Note that a panic after the response has started writing cannot change the status; the connection is simply closed mid-body.
Centralise error mapping
For a large service, translate domain errors once instead of in every handler:
func httpError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, ErrNotFound):
return ezz.NotFound("not found")
case errors.Is(err, ErrConflict):
return ezz.Conflict("already exists")
case errors.Is(err, ErrPermission):
return ezz.Forbidden("not allowed")
default:
return ezz.InternalServerError("internal error").WithInternal(err)
}
}
app.GET("/users/{id}", ezz.H(func(ctx *ezz.Context, req GetUserRequest) (*User, error) {
user, err := store.Find(ctx, req.ID)
return user, httpError(err)
}))
Returning a non-nil value alongside an error is safe: the error wins and the value is discarded.
Next steps
- Middleware for recovery, logging, and the rest of the chain.
- Auth rules for where 401 and 403 come from.