Show error pages
Browsers get a styled HTML page for 404s, 500s and other errors, and a handler
panic becomes a 500 page instead of a dropped connection. Handlers keep
reporting errors with http.Error or response.Error.
#Generated projects
startproject wires this already. project/errors.go installs the middleware
and project/errors.templ holds the page:
func GlobalMiddleware(logs *slog.Logger) []cicadel.Middleware {
return []cicadel.Middleware{
observability.Middleware(logs, nil),
errorpages.Middleware(errorpages.Options{Page: ErrorPage, Logger: logs}),
}
}
func ErrorPage(r *http.Request, e errorpages.Error) ui.Content {
stylesheet, err := static.URL(r.Context(), "project", "app.css")
if err != nil {
stylesheet = ""
}
return ui.Fragment(errorPage(stylesheet, e))
}Edit project/errors.templ to change the design. Run go tool cicadel ui build
afterwards, as for any component.
#Add it to an existing project
Pass the middleware to cicadel.New, after observability.Middleware so pages
and logs carry the request ID:
app, err := cicadel.New(s, apps,
observability.Middleware(logs, nil),
errorpages.Middleware(errorpages.Options{Page: ErrorPage, Logger: logs}),
)Without Page, the middleware uses errorpages.Default: the minimal
ui/layout document with the status, a title, a description and a link home.
#What gets a page
The middleware replaces a response when all of these hold:
- the status is 400 or higher;
- its
Content-Typeistext/plain, which is whathttp.Errorwrites; - the client explicitly accepts
text/html, as browser navigations and htmx requests do.
That covers the router’s own 404 and 405 responses, http.Error in your
handlers, CSRF failures and MaxBytesError handling. Other headers, such as
Allow on a 405, are kept.
These pass through unchanged:
- responses you render yourself, in HTML, JSON or any other type;
- API clients: no
Acceptheader,*/*(curl,fetch()) or other types such asapplication/json; - successful and redirect responses.
Good to know
Pages show fixed copy for the status. The text a handler passed to
http.Error is never displayed, so a database error message cannot leak
into a page.
#The page data
Page receives an errorpages.Error:
| Field | Meaning |
|---|---|
Status | The HTTP status. Code() returns it as text for templates. |
Title, Description | Fixed copy, such as “Page not found”. |
Reason | errorpages.ReasonCSRF for a failed CSRF check, otherwise empty. |
RequestID | The observability request ID, when that middleware runs outside. |
errorpages.New(status, reason) builds the same value, for tests or for pages
you render yourself. A CSRF failure has the title “Form expired” and tells the
user to reload the page.
#Panics
A panic in a handler or inner middleware:
- is logged with its stack and the request ID;
- is passed to
Options.Report, if set, for an error tracker; - becomes a 500 page, with any headers the handler set for its own
representation (
Content-Type,Content-Disposition) removed.
If the handler had already sent part of its response, the page cannot replace
it. The middleware then aborts the connection with http.ErrAbortHandler, so
the client never mistakes a truncated response for a complete one.
errorpages.Options{
Page: ErrorPage,
Logger: logs,
Report: func(r *http.Request, recovered any, stack []byte) {
sentry.CurrentHub().Recover(recovered) // any tracker
},
}#htmx requests
An htmx partial request never receives a full page, because htmx would swap it into the target element. Instead the response keeps its status and carries:
HX-Reswap: none, so the page stays as it is;HX-Trigger: {"cicadel:error":{"status":403,"title":"Access denied"}}.
Listen for the event in a static script to show a message. Inline handlers such
as hx-on need unsafe-eval, which the default content security policy blocks.
document.addEventListener("cicadel:error", (event) => {
showToast(event.detail.title); // your own notification code
});Full-page htmx navigations, such as boosted links and history restores, get the page.
#Test it
app := apptest.New(t, s, apps, GlobalMiddleware(slog.New(slog.DiscardHandler))...)
apptest.NewClient(app).
Request(t, "GET", "/missing", nil, http.Header{"Accept": {"text/html"}}).
Status(t, 404).
Contains(t, "Page not found")#Limits
- Requests rejected before your middleware runs keep plain text: a host not in
AllowedHosts(400) and the framework’s last-resort panic recovery. - A page-specific
Content-Security-Policyset by the failing handler still applies to the error page. - A panic recovered here is not seen by
observability.Middleware, so itsEvent.Panickedis false. The request is still logged with status 500, and the panic is logged here with its stack.