diff --git a/README.md b/README.md index 2770bef..38b41f6 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ runtime.goexit Migrating to `eris` should be a very simple process. If it doesn't offer something that you currently use from existing error packages, feel free to submit an issue to us. If you don't want to refactor all of your error handling yet, `eris` should work relatively seamlessly with your existing error types. Please submit an issue if this isn't the case for some reason. -Many of your dependencies will likely still use [pkg/errors](https://github.com/pkg/errors) for error handling. Currently, when external error types are wrapped with additional context, the original error is flattened (via `err.Error()`) and used to create a root error. This adds a stack trace for the error and allows it to function more seamlessly with the rest of the `eris` package. However, we're looking into potentially integrating with other error packages to unwrap and format external errors. +Many of your dependencies will likely still use [pkg/errors](https://github.com/pkg/errors) for error handling. When external types are wrapped with additional context, `eris` attempts to unwrap them to build a new error chain. If an error type doesn't implement the unwrapping interface, the original error is flattened (via `err.Error()`) and used to create a new root error instead. ## Contributing diff --git a/eris.go b/eris.go index d293fb8..e186fb6 100644 --- a/eris.go +++ b/eris.go @@ -30,8 +30,10 @@ func Errorf(format string, args ...interface{}) error { // // This method behaves differently for each error type. For root errors, the stack trace is reset to the current // callers which ensures traces are correct when using global/sentinel error values. Wrapped error types are simply -// wrapped with the new context. For external types (i.e. something other than root or wrap errors), a new root -// error is created for the original error and then it's wrapped with the additional context. +// wrapped with the new context. For external types (i.e. something other than root or wrap errors), this method +// attempts to unwrap them while building a new error chain. If an external type does not implement the unwrap +// interface, it flattens the error and creates a new root error from it before wrapping with the additional +// context. func Wrap(err error, msg string) error { return wrap(err, msg) } @@ -72,10 +74,35 @@ func wrap(err error, msg string) error { root.stack.insertPC(*stack) } default: + // attempt to unwrap external errors while building a new error chain or fallback to flattening them + var errStr []string + for e != nil { + str := e.Error() + errStr = append([]string{str}, errStr...) + e = Unwrap(e) + // unwrap twice for pkg/errors and other libraries like it + if e != nil && e.Error() == str { + e = Unwrap(e) + } + } err = &rootError{ - msg: e.Error(), + msg: errStr[0], stack: stack, } + for i := 1; i < len(errStr); i++ { + // parse the current layer's message by substracting the other layers + // note: this assumes delimiters are two characters like ": " + var layerMsg string + msgCutoff := len(errStr[i]) - len(errStr[i-1]) - 2 + if msgCutoff >= 0 { + layerMsg = errStr[i][:msgCutoff] + } + err = &wrapError{ + msg: layerMsg, + err: err, + frame: frame, + } + } } return &wrapError{ diff --git a/eris_test.go b/eris_test.go index 75423fa..41c5b33 100644 --- a/eris_test.go +++ b/eris_test.go @@ -58,19 +58,10 @@ func TestErrorWrapping(t *testing.T) { input: []string{"additional context", "even more context"}, output: "even more context: additional context: formatted root error", }, - "standard error wrapping with a third-party root cause (errors.New)": { - cause: errors.New("external error"), - input: []string{"additional context", "even more context"}, - output: "even more context: additional context: external error", - }, "no error wrapping with a local root cause (eris.Errorf)": { cause: eris.Errorf("%v root error", "formatted"), output: "formatted root error", }, - "no error wrapping with a third-party root cause (errors.New)": { - cause: errors.New("external error"), - output: "external error", - }, } for desc, tc := range tests { @@ -85,6 +76,104 @@ func TestErrorWrapping(t *testing.T) { } } +type withMessage struct { + msg string +} + +func (e *withMessage) Error() string { return e.msg } + +type withLayer struct { + err error + msg string +} + +func (e *withLayer) Error() string { return e.msg + ": " + e.err.Error() } +func (e *withLayer) Unwrap() error { return e.err } + +type withEmptyLayer struct { + err error +} + +func (e *withEmptyLayer) Error() string { return e.err.Error() } +func (e *withEmptyLayer) Unwrap() error { return e.err } + +func TestExternalErrorWrapping(t *testing.T) { + tests := map[string]struct { + cause error // root error + input []string // input for error wrapping + output error // expected output + }{ + "no error wrapping with a third-party root cause (errors.New)": { + cause: errors.New("external error"), + output: eris.New("external error"), + }, + "standard error wrapping with a third-party root cause (errors.New)": { + cause: errors.New("external error"), + input: []string{"additional context", "even more context"}, + output: eris.Wrap(eris.Wrap(eris.New("external error"), "additional context"), "even more context"), + }, + "wrapping a wrapped third-party root cause (errors.New and fmt.Errorf)": { + cause: fmt.Errorf("additional context: %w", errors.New("external error")), + input: []string{"even more context"}, + output: eris.Wrap(eris.Wrap(eris.New("external error"), "additional context"), "even more context"), + }, + "wrapping a wrapped third-party root cause (multiple layers)": { + cause: fmt.Errorf("even more context: %w", fmt.Errorf("additional context: %w", errors.New("external error"))), + input: []string{"way too much context"}, + output: eris.Wrap(eris.Wrap(eris.Wrap(eris.New("external error"), "additional context"), "even more context"), "way too much context"), + }, + "wrapping a wrapped third-party root cause that contains an empty layer": { + cause: fmt.Errorf(": %w", errors.New("external error")), + input: []string{"even more context"}, + output: eris.Wrap(eris.Wrap(eris.New("external error"), ""), "even more context"), + }, + "wrapping a wrapped third-party root cause that contains an empty layer without a delimiter": { + cause: fmt.Errorf(": %w", errors.New("external error")), + input: []string{"even more context"}, + output: eris.Wrap(eris.Wrap(eris.New("external error"), ""), "even more context"), + }, + "wrapping a pkg/errors style error (contains layers without messages)": { + cause: &withLayer{ // var to mimic wrapping a pkg/errors style error + msg: "additional context", + err: &withEmptyLayer{ + err: &withMessage{ + msg: "external error", + }, + }, + }, + input: []string{"even more context"}, + output: eris.Wrap(eris.Wrap(eris.New("external error"), "additional context"), "even more context"), + }, + } + + for desc, tc := range tests { + t.Run(desc, func(t *testing.T) { + err := setupTestCase(false, tc.cause, tc.input) + + // unwrap the actual and expected output to make sure external errors are actually wrapped properly + var inputErr, outputErr []string + for err != nil { + inputErr = append(inputErr, err.Error()) + err = eris.Unwrap(err) + } + for tc.output != nil { + outputErr = append(outputErr, tc.output.Error()) + tc.output = eris.Unwrap(tc.output) + } + + // compare each layer of the actual and expected output + if len(inputErr) != len(outputErr) { + t.Fatalf("%v: expected output to have '%v' layers but got '%v': { %#v } got { %#v }", desc, len(outputErr), len(inputErr), outputErr, inputErr) + } + for i := 0; i < len(inputErr); i++ { + if inputErr[i] != outputErr[i] { + t.Errorf("%v: expected { %#v } got { %#v }", desc, inputErr[i], outputErr[i]) + } + } + }) + } +} + func TestErrorUnwrap(t *testing.T) { tests := map[string]struct { cause error // root error diff --git a/examples/external/example.go b/examples/external/example.go new file mode 100644 index 0000000..8c1c837 --- /dev/null +++ b/examples/external/example.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + + pkgerrors "github.com/pkg/errors" + "github.com/rotisserie/eris" +) + +var ( + errExternal = pkgerrors.New("external error") +) + +// example method that returns an external error (e.g. pkg/errors). +func getResource(id string) error { + return pkgerrors.Wrap(errExternal, "resource not found") +} + +// example method that wraps an external error using eris. +func readResource(id string) error { + err := getResource(id) + if err != nil { + return eris.Wrapf(err, "failed to get resource '%v'", id) + } + return nil +} + +func processResource(id string) error { + err := readResource(id) + if err != nil { + return eris.Wrapf(err, "failed to process resource '%v'", id) + } + return nil +} + +// This example demonstrates how error wrapping works with external error handling libraries +// (e.g. pkg/errors). When an external error is wrapped, eris attempts to unwrap it and returns +// a new error containing the external error chain, the new context, and an eris stack trace. +func main() { + err := processResource("res1") + fmt.Printf("%+v\n", err) +} diff --git a/examples/go.mod b/examples/go.mod index fc7600d..f2e94e9 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -5,6 +5,6 @@ go 1.14 require ( github.com/getsentry/sentry-go v0.5.0 github.com/pkg/errors v0.9.1 - github.com/rotisserie/eris v0.3.0 + github.com/rotisserie/eris v0.3.1-0.20200303165657-37855979489c github.com/sirupsen/logrus v1.4.2 ) diff --git a/examples/go.sum b/examples/go.sum index e707124..d8ef339 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -103,8 +103,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rotisserie/eris v0.3.0 h1:NMwJvCWWTrbhgXakSUqbI//HmFt5B5KWd6NVH+9+yF0= -github.com/rotisserie/eris v0.3.0/go.mod h1:DfmQXutjjasYYOYqyqgnevbDzsVLOwG/KWlwVkBoU3U= +github.com/rotisserie/eris v0.3.1-0.20200303165657-37855979489c h1:UGb3jr8R7Npl3UARxgy0qreZhXZgX/zoWz+YpewdjqU= +github.com/rotisserie/eris v0.3.1-0.20200303165657-37855979489c/go.mod h1:Q/dTG//WLzDxzPatLiqGbgroZbLQcZyX45WeW9D0lNQ= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= diff --git a/stack_test.go b/stack_test.go index 913cfca..40621bc 100644 --- a/stack_test.go +++ b/stack_test.go @@ -1,6 +1,8 @@ package eris_test import ( + "errors" + "fmt" "strings" "testing" @@ -8,26 +10,39 @@ import ( ) const ( - file = "eris/stack_test.go" - readFunc = "eris_test.ReadFile" - parseFunc = "eris_test.ParseFile" - processFunc = "eris_test.ProcessFile" - globalTestFunc = "eris_test.TestGlobalStack" - localTestFunc = "eris_test.TestLocalStack" + file = "eris/stack_test.go" + readFunc = "eris_test.ReadFile" + parseFunc = "eris_test.ParseFile" + processFunc = "eris_test.ProcessFile" + globalTestFunc = "eris_test.TestGlobalStack" + localTestFunc = "eris_test.TestLocalStack" + extGlobalTestFunc = "eris_test.TestExtGlobalStack" + extLocalTestFunc = "eris_test.TestExtLocalStack" +) + +var ( + errEOF = eris.New("unexpected EOF") + errExt = errors.New("external error") ) // example func that either returns a wrapped global or creates/wraps a new local error -func ReadFile(fname string, global bool) error { - if global { - return eris.Wrapf(ErrUnexpectedEOF, "error reading file '%v'", fname) +func ReadFile(fname string, global bool, external bool) error { + var err error + if !external && !global { // local eris + err = eris.New("unexpected EOF") + } else if !external && global { // global eris + err = errEOF + } else if external && !global { // local external + err = fmt.Errorf("external context: %w", errors.New("external error")) + } else { // global external + err = fmt.Errorf("external context: %w", errExt) } - err := eris.New("unexpected EOF") return eris.Wrapf(err, "error reading file '%v'", fname) } // example func that just catches and returns an error -func ParseFile(fname string, global bool) error { - err := ReadFile(fname, global) +func ParseFile(fname string, global bool, external bool) error { + err := ReadFile(fname, global, external) if err != nil { return err } @@ -35,9 +50,9 @@ func ParseFile(fname string, global bool) error { } // example func that wraps an error with additional context -func ProcessFile(fname string, global bool) error { +func ProcessFile(fname string, global bool, external bool) error { // parse the file - err := ParseFile(fname, global) + err := ParseFile(fname, global, external) if err != nil { return eris.Wrapf(err, "error processing file '%v'", fname) } @@ -47,18 +62,18 @@ func ProcessFile(fname string, global bool) error { func TestGlobalStack(t *testing.T) { // expected results expectedChain := []eris.StackFrame{ - {Name: readFunc, File: file, Line: 22}, - {Name: processFunc, File: file, Line: 42}, + {Name: readFunc, File: file, Line: 40}, + {Name: processFunc, File: file, Line: 57}, } expectedRoot := []eris.StackFrame{ - {Name: readFunc, File: file, Line: 22}, - {Name: parseFunc, File: file, Line: 30}, - {Name: processFunc, File: file, Line: 40}, - {Name: processFunc, File: file, Line: 42}, - {Name: globalTestFunc, File: file, Line: 61}, + {Name: readFunc, File: file, Line: 40}, + {Name: parseFunc, File: file, Line: 45}, + {Name: processFunc, File: file, Line: 55}, + {Name: processFunc, File: file, Line: 57}, + {Name: globalTestFunc, File: file, Line: 76}, } - err := ProcessFile("example.json", true) + err := ProcessFile("example.json", true, false) uerr := eris.Unpack(err) validateWrapFrames(t, expectedChain, uerr) validateRootStack(t, expectedRoot, uerr) @@ -67,19 +82,61 @@ func TestGlobalStack(t *testing.T) { func TestLocalStack(t *testing.T) { // expected results expectedChain := []eris.StackFrame{ - {Name: readFunc, File: file, Line: 25}, - {Name: processFunc, File: file, Line: 42}, + {Name: readFunc, File: file, Line: 40}, + {Name: processFunc, File: file, Line: 57}, + } + expectedRoot := []eris.StackFrame{ + {Name: readFunc, File: file, Line: 32}, + {Name: readFunc, File: file, Line: 40}, + {Name: parseFunc, File: file, Line: 45}, + {Name: processFunc, File: file, Line: 55}, + {Name: processFunc, File: file, Line: 57}, + {Name: localTestFunc, File: file, Line: 97}, + } + + err := ProcessFile("example.json", false, false) + uerr := eris.Unpack(err) + validateWrapFrames(t, expectedChain, uerr) + validateRootStack(t, expectedRoot, uerr) +} + +func TestExtGlobalStack(t *testing.T) { + // expected results + expectedChain := []eris.StackFrame{ + {Name: readFunc, File: file, Line: 40}, + {Name: readFunc, File: file, Line: 40}, + {Name: processFunc, File: file, Line: 57}, + } + expectedRoot := []eris.StackFrame{ + {Name: readFunc, File: file, Line: 40}, + {Name: parseFunc, File: file, Line: 45}, + {Name: processFunc, File: file, Line: 55}, + {Name: processFunc, File: file, Line: 57}, + {Name: extGlobalTestFunc, File: file, Line: 118}, + } + + err := ProcessFile("example.json", true, true) + uerr := eris.Unpack(err) + validateWrapFrames(t, expectedChain, uerr) + validateRootStack(t, expectedRoot, uerr) +} + +func TestExtLocalStack(t *testing.T) { + // expected results + expectedChain := []eris.StackFrame{ + {Name: readFunc, File: file, Line: 40}, + {Name: readFunc, File: file, Line: 40}, + {Name: processFunc, File: file, Line: 57}, } expectedRoot := []eris.StackFrame{ - {Name: readFunc, File: file, Line: 24}, - {Name: readFunc, File: file, Line: 25}, - {Name: parseFunc, File: file, Line: 30}, - {Name: processFunc, File: file, Line: 40}, - {Name: processFunc, File: file, Line: 42}, - {Name: localTestFunc, File: file, Line: 82}, + {Name: readFunc, File: file, Line: 40}, + {Name: parseFunc, File: file, Line: 45}, + {Name: processFunc, File: file, Line: 55}, + {Name: processFunc, File: file, Line: 57}, + {Name: extLocalTestFunc, File: file, Line: 139}, } - err := ProcessFile("example.json", false) + err := ProcessFile("example.json", false, true) uerr := eris.Unpack(err) validateWrapFrames(t, expectedChain, uerr) validateRootStack(t, expectedRoot, uerr)