cicadel

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:

Go
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:

Go
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-Type is text/plain, which is what http.Error writes;
  • 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 Accept header, */* (curl, fetch()) or other types such as application/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:

FieldMeaning
StatusThe HTTP status. Code() returns it as text for templates.
Title, DescriptionFixed copy, such as “Page not found”.
Reasonerrorpages.ReasonCSRF for a failed CSRF check, otherwise empty.
RequestIDThe 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:

  1. is logged with its stack and the request ID;
  2. is passed to Options.Report, if set, for an error tracker;
  3. 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.

Go
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.

JavaScript
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

Go
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-Policy set by the failing handler still applies to the error page.
  • A panic recovered here is not seen by observability.Middleware, so its Event.Panicked is false. The request is still logged with status 500, and the panic is logged here with its stack.

Type to search this version.