Core

Errors

Map Go errors to status codes and control the error body.

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

ConstructorStatus
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

StatusProduced by
400Binding failure or validation failure, before the handler runs
401No credentials, or the auth provider rejected them
403Authenticated, but the route's role or permission rule said no
500An authorization resolver failed, or the handler returned a non-HTTP error
500A 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.

A resolver failure deliberately produces 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
The recovery response is 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.
Copyright © 2026