exception

package
v0.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 21, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package exception is where a failed request stops.

The two ways a request fails, and the one place they meet

A handler either returns an error or panics. Both arrive here: Recover turns a panic into a value the Handler answers, and the routing layer hands the Handler whatever a controller action returned. One Handler, one decision about what the person in front of the browser sees.

Report and Render are two calls and the caller makes both. Render used to report on its way in as well, so an application written that way wrote every failure to the log twice. Recover is the exception and says so where it does it: a panic is news whatever it classifies as, so it logs what it caught itself.

Abort is a returned error, not a call that never comes back

There is no throw here and no global helper, so the equivalent is a value:

if invoice == nil {
	return exception.Abort(http.StatusNotFound, "no invoice with that number")
}

AbortIf and AbortUnless return nil when the condition does not call for a failure, which is what lets the caller write one line instead of the same if statement twice.

Abort builds an *HTTPError, StatusOf reads the status back out of an error chain, and classify is the closed table that says what the collection's own sentinels mean -- auth.ErrForbidden is 403, an expired CSRF token is 419. Before this existed, every error leaving a handler became 500, including the ones that had already said exactly what they were.

The table is closed on purpose. An application that wants a status says so with Abort; it does not get a second mechanism for the same sentence. Map is not that second mechanism: it turns somebody else's error -- a driver's, a library's -- into one of these, and the answer still comes from the one table.

The pages

Two kinds, and they never overlap. A status the framework recognises gets the status page -- 401, 403, 404, 405, 419, 429, 500, 503, and the standard text for anything else. An error nobody claimed gets the debug page in development, which is Ignition's idea with the Collector behind it: the stack with source snippets, the queries with their timing, the dumps, the events, and the hints -- the part that names the probable cause instead of only showing the data.

The two are the two Displayers: PlainDisplayer and DebugDisplayer.

An application overrides a status page by providing a view named errors/404, errors/403 and so on, and wiring it through Config.Views. Nothing is required: the built-in pages answer until somebody wants their own.

The JSON answer

A client that asked for JSON gets a Problem: the problem details document of RFC 9457, served as ProblemContentType. It is one shape, and WriteProblem is the one function that writes it, so a refusal from a middleware and a failure from a handler read the same to whoever parses them.

htmx is not that client. It sends X-Requested-With and swaps HTML, so wantsJSON excludes it: a problem document swapped into a div is a JSON document on the page.

What a test writes instead of a global fake

There is no registry to swap a handler in, and a package-level handler a test could swap would be shared mutable state that two tests calling t.Parallel would fight over.

The Handler a test holds is one it built, and the recording is a callback on it:

var reported []error
h := exception.NewHandler(exception.Config{})
h.Reportable(func(err error) { reported = append(reported, err) }).Stop()

placeOrder(ctx, g, h)

if len(reported) != 1 {
	t.Fatalf("reported %d failures, want 1", len(reported))
}

ReportableHandler.Stop is what makes it a fake rather than a bystander: reporting ends at the callback, so nothing reaches the log and the test output stays the failures the test itself printed.

Absolute rule

Nothing that reveals the inside of the process -- the debug page, a stack, an error string that is not an *HTTPError message -- may be reachable when Config.Dev is false.

Index

Constants

View Source
const ProblemContentType = "application/problem+json"

ProblemContentType is the media type a problem document is served as.

It is not application/json. A client that understands problem documents recognises this one and knows every member below without being told, and one that does not still parses it, because the "+json" suffix says how.

View Source
const StatusPageExpired = 419

StatusPageExpired is 419, which is not in any RFC.

403 would be the standard answer and it is the wrong one: it says the account may not do this, when the account may, and the form is simply old.

Variables

View Source
var Unlimited = Throttle{MaxAttempts: -1}

Unlimited is the throttle that reports the error every time.

It is what a ThrottleUsing callback returns when it has looked at the error and decided it wants no budget on it, and it is what the handler falls back to when no callback answered at all.

Functions

func Abort

func Abort(status int, message string) error

Abort builds a failure as a value rather than raising one.

return exception.Abort(http.StatusNotFound, "no invoice with that number")

There is no method on the request context, which is where the audit found the previous attempt at this -- a helper nothing could reach is a helper that does not exist. An error is reachable from every handler, from a service three calls down, and from a job.

An empty message means the standard sentence for the status, so the common case is exception.Abort(404, "").

To carry a cause, wrap it: fmt.Errorf("loading invoice: %w", exception.Abort(...)) keeps the status -- StatusOf walks the chain -- and puts the context in the log without putting it on the page.

func AbortIf

func AbortIf(condition bool, status int, message string) error

AbortIf is the abort_if() helper.

if err := exception.AbortIf(invoice.Locked, http.StatusConflict, "this invoice is closed"); err != nil {
	return err
}

Nothing here throws, so the caller returns the error -- which is why this reads as one line and not as the same if statement written twice.

func AbortUnless

func AbortUnless(condition bool, status int, message string) error

AbortUnless is the abort_unless() helper.

if err := exception.AbortUnless(invoice != nil, http.StatusNotFound, ""); err != nil {
	return err
}

func Recover

func Recover(h *Handler) pipeline.Middleware[http.Handler]

Recover is the middleware that turns a panic into a handled failure: it captures what escaped and hands it to the Handler. The only place to catch a panic is a deferred recover, which is why this is middleware rather than a hook installed once at boot.

Order matters and is not a matter of taste: Recover must be the outermost middleware, or a panic raised in any other middleware escapes without a page; the observability middleware must come right after it, because everything below depends on the context it builds.

In development the Handler draws the full debug page -- stack, request, queries, dumps. Anywhere else it draws the status page, which leaks nothing and carries the request id so the operator can correlate it with the structured log.

It returns a pipeline.Middleware[http.Handler] rather than naming http's alias, which is what lets this package produce middleware without importing the routing layer that will call it.

func StatusOf

func StatusOf(err error) (int, bool)

StatusOf reads an error chain and answers two things at once: the HTTP status the error asks for, and whether it asked at all.

It is what the routing layer calls with whatever a controller action returned. False means nobody claimed the error, which is a 500 and, in development, the debug page.

func WriteProblem added in v0.5.0

func WriteProblem(w http.ResponseWriter, r *http.Request, status int, detail string)

WriteProblem answers the request with a problem document, and is the only shape a JSON failure leaves this application in.

detail is shown to the caller, so it carries what was written for them and never what was written for the log: a driver's text, a policy's reason, the contents of a dereference. The response is marked not to be cached, because a refusal is one person's.

It writes the status and the body, so nothing may write to w afterwards.

Types

type Config

type Config struct {
	// Dev enables the debug page. It must be false anywhere the application is
	// reachable by somebody who is not running it.
	Dev bool

	// Editor is the target of the "open in IDE" links: vscode, cursor, goland
	// or zed.
	Editor string

	// AppModule is the module path of the application, used to tell app frames
	// from collection and stdlib frames.
	AppModule string

	// Diagnose collects what the registered modules have to say about the state
	// of the system right now. Pass the kernel's own.
	//
	// It exists because the most useful hint is often about something that
	// happened outside this request: the outbox has been stuck for four minutes,
	// the scheduler last ran an hour ago. A page that only looks at the request
	// cannot see any of it, and that is exactly the state where somebody is
	// staring at an error wondering what changed.
	Diagnose func(ctx context.Context) []string

	// Views is the application's own error pages, when it has any. Nil means the
	// built-in ones answer.
	Views Views

	// DontReport are the errors never written to the log.
	//
	// "Do not log 404" is the wrong shape: a 404 from a bad link and a 404 from a
	// repository that lost a row are the same status and different news.
	DontReport []error

	// RenderJSONWhen decides whether a failure is answered as JSON rather than
	// as a page. Nil means the default: the request asked for JSON.
	RenderJSONWhen func(r *http.Request) bool

	// Console says the process is running a command rather than serving
	// requests, which is what RunningInConsole reports.
	//
	// A Go binary knows which of the two it started as, so the kernel says it
	// here instead.
	Console bool
}

Config is everything the Handler needs from the application.

The zero value is a working production handler: no debug page, built-in status pages, everything reported. Development turns Dev on, which is the only switch that decides whether the inside of the process is visible.

type DebugDisplayer

type DebugDisplayer struct {
	// contains filtered or unexported fields
}

DebugDisplayer draws the debug page: the stack with source, the queries with their timing, the dumps, the events, and the hints that name the probable cause.

One debug page, named for what it is.

func (*DebugDisplayer) Display

func (d *DebugDisplayer) Display(w http.ResponseWriter, r *http.Request, err error)

Display draws the debug page for the failure.

It used to answer 500 always, which made a 404 in development a 500 to every client and to every test written against it -- the page is the same page either way, and the status is the error's answer rather than the page's.

type Displayer

type Displayer interface {
	Display(w http.ResponseWriter, r *http.Request, err error)
}

Displayer draws a failure.

type ErrorHandler

type ErrorHandler func(err error, status int, fromConsole bool) any

ErrorHandler is one callback on the handler stack.

type HTTPError

type HTTPError struct {
	// Status is the HTTP status to answer with.
	Status int
	// Message is the sentence the person sees. Empty means the standard text
	// for the status.
	Message string
	// Err is the cause, when there was one. It is reported and never shown.
	Err error
	// Headers are what the answer carries besides the status: the Retry-After
	// of a 429, the WWW-Authenticate of a 401.
	//
	// Nothing here had it, so a 429 went out with no Retry-After and a client had
	// nothing to obey. Abort does not take them -- the common failure has none --
	// so an answer that carries headers is written as the value it is:
	//
	//	&exception.HTTPError{
	//		Status:  http.StatusTooManyRequests,
	//		Headers: http.Header{"Retry-After": {"30"}},
	//	}
	Headers http.Header
}

HTTPError is an error that names the answer it wants.

It is what Abort returns and the only thing an application uses to choose a status. The Message is shown to whoever made the request, in every environment, so it is written for them: it is the developer's own sentence.

func (*HTTPError) Error

func (e *HTTPError) Error() string

Error is what makes an *HTTPError satisfy the error interface.

It carries the status because this string ends up in a log line, where the number is the first thing anybody looks for.

func (*HTTPError) Unwrap

func (e *HTTPError) Unwrap() error

Unwrap exposes the cause, so errors.Is and errors.As reach through it.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler decides what a failed request answers.

Everything an application registers on it -- Reportable, Renderable, Map, Ignore, Level, ThrottleUsing, BuildContextUsing -- is registered once at boot and read on every request, so the handler is safe for concurrent use.

func NewHandler

func NewHandler(cfg Config) *Handler

NewHandler builds a Handler. Nothing is resolved from a registry: the configuration arrives as a value.

func (*Handler) BuildContextUsing

func (h *Handler) BuildContextUsing(contextCallback func(err error, context map[string]any) map[string]any) *Handler

BuildContextUsing registers a callback that builds the fields logged with an error.

func (*Handler) Displayer

func (h *Handler) Displayer() Displayer

Displayer is the choice made before anything is drawn: the debug displayer when the application is in debug mode, the plain one otherwise.

There is only one of each here, so there is nothing to inject and the choice is the same choice.

func (*Handler) DontFlash

func (h *Handler) DontFlash(attributes ...string) *Handler

DontFlash keeps the given attributes from ever being carried back to a form after a validation failure.

func (*Handler) DontReport

func (h *Handler) DontReport(errs ...error) *Handler

DontReport is Ignore under its other name.

It is the alias of Ignore.

func (*Handler) DontReportDuplicates

func (h *Handler) DontReportDuplicates() *Handler

DontReportDuplicates makes an error reported at most once.

Go has no weak map and no exception instance: the key is the error value, which is the same identity for the pointer errors a Go program raises, and an error value that cannot be a map key is simply always reported.

func (*Handler) DontReportWhen

func (h *Handler) DontReportWhen(dontReportWhen func(err error) bool) *Handler

DontReportWhen registers a callback that decides whether an error is reported.

func (*Handler) Error

func (h *Handler) Error(callback ErrorHandler)

Error registers an application error handler, in front of the ones already registered.

func (*Handler) Fatal

func (h *Handler) Fatal(callback func(err error) any)

Fatal registers an error handler for fatal failures.

The same failure in Go is a panic, or an error no part of the collection claimed, and that is what this fires for.

func (*Handler) FlashableInput

func (h *Handler) FlashableInput(input map[string]any) map[string]any

FlashableInput is the input with every value that never goes back to a form removed: the secrets session.IsSecretField names, and whatever DontFlash was told on top of them.

The secrets are not listed here. This package carried its own three exact names -- current_password, password and password_confirmation -- which let token, otp, secret and every qualified name through. One question is answered in one place, and it is the place the flash cookie already asks.

This package answers failures, it does not route, so the removal is a method of its own and the redirect belongs to whoever builds it -- reading the property from another package.

func (*Handler) HandleConsole

func (h *Handler) HandleConsole(err error) any

HandleConsole handles an error raised by a command.

It runs the same handler stack with fromConsole set. The failure that nothing answered is written by RenderForConsole.

func (*Handler) HandleException

func (h *Handler) HandleException(w http.ResponseWriter, r *http.Request, err error) any

HandleException handles an exception for the application.

It runs the handler stack and, when none of them answered, hands the failure to the displayer.

func (*Handler) HandleUncaughtException

func (h *Handler) HandleUncaughtException(w http.ResponseWriter, r *http.Request, err error)

HandleUncaughtException handles the failure nobody caught.

It is Recover that calls this.

func (*Handler) Ignore

func (h *Handler) Ignore(errs ...error) *Handler

Ignore stops the given errors from being reported.

func (*Handler) Level

func (h *Handler) Level(target error, level slog.Level) *Handler

Level sets the log level for the given error.

Naming the same error twice replaces the level. This appended, and mapLogLevel reads from the front, so the first call won and the second was a line that did nothing. The entry keeps its place in the list.

func (*Handler) Map

func (h *Handler) Map(from error, to func(err error) error) *Handler

Map registers a new exception mapping.

h.Map(sql.ErrNoRows, func(err error) error {
	return exception.Abort(http.StatusNotFound, "")
})

There are no class names in Go, so the key is the sentinel and the match is errors.Is, which is how this collection asks "is this that error" everywhere else.

func (*Handler) Missing

func (h *Handler) Missing(callback func(err error) any)

Missing registers a 404 error handler.

There are no classes here, so the filter is the status the error classified as.

func (*Handler) PushError

func (h *Handler) PushError(callback ErrorHandler)

PushError registers an application error handler at the bottom of the stack.

func (*Handler) Register

func (h *Handler) Register(environment string) pipeline.Middleware[http.Handler]

Register installs the exception handling for the environment, and returns the middleware that catches what escapes.

Go has none of the three -- there are no warnings to promote to exceptions, no hook for what escaped, and no hook at shutdown. What is left is recover(), and Recover is the middleware that calls it, so this is where a kernel gets it from.

func (*Handler) Render

func (h *Handler) Render(w http.ResponseWriter, r *http.Request, err error)

Render writes the answer for a failed request instead of returning one.

It answers an error a handler returned.

This is the path that did not exist: every error leaving a controller became a panic, and every panic became 500, so an authorization refusal and a database being down were the same page. Now the error says what it is -- through Abort, or through the sentinel table in classify -- and what it says is the answer.

It does not report. Report and Render are the two halves the type doc separates, and the caller calls both. This used to report on the way in, so an application written that way logged every failure twice.

func (*Handler) RenderForConsole

func (h *Handler) RenderForConsole(w io.Writer, err error)

RenderForConsole is the answer to a failure outside a request -- a command, a job, a scheduled task.

The same classification, none of the HTML. A status is printed when the error claimed one, because a command that hits a 403 from a policy should say so rather than print a stack about a Grant.

func (*Handler) Renderable

func (h *Handler) Renderable(renderUsing any) *Handler

Renderable registers a renderable callback.

h.Renderable(func(err *PaymentDeclined, w http.ResponseWriter, r *http.Request) bool {
	...
	return true
})

func (*Handler) Report

func (h *Handler) Report(ctx context.Context, err error)

Report writes the failure to the log, unless something silenced it.

The level comes from the status and not from a knob: below 500 the application answered on purpose and it is a warning, 500 and above nobody meant it and it is an error. A framework where every 404 arrives at ERROR is a framework whose alerts get switched off. Level overrides that for a sentinel that deserves a different one.

func (*Handler) Reportable

func (h *Handler) Reportable(reportUsing any) *ReportableHandler

Reportable registers a reportable callback.

h.Reportable(func(err *QueryError) bool {
	telemetry.Record(err)
	return true
}).Stop()

A callback that returns false stops the reporting there.

func (*Handler) RespondUsing

func (h *Handler) RespondUsing(callback func(w http.ResponseWriter, r *http.Request, err error)) *Handler

RespondUsing registers the callback that prepares the final response.

Nothing here returns a response: the callback is given the writer the answer was written to, and it adds what it wants -- a header, a trace id -- after the fact.

func (*Handler) RunningInConsole

func (h *Handler) RunningInConsole() bool

RunningInConsole reports whether the process is running a command rather than serving requests.

A Go binary knows which of the two it started as, so the kernel says so in Config.Console and this reads it.

func (*Handler) SetDebug

func (h *Handler) SetDebug(debug bool)

SetDebug sets the debug level for the handler.

It is the same switch as Config.Dev, and it must be false anywhere the application is reachable by somebody who is not running it.

func (*Handler) ShouldRenderJSONWhen

func (h *Handler) ShouldRenderJSONWhen(callback func(r *http.Request, err error) bool) *Handler

ShouldRenderJSONWhen registers the callback that decides whether a failure is answered as JSON.

func (*Handler) ShouldReport

func (h *Handler) ShouldReport(err error) bool

ShouldReport reports whether the error would be written to the log.

func (*Handler) StopIgnoring

func (h *Handler) StopIgnoring(errs ...error) *Handler

StopIgnoring removes the given errors from the list of ignored ones.

func (*Handler) ThrottleUsing

func (h *Handler) ThrottleUsing(throttleUsing any) *Handler

ThrottleUsing registers a callback that decides how often an error may be reported.

h.ThrottleUsing(func(err *QueryError) exception.Throttle {
	return exception.Throttle{MaxAttempts: 10, Decay: time.Minute}
})

The callback is matched to the error by the type of its first parameter, the same way Reportable's is.

type PageData

type PageData struct {
	// Status is the HTTP status being answered.
	Status int
	// Title is the short name of the status: "Not Found", "Page Expired".
	Title string
	// Message is the sentence written for the person reading it.
	Message string
	// RequestID ties the page to the log line for this request. Empty when
	// nothing upstream assigned one.
	RequestID string
}

PageData is what a status page is given.

It is a struct and not a map, because every view in this collection takes a typed struct: a map renders blank on a typo, and blank is what nobody debugs. An application that provides its own errors/404 declares its page data as this type.

type PlainDisplayer

type PlainDisplayer struct {
	// contains filtered or unexported fields
}

PlainDisplayer draws the status page: the standard sentence for the status and nothing about the inside of the process.

It is what answers anywhere the application is reachable by somebody who is not running it, which is why it is the one that leaks nothing.

func (*PlainDisplayer) Display

func (d *PlainDisplayer) Display(w http.ResponseWriter, r *http.Request, err error)

Display draws the status page for the failure.

This draws the same page the status path draws, with the sentence for the status, and it copies the headers too: the sentence said there were none to copy, and an *HTTPError has carried them since -- Retry-After on a 429 is the difference between a client that backs off and one that hammers.

type Problem added in v0.5.0

type Problem struct {
	// Type names the class of failure as a URI, and is "about:blank" when the
	// status code says all there is to say. A client that matches on it gets
	// the same answer for every problem written here, which is why it should
	// match on Status instead.
	Type string `json:"type"`

	// Title is the short, stable name of the failure: "Not Found", "Page
	// Expired". It does not change between occurrences of the same status, so
	// it is the member to group by.
	Title string `json:"title"`

	// Status is the HTTP status, repeated in the body so that a document
	// separated from its response -- logged, forwarded, stored -- still says
	// what it was.
	Status int `json:"status"`

	// Detail is the sentence written for the person reading it, and it is
	// specific to this occurrence. It is empty when there is nothing to add to
	// the title.
	Detail string `json:"detail,omitempty"`

	// Instance is the address the failure happened at, as a URI reference.
	Instance string `json:"instance,omitempty"`

	// RequestID ties the document to the log line that holds the cause. It is
	// the extension member, and it is the one thing here worth quoting in a
	// support conversation: the detail is deliberately vague, and this is not.
	RequestID string `json:"request_id,omitempty"`
}

Problem is the body of an error response: the problem details document of RFC 9457.

It is one shape for every failure answered as JSON, which is what a client gets to rely on. A client reads Status to decide what to do, Title to know which failure it is, Detail to show somebody, and RequestID to quote when it asks what happened.

The members are the ones the RFC names, plus RequestID, which it allows as an extension. Detail is the only one written for a person, and it is the one that carries nothing the caller was not allowed to see.

type ReportableHandler

type ReportableHandler struct {
	// contains filtered or unexported fields
}

ReportableHandler is one callback registered with Reportable.

It is returned so the registration can be continued: Stop says that reporting ends with this callback rather than falling through to the log.

func (*ReportableHandler) Handles

func (r *ReportableHandler) Handles(err error) bool

Handles reports whether the callback handles the given error.

A Go closure carries the same information in the type of its first parameter, and it is read the same way: a callback written func(*HTTPError) bool handles whatever errors.As can pull an *HTTPError out of, and one written func(error) bool handles everything.

func (*ReportableHandler) Stop

Stop makes report handling stop after invoking this callback.

type StackFrame

type StackFrame struct {
	Func    string
	File    string
	Line    int
	IsApp   bool     // false for the runtime, the stdlib and the collection itself
	Snippet []string // surrounding lines, rendered inline
	SnipTop int      // line number of the first snippet line
}

StackFrame is one frame of the stack, already enriched with the source snippet around the failing line.

func Capture

func Capture(skip int, appModule string) []StackFrame

Capture reads the stack, folding the source snippet and whether the frame is the application's own into each entry.

It collects the stack from skip onwards, marking which frames belong to the application. Same decision as Ignition: application frames are expanded by default and everything else is collapsed.

appModule is the caller's module path. When empty, every frame that is not runtime, stdlib or part of this collection counts as application code.

type Throttle

type Throttle struct {
	// Key groups the errors that share a budget. Empty means one budget per error
	// type.
	Key string
	// MaxAttempts is how many reports fit in the window.
	//
	// Zero is zero: the error is never reported. Unlimited is how a callback says
	// it wants no budget.
	MaxAttempts int
	// Decay is how long the window lasts. Zero means a minute.
	Decay time.Duration
}

Throttle is how often an error may be reported.

Two replicas therefore throttle separately, which is the honest cost of not depending on a shared store from here.

The zero value reports nothing, because zero attempts is zero attempts. Unlimited is the one that throttles nothing.

type Views

type Views interface {
	// Has reports whether a view of that name is registered.
	Has(name string) bool
	// Render draws it with the status already decided.
	Render(ctx context.Context, w http.ResponseWriter, status int, name string, data any) error
}

Views is the application's own error pages, when it has any.

It is declared here rather than imported because the view layer sits above this package and importing it back would be a cycle -- the same reason http.Renderer is declared where it is consumed. The view package satisfies it without knowing this package exists.

Has is what makes the fallback safe: this package asks before it renders, so an application that has an errors/404 gets its own page and one that has not gets the built-in one, with no guessing from a failed render.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL