Handlers never write partial error responses. They return an error, and the error handler renders the right response for the client, logs it and keeps internals out of client output.
func (p *PostController) Show(c *web.Ctx) error {
post, err := p.posts.Find(c.Context(), c.Param("post"))
if errors.Is(err, store.ErrMissing) {
return errs.ErrNotFound
}
if err != nil {
return fmt.Errorf("find post: %w", err) // 500, cause logged, never shown
}
return c.JSON(http.StatusOK, post)
}The errs package#
HTTPError#
type HTTPError struct {
Status int // HTTP status
Message string // safe to show to clients; empty means the status text
Err error // internal cause: logged, never shown
Headers http.Header // added to the response (Allow, Retry-After…)
Key string // i18n key translating Message per request (errs.NewKey)
Params map[string]any // {name} placeholders of Key
}
errs.New(http.StatusConflict, "That slug is taken.")
errs.NewKey(http.StatusConflict, "posts.slug_taken", "That slug is taken.", nil) // translated in the request locale
errs.Wrap(http.StatusBadGateway, err)
errs.ErrTooManyRequests.WithHeader("Retry-After", "30")Sentinel errors#
ErrBadRequest (400), ErrUnauthorized (401), ErrForbidden (403), ErrNotFound (404), ErrMethodNotAllowed (405), ErrConflict (409), ErrPreconditionFailed (412), ErrRequestTooLarge (413), ErrUnsupportedMediaType (415), ErrUnprocessable (422), ErrTooManyRequests (429), ErrInternal (500).
errors.Is matches any *HTTPError with the same status, even when wrapped or re-created with a cause:
errors.Is(errs.Wrap(404, sql.ErrNoRows), errs.ErrNotFound) // trueValidationError#
verr := errs.NewValidation().
Add("email", "The email is invalid.").
Add("password", "The password must be at least 12 characters.")
verr.First("email") // "The email is invalid."
verr.Empty() // false
return verr // 422Add returns the error for chaining, so errcheck flags it as a bare statement. Use Append (no result) when collecting messages in a loop or conditional:
verr := errs.NewValidation()
if locked {
verr.Append("price", "The price cannot change after the first sale.")
}
if !verr.Empty() {
return verr
}Status mapping#
errs.Status(err) returns the status of an *HTTPError, 422 for a *ValidationError, the status of any error implementing errs.StatusCoder (HTTPStatus() int), and 500 otherwise.
Content negotiation#
web.DefaultErrorHandler renders by client:
| Client | Response |
|---|---|
Wants JSON (Accept has JSON and not HTML, or a JSON body with no preference) | application/problem+json; 422 includes the errors map |
| Classic form post with a validation error | Validation hook: flash errors and redirect back (installed by the session package); GET/HEAD requests get the 422 page instead |
htmx (HX-Request: true) | HTML fragment with the status (htmx 4 swaps 4xx) |
| Browser | HTML page: the view errors/<status> when a Renderer has it, else a built-in page |
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"instance": "/api/posts/99",
"request_id": "fJ3k…"
}Services.ProblemBaseURL prefixes the validation problem type URI (<base>/validation); otherwise it is about:blank.
What clients see#
- 4xx: the
HTTPErrormessage, translated from itsKeyin the request locale when the app uses localization. - 5xx: only the status text, unless
Services.Debugis set (never in production). - Every error is logged with method, path, status and request id: 5xx at
ERROR, the rest atDEBUG. - If the response was already written, the error is only logged.
Custom handler#
services := &web.Services{
Errors: func(c *web.Ctx, err error) {
if errors.Is(err, billing.ErrPaymentRequired) {
_ = c.Problem(http.StatusPaymentRequired, "Payment required", "Upgrade your plan.")
return
}
web.DefaultErrorHandler(c, err)
},
}Middleware renders errors with the same handler through web.Fail(w, r, err).
Panics#
middleware.Recover() converts panics to 500 responses through the error handler and logs the stack trace server-side only.
Database errors#
orm.ErrNotFound (returned by First and Find) matches errs.ErrNotFound, so returning it answers 404. Unique-constraint violations are wrapped in orm.ErrConflict and answer 409.
Debug error page#
With APP_DEBUG=true outside production, 5xx errors in a browser render a debug page: the error message and root type, the Unwrap chain, the stack with source snippets (app frames open, framework and standard library frames collapsed), the request (method, URL, route, request id, headers with Authorization, Cookie and token headers redacted) and the Go version. Stacks come from recovered panics and from errors created with errs.New, Wrap or WithStack. JSON clients keep getting problem+json (with the error text as detail) and htmx gets the fragment. vukem serve prints the same report in colour on stderr. The page never renders in production.