Skip to content
Zever
Home

Errors

Match typed errors, map them to stable HTTP statuses, and keep secrets out of error text.

2 min readEdit on GitHub

Errors are typed values with stable codes. Handlers match the error, pick a status, and log once (see Logging). There is no config block or accessor: errors come from services you resolved, and config.Default() returns the same typed values as production.

Match with errors.Is, write a stable message, and log only the unexpected branch:

handlers.go
func showUser(w http.ResponseWriter, r *http.Request) {
user, err := findUser(r.Context(), id)
if errors.Is(err, db.ErrNotFound) {
writeError(w, http.StatusNotFound, "user not found")
return
}
if err != nil {
logger.Error().Err(err).Msg("find user failed")
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, user)
}
Terminal window
curl -s localhost:8080/v1/users/missing -w "\n%{http_code}\n"
{"error":"user not found"}
404

Log once, at the boundary. Libraries add context with %w; handlers choose the status. writeJSON and writeError come from Requests and responses. A helper that returns true lets the caller skip the generic 500. Wrap with %w to keep identity, ORM failures carry orm: and orm/render: prefixes, and the DSL mirrors apperror codes in ir.ErrorCode.

.zen RPCs declare their error set (errors: { already_exists }) so clients and OpenAPI know which codes to expect.

Error Status Envelope
db.ErrNotFound 404 {"error":"user not found"}
dialect.ErrUnsupportedByDialect 400 {"error":"query not supported by this database"}
already_exists (RPC error set) 409 {"error":"already exists"}
Rate-limit deny 429 plus Retry-After {"error":"rate limit exceeded"}
Handler timeout (middleware.Timeout) 504 {"error":"request timed out"}
Handler panic (middleware.Recover) 500 {"error":"internal error"}
Anything else 500 {"error":"internal error"}

Also in use: 400 unparsable, 422 invalid (Validation).

c.Close(ctx) returns an errors.Join of per-service errors. Match container.ClosePanicError and container.CloseTimeoutError (value types) with errors.As, then log the joined error once at exit:

var pe container.ClosePanicError
if errors.As(err, &pe) { /* a service panicked during Close */ }

Error text names services and fields, never values. The same rule covers logs and displayed config: zever config show prints redacted config. Render config only through config.RedactedServices.

errors.Is(err, db.ErrNotFound) never matches

A layer dropped the chain with %v or a new error. Wrap with %w at every layer.

A 500 body leaks internals

The handler writes err.Error() to the client. Write a stable slug and log the detail.

A dialect failure looks like a bug

A postgres-only feature on sqlite returns dialect.ErrUnsupportedByDialect by design. Match it and use a portable formulation.

The shutdown error is opaque

You print only the first error. Close joins all of them: log the full value, then use errors.As for panic and timeout cases.