Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: try to unwrap external errors during error wrapping #80

Merged
merged 1 commit into from
Mar 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 30 additions & 3 deletions eris.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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{
Expand Down
107 changes: 98 additions & 9 deletions eris_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions examples/external/example.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 1 addition & 1 deletion examples/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
4 changes: 2 additions & 2 deletions examples/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading