Skip to content

Go package for crafting archetypal errors and explorable stack traces

License

Notifications You must be signed in to change notification settings

PaulioRandall/go-trackerr

Repository files navigation

Trackerr

Package trackerr aims to facilitate creation of referenceable errors and elegant stack traces.

It was crafted in frustration trying to navigate Go's printed error stacks and the challenge of reliably asserting specific error types while testing.

I hope the code speaks mostly for itself so you don't have to trawl through my ramblings.

API

import (
	// Package imported is just called 'trackerr' 
	"github.com/PaulioRandall/go-trackerr"
)

Please note: TrackedError and UntrackedError are structs but I've specified them here as interfaces for documentation purposes.

var (
    // ErrTodo for specifying a TODO.
    ErrTodo = New("TODO: Implementation needed")

    // ErrBug for the site of known bugs
    ErrBug = New("BUG: Fix needed")

    // ErrInsane for sanity checking.
    ErrInsane = New("Sanity check failed!!")
)

func New(msg string, args ...any) TrackedError {}
func Track(msg string, args ...any) TrackedError {}
func Untracked(msg string, args ...any) UntrackedError {}

func All(e error, targets ...error) bool
func AllOrdered(e error, targets ...error) bool
func Any(e error, targets ...error) bool
func HasTracked(e error) bool
func Is(e, target error) bool
func IsTracked(e error) bool
func IsTrackerr(e error) bool
func Unwrap(e error) error

func Stack(rootCause error, errs ...ErrorThatWraps) error
func SliceStack(e error) []error
func Squash(e error) error
func Squashf(e error, f ErrorFormatter) error
func ErrorStack(e error) string
func ErrorStackf(e error, f ErrorFormatter) string
func ErrorWithoutCause(e error) string

func Debug(e error) (int, error)
func DebugPanic(catch *error)

func Initialised()

type ErrorFormatter func(errMsg string, e error, isFirst bool) string

type ErrorThatWraps interface {
	error
	CausedBy(rootCause error, causes ...ErrorThatWraps) error
}

type TrackedError interface { // Actually a struct in code
	ErrorThatWraps

	Error() string

	Because(msg string, args ...any) error
	BecauseOf(rootCause error, msg string, args ...any) error
	CausedBy(rootCause error, causes ...ErrorThatWraps) error

	Is(error) bool
	Unwrap() error
}

type UntrackedError interface { // Actually a struct in code
	ErrorThatWraps

	Error() string

	Because(msg string, args ...any) error
	BecauseOf(rootCause error, msg string, args ...any) error
	CausedBy(rootCause error, causes ...ErrorThatWraps) error

	Unwrap() error
}

type Realm interface {
	New(msg string, args ...any) *TrackedError
	Track(msg string, args ...any) *TrackedError
}

type IntRealm struct {}

Tracked errors should be package variables

It's important to define errors created via New and Track as package scooped (global) or you won't be able to reference them. It is not recommended to create trackable errors after initialisation but Realms exist for such cases.

Wrapping errors

You can return a tracked or untracked error directly but it's recommended to call one of the receiving functions CausedBy, Because, BecauseOf, or ContextFor with additional information.

var (
	ErrLoadingData = trackerr.New("Failed to load data")
	ErrOpeningDatabase = trackerr.New("Could not open database")

	dbFile = "./data/db.sqlite"
)

func Err() error {
	return ErrLoadingData
}

func CausedBy() error {
	return ErrLoadingData.CausedBy(ErrOpeningDatabase)
}

func Because() error {
	return ErrLoadingData.Because("Database file '%s' not found", dbFile)
}

func BecauseOf() error {
	e := trackerr.Untracked("Database file '%s' not found", dbFile)
	return ErrLoadingData.BecauseOf(e, "Could not open database")
}

func ContextFor() error {
	e := trackerr.Untracked("Database file '%s' not found", dbFile)
	return ErrLoadingData.ContextFor(ErrOpeningDatabase, e)
}

Prevent creating tracked errors after program initialisation

It's also recommended to call Initialised from an init function in package main to prevent the creation of trackable errors after program initialisation.

package main

import (
	"github.com/PaulioRandall/go-trackerr"
)

var ErrForNoReason = trackerr.New("Failed for no reason")

func init() {
	trackerr.Initialised()
}

func main() {
	// Bad, will panic
	e = trackerr.New("I felt like it")

	_ = e
}

Debugging

For manual debugging there's trackerr.Debug which will print a readable stack trace.

func Debug() {
	a := trackerr.UntrackedError("Failed to load data")
	b := trackerr.UntrackedError("Could not open database")
	c := trackerr.UntrackedError("Database file not found")

	e := Stack(a, b, c)

	trackerr.Debug(e)

	// [DEBUG ERROR]
	// Failed to load data
	// ⤷ Could not open database
	// ⤷ Database file not found
}

Alternatively the deferable trackerr.DebugPanic(nil) will recover from a panic, print the error (if it is one), then resume the panic.

func DebugPanic() {
	defer trackerr.DebugPanic(nil)

	a := trackerr.UntrackedError("Failed to load data")
	b := trackerr.UntrackedError("Could not open database")
	c := trackerr.UntrackedError("Database file not found")

	e := Stack(a, b, c)
	panic(e)

	// [DEBUG ERROR]
	// Failed to load data
	// ⤷ Could not open database
	// ⤷ Database file not found
}

Passing a pointer to an error trackerr.DebugPanic(&e) will prevent the panic resuming and instead set it as the value pointed to by the pointer.

func DebugPanic() (e error) {
	defer trackerr.DebugPanic(&e)

	...
}

Custom errors

You may also craft your own error types and wrap or be wrapped by trackerr errors.

type myError struct {
	msg string
	cause error
}

func (e myError) CausedBy(other error) error {
	e.cause = other
	return e
}

func (e myError) Unwrap() error {
	return e.cause
}

var (
	ErrLoadingData = trackerr.New("Failed to load data")
	ErrFileNotFound = trackerr.New("Database file not found")
)

func main() {
	e := myError{ msg: "Could not open database" }
	e = ErrLoadingData.ContextFor(ErrFileNotFound, e)
	_ = e
}

Testing

One place trackerr becomes useful is when asserting errors in tests.

Trackerr assigns errors there own private unique identifiers which are used for comparison by errors.Is and trackerr's utility functions. This separates the concerns of communicating with humans from asserting that specific errors occur when they should.

// csvreader.go

import (
	"errors"
)

var ErrParsingCSV = trackerr.New("Could not parse CSV")

func ReadCSV(file string) error {
	...

	return ErrParsingCSV
}
// csvreader_test.go

import (
	"errors"
	"testing"
)

func TestReadCSV_InvalidFormat(t *testing.T) {
	e := ReadCSV("/path/to/csv/file")
	
	if !errors.Is(e, ErrParsingCSV) {
		t.Log("Expected ErrParsingCSV error")
		t.Fail()
	}
}

Design decisions

The design is largely usage lead and thus somewhat emergent. That is, I had projects requiring trackable errors to which I crafted structures and functions based on need.

Composition > Framing

The package is designed to work in a compositional manner such that trackerr.New, trackerr.Track, and errors.new can be exchanged incrementally. Engineers may compose all their errors using trackerr or just the few that require tracking. Most of trackerr's utility functions work on the error interface so the underlying error types matter little.

Composition is favoured over framing, when feasible, so the power to change and adapt, with needs and the times, remains in the hands of the consuming engineers. In so much as possible, minimising the my way or the highway mentality which is core to commercial software but also rampant in open source tooling.

If my package no longer provides value for cost or if something better appears then it should be incrementally removable or replacable. I find that a good design is one that can change easily. My preference for changability, Continuous Integration (CI), and Continuous Delivery (CD) certainly influenced these decisions.

Why not string equality?

Many programmers test assert using error messages (strings) but I've found this to be unreliable, reduces changability, and leaves me feeling less than confident in my code; and testing is all about gaining confidence.

Communicating aaccurate and relevant information to humans can be quite a fraught affair so I'd like to maximise the ease of improving and rewriting error messages without having to worry about breaking tests.

Why not pointer equality?

Comparing pointers is better than comparing text but this means package scooped errors must be immutable, thus cannot have a cause attached to them or be wrapped. The receiving functions of TrackedError and UntrackedError produce copies of themselves (including their IDs) that allows the attachment of causes while keeping the equality checking. errors.Is(copy, original) still returns true as private unique identifiers are compared, not string messages or pointers.

Unfortunately, this means copy == original will always return false. This is not much of a sacrifice as error pointer comparisons lost favour with the introduction of error wrapping (Go 1.13). Use errors.Is, trackerr.Is, or one of trackerr's other utility functions instead.

Checking out (in both senses)

git clone https://github.com/PaulioRandall/go-trackerr.git
cd go-trackerr

Standard Go commands can be used from here but my ./godo script eases things:

./godo [help]   # Print usage
./godo doc[s]   # Fire up documentation server
./godo clean    # Clean Go caches
./godo test     # fmt -> test -> vet

About

Go package for crafting archetypal errors and explorable stack traces

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published