diff --git a/README.md b/README.md index 993e17b..fec26f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,203 @@ # adverr -Advanced error handling for Go \ No newline at end of file +Package adverr implements errors with call stack traces +as well as error templates for error equality + +## Installation +``` +go get git.tordarus.net/tordarus/adverr +``` + +## Usage examples + +### Import +```go +import "git.tordarus.net/tordarus/adverr" +``` + +### Create error templates +```go +var ( + ErrDoStuffFailed = adverr.NewErrTmpl("ErrDoStuffFailed", "Could'nt do stuff because of %s") +) +``` + +### Create independent error (without error template) +```go +func doStuffWithIndependentErr() error { + return adverr.New("Could'nt do stuff") +} +``` + +### Create error based on template +```go +func doStuff() error { + return ErrDoStuffFailed.New("reasons") +} +``` + +### Print errors on stderr convieniently +```go +adverr.Print(myErr) +adverr.Println(myErr) +``` + +### Print errors on stderr and exit with exit code +```go +adverr.Fatal(myErr, 1) +adverr.Fatalln(myErr, 1) +``` + +### Advantages of error templates +two errors made by the same template will return true when called with `errors.Is()` +```go +func doStuffAndFailWithMsg(msg string) error { + return ErrDoStuffFailed.New(msg) +} + +err1 := doStuffAndFailWithMsg("err1") +err2 := doStuffAndFailWithMsg("err2") + +fmt.Println(errors.Is(err1, err2)) // true +fmt.Println(err1 == err2) // false +fmt.Println(err1.Error() == err2.Error()) // false +``` + +### Wrap errors (Causality of errors) +By wrapping errors, you can provide an error that is caused by another error. +A 'Caused by' section will be printed in the stack trace showing the original error. +You can also retrieve the original error by using `errors.Unwrap()` +```go +func doStuffWrapped() error { + err := doStuff() + if err != nil { + return adverr.Wrap("doStuffWrapped failed", err) + } + + return nil +} +``` + +### Chain errors (Errors caused in succession) +By chaining errors, you can provide an error that represents multiple errors caused in succession inside the same function. +For each chained error a 'Previously thrown' section will be printed in the stack trace. +You can programmatically check chained errors using the following methods: + +```go +// Get Returns the first error in the chain for which errors.Is(target) returns true +Get(target error) error + +// GetByIndex returns the i'th error in the chain +GetByIndex(i int) error + +// Chain returns a slice of all chained errors +Chain() []error + +// Contains is a shorthand for Get(target) != nil. +// Can be considered as an errors.Is function but for chains instead of causes +Contains(target error) bool +``` + +Be aware that the standard library calls wrapped errors chains as well! But these chains are something different. Here is an example use case: + +You have a list of files from which you only want to read the first one you have read permissions for. This is most likely done in a loop inside the same function. +A chained error can keep all previously failed read errors and show them in a debuggable way. Wrapping by causality would be ambiguous because they might already have been wrapped multiple times and their causes can therefore not be distinguished from previously failed errors (chained errors). + +### Retrieve call stack trace (for debugging purposes) +```go +fmt.Println(adverr.Trace()) +``` + +### Example of a printed error +Code: +```go +package main + +import ( + "adverr" + "errors" +) + +var ( + ErrDoStuffFailed = adverr.NewErrTmpl("ErrDoStuffFailed", "Could'nt do stuff because of %s") +) + +func main() { + err := doStuffInAnotherGoroutine() + if err != nil { + adverr.Fatalln(err, 1) + } +} + +func doStuff() error { + err := doGoNativeStuff() + if err != nil { + return ErrDoStuffFailed.Wrap(err, "reasons") + } + + return nil +} + +func doStuffInAnotherGoroutine() error { + ch := make(chan error, 1) + + go func() { + ch <- doStuff() + close(ch) + }() + + err := <-ch + if err != nil { + return adverr.Wrap("Goroutine failed because of errors", err) + } + + return nil +} + +func doGoNativeStuff() error { + return errors.New("some go error") +} + +``` +Output: +``` +adverr.Error: Goroutine failed because of errors + at main.doStuffInAnotherGoroutine (/home/user/go/src/test/main.go:38) + at main.main (/home/user/go/src/test/main.go:13) + at runtime.main (/usr/local/go/src/runtime/proc.go:204) +Caused by ErrDoStuffFailed: Could'nt do stuff because of reasons + at main.doStuff (/home/user/go/src/test/main.go:22) + at main.doStuffInAnotherGoroutine.func1 (/home/user/go/src/test/main.go:32) +Caused by errors.errorString: some go error + (Unknown source) +``` + +### Globals +You can set the maximum limit of the call stack trace via +```go +adverr.CallStackLength = 50 // default value: 100 +``` + +If you are in a productive environment, consider disabling call traces completely for performance reasons: +```go +adverr.DisableTrace = true // default value: false +``` + +## Change log + +### v0.1.2 + +Introduced error chaining + +### v0.1.1 + +Improved errors.Is behavior so that ErrTmpl's are considered as targets as well. Example: + +```go +err := ErrDoStuffFailed.New("some error") +fmt.Println(errors.Is(err, ErrDoStuffFailed)) // returns true since v0.1.1 +``` + +### v0.1.0 + +initial release diff --git a/calltrace.go b/calltrace.go new file mode 100644 index 0000000..1479173 --- /dev/null +++ b/calltrace.go @@ -0,0 +1,51 @@ +package adverr + +import ( + "runtime" + "strconv" + "strings" +) + +// CallTrace represents a call stack trace similar to Java's stack trace +type CallTrace struct { + frames *runtime.Frames + more bool +} + +// Trace returns a new CallTrace starting from this call +// Use skip to skip the first entries in the trace +func Trace(skip int) *CallTrace { + if DisableTrace { + return nil + } + + pc := make([]uintptr, CallStackLength+1) + n := runtime.Callers(skip+1, pc) + pc = pc[:n] + return &CallTrace{runtime.CallersFrames(pc), n == CallStackLength+1} +} + +func (ct *CallTrace) String() string { + if ct == nil { + return "" + } + + b := new(strings.Builder) + + for frame, ok := ct.frames.Next(); ok; frame, ok = ct.frames.Next() { + b.WriteString("\tat ") + b.WriteString(frame.Function) + b.WriteString(" (") + b.WriteString(frame.File) + b.WriteString(":") + b.WriteString(strconv.Itoa(frame.Line)) + b.WriteString(")") + b.WriteString("\n") + } + + if ct.more { + b.WriteString("\t ...\n") + } + + return b.String() +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..bf988d4 --- /dev/null +++ b/doc.go @@ -0,0 +1,34 @@ +/* + Package adverr implements errors with call stack traces + as well as error templates for error equality + + Usage examples + + Creating templates: + var ( + ErrDoStuffFailed = adverr.NewErrTmpl("ErrDoStuffFailed", "Could'nt do stuff because of %s") + ) + + Creating independent error (without error template): + func doStuffWithIndependentErr() error { + return adverr.New("Could'nt do stuff") + } + + Creating error based on template: + func doStuff() error { + return ErrDoStuffFailed.New("reasons") + } + + Printing errors on stderr convieniently: + Print(myErr) + Println(myErr) + + Printing errors on stderr and exit with exitcode: + Fatal(myErr, 1) + Fatalln(myErr, 1) + + Advantages of error templates + two errors made by the same template will return true when called with errors.Is() + +*/ +package adverr diff --git a/error.go b/error.go new file mode 100644 index 0000000..b7370ec --- /dev/null +++ b/error.go @@ -0,0 +1,157 @@ +package adverr + +import ( + "errors" + "reflect" + "strings" +) + +// Error is a wrapper for error with stack trace +type Error struct { + msg string + callTrace *CallTrace + tmpl *ErrTmpl + cause error + prev []error +} + +// New returns a new Error with the given message +func New(msg string) *Error { + return &Error{ + msg: msg, + callTrace: Trace(2), + } +} + +// Wrap returns a new Error with the given message which is caused by cause +func Wrap(msg string, cause error) *Error { + return &Error{ + msg: msg, + cause: cause, + callTrace: Trace(2), + } +} + +// Chain returns a new Error with the given message and a slice of errors +// which were caused in the same function in succession +func Chain(msg string, errors []error) *Error { + return &Error{ + msg: msg, + callTrace: Trace(2), + prev: errors, + } +} + +func errtype(err error) string { + if e, ok := err.(*Error); ok && e.tmpl != nil { + return errtype(e.tmpl) + } else if tmpl, ok := err.(*ErrTmpl); ok { + return tmpl.name + } + + t := reflect.TypeOf(err) + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + return t.PkgPath() + "." + t.Name() +} + +func (e *Error) Unwrap() error { + return e.cause +} + +func (e *Error) Error() string { + b := new(strings.Builder) + printErr(e, b) + return b.String() +} + +// Is implements the error equality function used by errors.Is() +// It returns true if the error is the same instance or is created using the same ErrTmpl +func (e *Error) Is(target error) bool { + // same error instance + if target == e { + return true + } + + // no template used, therefore no equality possible + if e.tmpl == nil { + return false + } + + // same template, therefore errors are equal to another + if tErr, ok := target.(*Error); ok { + return tErr.tmpl == e.tmpl + } + + // target is the template itself, therefore they are considered equal to another + if tTmpl, ok := target.(*ErrTmpl); ok { + return tTmpl == e.tmpl + } + + return false +} + +// Get Returns the first error in the chain for which errors.Is(target) returns true +func (e *Error) Get(target error) error { + if e.prev == nil { + return nil + } + + for _, prevErr := range e.prev { + if errors.Is(prevErr, target) { + return prevErr + } + } + return nil +} + +// GetByIndex returns the i'th error in the chain +func (e *Error) GetByIndex(i int) error { + if e.prev == nil { + return nil + } + return e.prev[i] +} + +// Contains is a shorthand for Get(target) != nil. +// Can be considered as an errors.Is function but for chains instead of causes +func (e *Error) Contains(target error) bool { + return e.Get(target) != nil +} + +// Chain returns a slice of all chained errors +func (e *Error) Chain() []error { + return e.prev[:] +} + +func printErr(err error, b *strings.Builder) { + e, ok := err.(*Error) + + if ok { + b.WriteString(errtype(e)) + b.WriteString(": ") + b.WriteString(e.msg) + b.WriteString("\n") + b.WriteString(e.callTrace.String()) + } else { + b.WriteString(errtype(err)) + b.WriteString(": ") + b.WriteString(err.Error()) + b.WriteString("\n") + b.WriteString("\t(Unknown source)\n") + } + + cause := errors.Unwrap(err) + if cause != nil { + b.WriteString("Caused by ") + printErr(cause, b) + } + + if ok { + for _, prevErr := range e.prev { + b.WriteString("Previously thrown ") + printErr(prevErr, b) + } + } +} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..db37523 --- /dev/null +++ b/error_test.go @@ -0,0 +1,28 @@ +package adverr + +import ( + "errors" + "fmt" + "testing" +) + +func TestErr(t *testing.T) { + DisableTrace = false + err := doStuff() + Println(err) +} + +func doStuff() error { + return Wrap("wrapped error", Wrap("test error", fmt.Errorf("asd: %w", errors.New("test")))) +} + +func TestErrorChain(t *testing.T) { + + errors := make([]error, 0) + + errors = append(errors, doStuff()) + errors = append(errors, doStuff()) + errors = append(errors, doStuff()) + + Println(Chain("Neither of that stuff worked", errors)) +} diff --git a/error_tmpl.go b/error_tmpl.go new file mode 100644 index 0000000..1d51860 --- /dev/null +++ b/error_tmpl.go @@ -0,0 +1,56 @@ +package adverr + +import "fmt" + +var ( + // ErrTmplUsedAsErr is returned from ErrTmpl.Error() because an error template should never be used as an actual error value + ErrTmplUsedAsErr = NewErrTmpl("ErrTmplUsedAsErr", "Error template used as error value: %s") +) + +// ErrTmpl is an error template to define equalities between different errors +type ErrTmpl struct { + name string + format string +} + +// NewErrTmpl returns a new error template with the given format string as a predefined error message +func NewErrTmpl(name, format string) *ErrTmpl { + return &ErrTmpl{name, format} +} + +// Error implementation just for satisfying the error interface +// Please dont use ErrTmpls as actual errors +func (t *ErrTmpl) Error() string { + return ErrTmplUsedAsErr.New(errtype(t)).Error() +} + +// New returns a new Error in which the given values are being formatted into the format string of its template +func (t *ErrTmpl) New(args ...interface{}) *Error { + return &Error{ + msg: fmt.Sprintf(t.format, args...), + cause: nil, + tmpl: t, + callTrace: Trace(2), + } +} + +// Wrap returns a new Error with a given cause in which args are being formatted into the format string of its template +func (t *ErrTmpl) Wrap(cause error, args ...interface{}) *Error { + return &Error{ + msg: fmt.Sprintf(t.format, args...), + cause: cause, + tmpl: t, + callTrace: Trace(2), + } +} + +// Chain returns a new Error with the given message and a slice of errors +// which were caused in the same function in succession +func (t *ErrTmpl) Chain(msg string, errors []error) *Error { + return &Error{ + msg: msg, + callTrace: Trace(2), + tmpl: t, + prev: errors, + } +} diff --git a/error_tmpl_test.go b/error_tmpl_test.go new file mode 100644 index 0000000..401531b --- /dev/null +++ b/error_tmpl_test.go @@ -0,0 +1,28 @@ +package adverr + +import ( + "testing" +) + +var ( + ErrDoStuffFailed = NewErrTmpl("ErrDoStuffFailed", "test error: %s") +) + +func TestErrTmpl(t *testing.T) { + err := doTemplateStuff() + Println(err) +} + +func doTemplateStuff() error { + return ErrDoStuffFailed.New("because of reasons") +} + +func TestErrTmplChain(t *testing.T) { + errors := make([]error, 0) + + errors = append(errors, doTemplateStuff()) + errors = append(errors, doTemplateStuff()) + errors = append(errors, doTemplateStuff()) + + Println(ErrDoStuffFailed.Chain("Neither of that stuff worked", errors)) +} diff --git a/globals.go b/globals.go new file mode 100644 index 0000000..a114b46 --- /dev/null +++ b/globals.go @@ -0,0 +1,10 @@ +package adverr + +// DisableTrace decides if any call stack traces will be gathered when creating Errors +// If your application is doing performance-heavy tasks with lots of Error creations, you may consider setting this to true +// If set to true, all CallTraces in Error will be nil +var DisableTrace bool = false + +// CallStackLength decides how many calls from the call stack should be gathered at most +// Changing this value has no effect if DisableTrace is true +var CallStackLength int = 100 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..83b2240 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.tordarus.net/tordarus/adverr + +go 1.15 diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..dba9e69 --- /dev/null +++ b/utils.go @@ -0,0 +1,28 @@ +package adverr + +import ( + "fmt" + "os" +) + +// Println prints the given err to stderr followed by a newline +func Println(err error) { + fmt.Fprintln(os.Stderr, err) +} + +// Print prints the given err to stderr +func Print(err error) { + fmt.Fprint(os.Stderr, err) +} + +// Fatalln prints the given err to stderr followed by a newline and exits immediately with the given exit code +func Fatalln(err error, exitcode int) { + fmt.Fprintln(os.Stderr, err) + os.Exit(exitcode) +} + +// Fatal prints the given err to stderr and exits immediately with the given exit code +func Fatal(err error, exitcode int) { + fmt.Fprint(os.Stderr, err) + os.Exit(exitcode) +}