errorhandling

package module
v0.5.1 Latest Latest
Warning

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

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 4 Imported by: 0

README

errorhandling

Structured, user-friendly error reporting for Go CLIs — actionable hints, exit codes carried on the error, debug-gated stack traces, and a pluggable support-channel message

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules extracted from go-tool-base. Docs: errorhandling.go.phpboyscout.uk


gitlab.com/phpboyscout/go/errorhandling turns an error into output a user can act on. It adds a small reporting pipeline over go/errors: attach a hint telling the user what to do, attach an exit code or an outcome to the error itself, and surface stack traces only when debug logging is on.

Design

  • Framework-free, and dependency-free. The only dependency is go/errors, which is stdlib-only. No CLI framework, no config system, no logging library — the logging seam is a plain *slog.Logger and a depfootprint_test.go guard enforces the boundary.
  • The error carries the exit code. WithExitCode(err, 3) travels with the error, so the code that knows why something failed decides how the process exits, and main stays a one-liner.
  • Hints are for humans. A wrapped error says what broke; a hint says what to do about it. Both are rendered, with the hint kept out of the machine-facing message.
  • The error carries how the program should end. An Outcome states the exit code, the log level, and whether to print usage — so an error can be terminal and successful, which a closed switch over sentinels could never express.
  • Nothing exits. Fatal reports and returns the code it believes the process should use; main owns termination, so no deferred cleanup is skipped by a library.
  • Quiet on purpose. Stack traces appear only when the logger has debug enabled, and Quietly() demotes a fatal report to debug for expected terminations such as a SIGINT where an error line would be noise.
  • Bring your own usage printer. Printing usage for a parent command goes through the SetUsage(func() error) seam — with Cobra that is SetUsage(cmd.Usage) — so this module never imports a CLI framework.

Install

go get gitlab.com/phpboyscout/go/errorhandling

Quick start

package main

import (
	"context"
	"log/slog"
	"os"

	"gitlab.com/phpboyscout/go/errorhandling"
	"gitlab.com/phpboyscout/go/errors"
)

func main() {
	handler := errorhandling.New(slog.Default(), nil)

	if err := run(); err != nil {
		// Logs the message, kind and hint as one structured record, and hands
		// main the code to exit on.
		os.Exit(handler.Fatal(context.Background(), err))
	}
}

func run() error {
	err := errors.WithHint(
		errors.New("config file not found"),
		"Run 'mytool init' to create one",
	)

	return errorhandling.WithExitCode(err, 3)
}

What's inside

  • ReportingErrorHandler (Fatal / Error / Warn) and New.
  • Report optionsWithPrefix, Quietly, WithStackDepth.
  • OutcomesOutcome, WithOutcome, OutcomeOf.
  • Exit codesWithExitCode, ExitCode, ExitCodeUsage.
  • SentinelsErrNotImplemented (with NewErrNotImplemented for an issue link), ErrRunSubCommand and ErrUnknownSubCommand, which print usage, and ErrAssertionFailure (with NewAssertionFailure).
  • Help channels — the HelpConfig interface; you supply the implementation.
  • mocks — published testify mocks of ErrorHandler and HelpConfig.

What it does not do

No process exit, no signal handling, no panic recovery, no redaction, no crash reporting, no output writer of its own — reports go to the *slog.Logger you supply. Inside reporting, an outcome overrides the level and code you asked for, Quietly() is ignored by Error and Warn, and details are not debug-gated. All of it is listed under Limitations.

Documentation

Full guides and the reporting model: errorhandling.go.phpboyscout.uk. Reference — every symbol, level, exit code and log field: /reference. Signatures and doc comments: pkg.go.dev.

License

See LICENSE.

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

Examples

Constants

View Source
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.

View Source
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.

View Source
const ExitCodeKind = "errorhandling.exit_code"

ExitCodeKind identifies an attached exit code to anything reading the error through the introspection contract.

View Source
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.

View Source
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

View Source
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

func ExitCode(err error) int

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

func NewAssertionFailure(format string, args ...any) error

NewAssertionFailure returns an error denoting a bug in the program.

func NewErrNotImplemented

func NewErrNotImplemented(issueURL string) error

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

func Prefix(parts ...string) string

Prefix joins prefix fragments, for a caller assembling one from parts.

func UnknownSubCommand added in v0.5.0

func UnknownSubCommand(verb, path string) error

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

func WithExitCode(err error, code int) error

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

func WithOutcome(err error, o Outcome) error

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.

func OutcomeOf added in v0.2.0

func OutcomeOf(err error) (Outcome, bool)

OutcomeOf returns the outermost Outcome in err's tree.

Outermost wins: a caller wrapping someone else's error to change how it ends is making the more recent, more specific statement.

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)

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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