Documentation
¶
Overview ¶
Package errorhandling reports an error once, with everything it carries, and tells the caller what exit code to use.
It builds on gitlab.com/phpboyscout/go/errors: hints, structured attributes, stacks and a stable kind travel on the error itself and reach a log record through slog.LogValuer. This module adds only what the PROCESS knows and the error cannot — the support message from HelpConfig, and the caller's prefix.
Nothing here exits ¶
ErrorHandler.Fatal returns an exit code; main decides. A library calling os.Exit skips every deferred cleanup between itself and main.
A terminal error carries its own disposition ¶
Outcome says how an error ends — its exit code, how loudly to report it, whether to print usage — declared beside the sentinel it describes. Zero is a legitimate code: an outcome can be terminal and successful.
See spec 0002 on this project's wiki for the decisions and what was rejected.
Index ¶
- Constants
- Variables
- func ExitCode(err error) int
- func NewAssertionFailure(format string, args ...any) error
- func NewErrNotImplemented(issueURL string) error
- func Prefix(parts ...string) string
- func UnknownSubCommand(verb, path string) error
- func WithExitCode(err error, code int) error
- func WithOutcome(err error, o Outcome) error
- type ErrorHandler
- type HelpConfig
- type Outcome
- type ReportOption
- type StandardErrorHandler
- func (h *StandardErrorHandler) Error(ctx context.Context, err error, opts ...ReportOption)
- func (h *StandardErrorHandler) Fatal(ctx context.Context, err error, opts ...ReportOption) int
- func (h *StandardErrorHandler) SetUsage(usage func() error)
- func (h *StandardErrorHandler) Warn(ctx context.Context, err error, opts ...ReportOption)
Examples ¶
Constants ¶
const ( // KeyError is the group an error is logged under. KeyError = "err" // KeyHelp carries the support message from [HelpConfig]. KeyHelp = "help" // KeyPrefix carries the caller-supplied prefix. KeyPrefix = "prefix" // KeyStacktrace carries the stack, at debug only. KeyStacktrace = "stacktrace" )
Keys this module adds to a log record. Everything the ERROR carries — its message, kind, hints, details and attributes — arrives under KeyError via slog.LogValuer, so none of that needs a key here.
const DefaultStackDepth = 20
DefaultStackDepth bounds a reported stack when the caller names no bound.
Twenty frames is deep enough to cross several packages and still show where the failure happened, and shallow enough that a terminal stays readable. It is deliberately smaller than what go/errors captures: capture is bounded so that making an error in a hot path is cheap, reporting is bounded so that reading one is possible, and those are different questions.
Raise it, or remove the bound entirely, with WithStackDepth.
const ExitCodeKind = "errorhandling.exit_code"
ExitCodeKind identifies an attached exit code to anything reading the error through the introspection contract.
const ExitCodeUsage = 2
ExitCodeUsage is the process exit code used when a fatal-level report is a usage/special error (a subcommand-required or not-yet-implemented error). It follows the conventional Unix "command misuse" convention (2) — distinct from the generic failure code (1) — so scripts can tell an invalid invocation from an ordinary runtime failure.
const OutcomeKind = "errorhandling.outcome"
OutcomeKind identifies an attached Outcome to anything reading the error through the introspection contract — a log record, a span, a wire codec.
Variables ¶
var ( // ErrNotImplemented marks a command that exists but does nothing yet. ErrNotImplemented = WithOutcome( errors.NewSentinel("errorhandling.not_implemented", "command not yet implemented"), Outcome{Code: ExitCodeUsage, Level: slog.LevelWarn}, ) // ErrRunSubCommand marks a parent command invoked without a subcommand, // where being invoked without one is itself the mistake. // // That is a choice, not a rule: a parent that only groups its children can // equally treat a bare invocation as a request for help and succeed. Return // this when the command genuinely cannot act on its own. ErrRunSubCommand = WithOutcome( errors.NewSentinel("errorhandling.run_subcommand", "subcommand required"), Outcome{Code: ExitCodeUsage, Level: slog.LevelWarn, Usage: true}, ) // ErrUnknownSubCommand marks a parent command given a verb it does not have. // // Cobra reports an unknown command for the root only, so a parent that wants // to catch a mistyped subcommand has to do it in its own run function. Wrap // this with the offending verb and the command path: // // errors.Wrapf(errorhandling.ErrUnknownSubCommand, // "unknown command %q for %q", args[0], cmd.CommandPath()) // // Distinct from ErrRunSubCommand: there, no subcommand was given at all; // here, one was, and it does not exist. ErrUnknownSubCommand = WithOutcome( errors.NewSentinel("errorhandling.unknown_subcommand", "unknown subcommand"), Outcome{Code: ExitCodeUsage, Level: slog.LevelWarn, Usage: true}, ) // ErrAssertionFailure marks a violated internal invariant — a bug in the // program rather than a mistake by its user. // // It used to be reported on a second log line saying so. It no longer needs // one: the kind is on the record, which a query can filter on and a string // prefix cannot. ErrAssertionFailure = errors.NewSentinel( "errorhandling.assertion_failure", "internal invariant violated") )
Functions ¶
func ExitCode ¶
ExitCode returns the exit code attached to err via WithExitCode. It returns 0 for a nil error and 1 for any error without an attached code. When codes are attached at multiple levels, the outermost (most recently applied) attachment wins, matching errors.As traversal order.
func NewAssertionFailure ¶
NewAssertionFailure returns an error denoting a bug in the program.
func NewErrNotImplemented ¶
NewErrNotImplemented returns an unimplemented-command error carrying a link to the issue tracking the work.
The URL is an attribute rather than a bespoke payload type, so it reaches a log record and a span without this module doing anything.
func Prefix ¶ added in v0.2.0
Prefix joins prefix fragments, for a caller assembling one from parts.
func UnknownSubCommand ¶ added in v0.5.0
UnknownSubCommand reports a parent command given a verb it does not have, naming the verb and the command that rejected it.
It exists so the message and the sentinel do not drift between CLIs. The cobra glue that calls it cannot live here — this module deliberately imports no CLI framework, and a module holding eight lines of glue would not earn its keep — but the half worth sharing is the wording and the identity, not the closure:
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return errorhandling.UnknownSubCommand(args[0], cmd.CommandPath())
}
return cmd.Usage()
}
func WithExitCode ¶
WithExitCode attaches a process exit code to err. The ErrorHandler's fatal path uses the attached code instead of the default 1, letting callers thread non-standard exit codes (for example the Unix 128+signum convention for signal-terminated runs) through the single exit path without a parallel os.Exit call site. Returns nil when err is nil.
func WithOutcome ¶ added in v0.2.0
WithOutcome attaches a terminal disposition to err. Returns nil when err is nil, so it composes at a sentinel declaration without a guard.
var ErrUpdateComplete = errorhandling.WithOutcome(
errors.NewSentinel("gtb.update_complete", "update complete — restart required"),
errorhandling.Outcome{
Code: 0,
Level: slog.LevelWarn,
Message: "update complete — please run the command again",
},
)
Example ¶
A terminal outcome is declared beside the sentinel it describes, so the error carries its own disposition instead of the handler switching on it.
package main
import (
"context"
"fmt"
"log/slog"
"os"
"slices"
"gitlab.com/phpboyscout/go/errors"
"gitlab.com/phpboyscout/go/errorhandling"
)
func main() {
errUpdateComplete := errorhandling.WithOutcome(
errors.NewSentinel("example.update_complete", "update complete — restart required"),
errorhandling.Outcome{
// Zero is legitimate: this outcome is terminal AND successful.
Code: 0,
Level: slog.LevelWarn,
Message: "update complete — please run the command again",
},
)
// The error arrives as a group, so suppressing it here means matching on the
// group name its attributes are nested under, not on a top-level key.
quiet := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelWarn,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey || slices.Contains(groups, errorhandling.KeyError) {
return slog.Attr{}
}
return a
},
}))
handler := errorhandling.New(quiet, nil)
fmt.Println("exit code:", handler.Fatal(context.Background(), errUpdateComplete))
}
Output: level=WARN msg="update complete — please run the command again" exit code: 0
Types ¶
type ErrorHandler ¶
type ErrorHandler interface {
// Fatal reports a terminal error and returns the exit code to use. A nil
// error reports nothing and returns 0.
Fatal(ctx context.Context, err error, opts ...ReportOption) int
// Error reports a non-terminal failure.
Error(ctx context.Context, err error, opts ...ReportOption)
// Warn reports something worth saying that is not a failure.
Warn(ctx context.Context, err error, opts ...ReportOption)
// SetUsage registers the function used to print usage for an error whose
// [Outcome] asks for it. CLI frameworks supply their own printer — with
// Cobra that is SetUsage(cmd.Usage), typically per-command in pre-run so
// the usage shown belongs to the command that actually failed.
SetUsage(usage func() error)
}
ErrorHandler reports an error once, with everything it carries.
Nothing here exits the process ¶
ErrorHandler.Fatal returns the exit code it believes the process should use; main decides what to do with it. A library calling os.Exit skips every deferred cleanup between itself and main — which cost go-tool-base a sync.Once and a manual flush before every fatal path, covering the one cleanup somebody noticed.
func New ¶
func New(l *slog.Logger, help HelpConfig) ErrorHandler
New creates an ErrorHandler. A nil help config disables the support message.
type HelpConfig ¶
type HelpConfig interface {
SupportMessage() string
}
HelpConfig supplies contextual support information to attach to reported errors — typically "contact <team> via <channel>" for whatever support channel your organisation uses.
It is deliberately the only help-related type in this module: the extension point, with no opinion about where a team's support channel lives. Slack, Teams, an on-call rota, a wiki URL, or a message assembled from configuration are all just implementations you supply.
Returning an empty string suppresses the help output entirely, so an implementation can stay silent when it has nothing useful to say (for example when its channel is not configured yet):
type slackHelp struct{ team, channel string }
func (s slackHelp) SupportMessage() string {
if s.team == "" || s.channel == "" {
return "" // not configured — say nothing
}
return fmt.Sprintf("For assistance, contact %s via Slack channel %s", s.team, s.channel)
}
Pass an implementation to New; passing nil is valid and disables help output.
Example ¶
package main
import (
"fmt"
"gitlab.com/phpboyscout/go/errorhandling"
)
// slackHelp is an example HelpConfig implementation. The module ships only the
// interface, so an application defines whatever support channel it actually
// uses — Slack here, but equally Teams, an on-call rota, or a wiki URL.
type slackHelp struct {
Team string
Channel string
}
func (s slackHelp) SupportMessage() string {
if s.Team == "" || s.Channel == "" {
return ""
}
return fmt.Sprintf("For assistance, contact %s via Slack channel %s", s.Team, s.Channel)
}
func main() {
var help errorhandling.HelpConfig = slackHelp{
Team: "mycompany",
Channel: "#dev-support",
}
fmt.Println(help.SupportMessage())
}
Output: For assistance, contact mycompany via Slack channel #dev-support
type Outcome ¶ added in v0.2.0
type Outcome struct {
// Code is the process exit code.
//
// Zero is legitimate and meaningful: an outcome can be terminal AND
// successful. A completed self-update is exactly that — the run must stop,
// and nothing went wrong.
Code int
// Level is how loudly to report it. A user-initiated stop is not an error
// and an interrupt is not a failure, so neither should log like one.
Level slog.Level
// Message replaces the error's own text, for when that text is machinery
// rather than something a user should read. Empty keeps the error's own.
Message string
// Usage prints the command's usage through the [ErrorHandler.SetUsage] seam
// before reporting.
//
// This is the one presentation this module already owns, which is why it is
// a bool rather than a callback: an Outcome carrying arbitrary behaviour
// would make an error a place to hide control flow.
Usage bool
}
Outcome is how a terminal error should be presented and what exit code it yields.
Why this is data rather than a branch ¶
This module used to hold a closed switch over three sentinels it happened to know about. A consumer with its own "stop here, and here is what it means" error had nowhere to put it — which is why go-tool-base handled its update-complete case in its own execute.go instead, and why that case, the only one that exits ZERO, could not be expressed here at all.
An outcome is declared beside the sentinel it describes, so the error carries its own disposition and any consumer can define one without touching this module.
type ReportOption ¶ added in v0.2.0
type ReportOption func(*reportConfig)
ReportOption adjusts a single report.
func Quietly ¶ added in v0.2.0
func Quietly() ReportOption
Quietly demotes the log line to debug without changing the exit code.
For an expected, user-initiated end — an interrupt, say — where the non-zero exit code is the signal and an error line would be noise. The message is still emitted at debug, so --debug continues to surface it.
func WithPrefix ¶ added in v0.2.0
func WithPrefix(prefix string) ReportOption
WithPrefix labels the report, for distinguishing which phase of a run failed.
func WithStackDepth ¶ added in v0.3.0
func WithStackDepth(frames int) ReportOption
WithStackDepth bounds how many frames of a reported stack are shown.
The default is DefaultStackDepth. A negative value removes the bound, for a caller that wants everything the error captured.
The frames kept are the innermost, so the failure and its immediate callers survive and runtime.main and runtime.goexit are the first to go. That is the end worth losing.
This bounds REPORTING, not capture. go/errors captures more than this so the frames are there when a caller raises the bound; the two answer different questions, one about the cost of making an error and one about the width of a terminal.
type StandardErrorHandler ¶
type StandardErrorHandler struct {
Logger *slog.Logger
Help HelpConfig
Usage func() error
}
StandardErrorHandler is the default ErrorHandler.
func (*StandardErrorHandler) Error ¶
func (h *StandardErrorHandler) Error(ctx context.Context, err error, opts ...ReportOption)
func (*StandardErrorHandler) Fatal ¶
func (h *StandardErrorHandler) Fatal(ctx context.Context, err error, opts ...ReportOption) int
func (*StandardErrorHandler) SetUsage ¶
func (h *StandardErrorHandler) SetUsage(usage func() error)
func (*StandardErrorHandler) Warn ¶
func (h *StandardErrorHandler) Warn(ctx context.Context, err error, opts ...ReportOption)