- Docs
- HTTP APIs
- Errors
Errors
Match typed errors, map them to stable HTTP statuses, and keep secrets out of error text.
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.
Quick start
Section titled “Quick start”Match with errors.Is, write a stable message, and log only the unexpected branch:
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)}curl -s localhost:8080/v1/users/missing -w "\n%{http_code}\n"{"error":"user not found"}404Log 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.
Map errors to statuses
Section titled “Map errors to statuses”| 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).
Handle shutdown errors
Section titled “Handle shutdown errors”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.ClosePanicErrorif errors.As(err, &pe) { /* a service panicked during Close */ }Keep secrets out of error text
Section titled “Keep secrets out of error text”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.
Troubleshooting
Section titled “Troubleshooting”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.