diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 86ddede..6dbb1b5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,22 +15,21 @@ jobs: steps: - uses: actions/setup-go@v3 with: - go-version: 1.19 + go-version: 1.22 - uses: actions/checkout@v3 - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v6 with: - # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: v1.50.1 + version: v1.59.0 args: --config .golangci.yaml unit-test: name: unit-test runs-on: ubuntu-latest steps: - - name: Set up Go 1.19 + - name: Set up Go 1.22 uses: actions/setup-go@v2 with: - go-version: 1.19 + go-version: 1.22 - name: Check out code uses: actions/checkout@v2 - name: unit-test diff --git a/Makefile b/Makefile index c07f284..2bd270d 100644 --- a/Makefile +++ b/Makefile @@ -97,3 +97,6 @@ spellcheck: echo "ERROR: cspell not found, install it manually! Link: https://cspell.org/docs/getting-started"; \ exit 1; \ fi + +mod-tidy: ## run go mod tidy + go mod tidy diff --git a/eris.go b/eris.go index 512f938..cfbcf2a 100644 --- a/eris.go +++ b/eris.go @@ -2,6 +2,7 @@ package eris import ( + "errors" "fmt" "io" "net/http" @@ -95,6 +96,19 @@ func Errorf(format string, args ...any) statusError { } } +type joinError interface { + Unwrap() []error +} + +// Join returns an error that wraps the given errors. +func Join(errs ...error) error { + internal := errors.Join(errs...) + if internal == nil { + return nil + } + return wrap(internal, "join error", DEFAULT_ERROR_CODE_NEW) +} + // Wrap adds additional context to all error types while maintaining the type of the original error. Adds a default error code 'internal' // // This method behaves differently for each error type. For root errors, the stack trace is reset to the current diff --git a/eris_test.go b/eris_test.go index 6e7e254..3d050a4 100644 --- a/eris_test.go +++ b/eris_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/risingwavelabs/eris" + "github.com/stretchr/testify/assert" ) var ( @@ -938,3 +939,32 @@ func TestWrapType(t *testing.T) { t.Errorf("expected nil error if wrap nil error, but error was %v", erisErr) } } + +func TestJoinError(t *testing.T) { + err := eris.Join(nil, nil) + if err != nil { + t.Errorf("join nil should be nil") + } + err = eris.Join(nil, fmt.Errorf("external error")) + if err == nil { + t.Errorf("join error should be error") + } + err = eris.Join(fmt.Errorf("err1"), nil, fmt.Errorf("err2")) + if err == nil { + t.Errorf("join errors should be error") + } + type joinError interface { + Unwrap() []error + } + if joinErr, ok := eris.Unwrap(err).(joinError); !ok { + if len(joinErr.Unwrap()) != 2 { + t.Errorf("join 2 errors should be 2 errors") + } + } + + err1 := errors.New("err1") + err2 := errors.New("err2") + err = eris.Join(err1, err2) + assert.True(t, errors.Is(err, err1)) + assert.True(t, errors.Is(err, err2)) +} diff --git a/format.go b/format.go index f723964..b0460f4 100644 --- a/format.go +++ b/format.go @@ -2,6 +2,7 @@ package eris import ( "fmt" + "strings" ) // FormatOptions defines output options like omitting stack traces and inverting the error or stack order. @@ -103,8 +104,11 @@ func ToCustomString(err error, format StringFormat) string { if format.Options.InvertOutput { errSep := false if format.Options.WithExternal && upErr.ErrExternal != nil { - str += formatExternalStr(upErr.ErrExternal, format.Options.WithTrace) - if (format.Options.WithTrace && len(upErr.ErrRoot.Stack) > 0) || upErr.ErrRoot.Msg != "" { + externalStr := formatExternalStr(upErr.ErrExternal, format.Options.WithTrace) + str += externalStr + if strings.Contains(externalStr, "\n") { + str += "\n" + } else if (format.Options.WithTrace && len(upErr.ErrRoot.Stack) > 0) || upErr.ErrRoot.Msg != "" { errSep = true str += format.ErrorSep } @@ -124,10 +128,13 @@ func ToCustomString(err error, format StringFormat) string { } str += upErr.ErrRoot.formatStr(format) if format.Options.WithExternal && upErr.ErrExternal != nil { - if (format.Options.WithTrace && len(upErr.ErrRoot.Stack) > 0) || upErr.ErrRoot.Msg != "" { + externalStr := formatExternalStr(upErr.ErrExternal, format.Options.WithTrace) + if strings.Contains(externalStr, "\n") { + str += "\n" + } else if (format.Options.WithTrace && len(upErr.ErrRoot.Stack) > 0) || upErr.ErrRoot.Msg != "" { str += format.ErrorSep } - str += formatExternalStr(upErr.ErrExternal, format.Options.WithTrace) + str += externalStr } } @@ -250,7 +257,17 @@ func ToCustomJSON(err error, format JSONFormat) map[string]any { jsonMap := make(map[string]any) if format.Options.WithExternal && upErr.ErrExternal != nil { - jsonMap["external"] = formatExternalStr(upErr.ErrExternal, format.Options.WithTrace) + + join, ok := upErr.ErrExternal.(joinError) + if !ok { + jsonMap["external"] = formatExternalStr(upErr.ErrExternal, format.Options.WithTrace) + } else { + var externals []map[string]any + for _, e := range join.Unwrap() { + externals = append(externals, ToCustomJSON(e, format)) + } + jsonMap["externals"] = externals + } } if upErr.ErrRoot.Msg != "" || len(upErr.ErrRoot.Stack) > 0 { @@ -311,10 +328,28 @@ type UnpackedError struct { // String formatter for external errors. func formatExternalStr(err error, withTrace bool) string { + type joinError interface { + Unwrap() []error + } + + format := "%v" if withTrace { - return fmt.Sprintf("%+v", err) + format = "%+v" + } + join, ok := err.(joinError) + if !ok { + return fmt.Sprintf(format, err) + } + + var strs []string + for i, e := range join.Unwrap() { + lines := strings.Split(fmt.Sprintf(format, e), "\n") + for no, line := range lines { + lines[no] = fmt.Sprintf("\t%s", line) + } + strs = append(strs, fmt.Sprintf("%d>", i)+strings.Join(lines, "\n")) } - return fmt.Sprint(err) + return strings.Join(strs, "\n") } // ErrRoot represents an error stack and the accompanying message. diff --git a/format_test.go b/format_test.go index 57f0772..0c796ae 100644 --- a/format_test.go +++ b/format_test.go @@ -3,7 +3,9 @@ package eris_test import ( "encoding/json" "errors" + "fmt" "reflect" + "regexp" "testing" "github.com/risingwavelabs/eris" @@ -362,7 +364,7 @@ func TestFormatJSONWithStack(t *testing.T) { t.Fatalf("%v: expected a 'message' field in the output but didn't find one { %v }", desc, errJSON) } if rootMap["message"] != tt.rootOutput["message"] { - t.Errorf("%v: expected { %v } got { %v }", desc, rootMap["message"], tt.rootOutput["message"]) + t.Errorf("%v: expected { %v } got { %v }", desc, tt.rootOutput["message"], rootMap["message"]) } if _, exists := rootMap["stack"]; !exists { t.Fatalf("%v: expected a 'stack' field in the output but didn't find one { %v }", desc, errJSON) @@ -381,7 +383,7 @@ func TestFormatJSONWithStack(t *testing.T) { t.Fatalf("%v: expected a 'message' field in the output but didn't find one { %v }", desc, errJSON) } if wrapMap[i]["message"] != tt.wrapOutput[i]["message"] { - t.Errorf("%v: expected { %v } got { %v }", desc, wrapMap[i]["message"], tt.wrapOutput[i]["message"]) + t.Errorf("%v: expected { %v } got { %v }", desc, tt.wrapOutput[i]["message"], wrapMap[i]["message"]) } if _, exists := wrapMap[i]["stack"]; !exists { t.Fatalf("%v: expected a 'stack' field in the output but didn't find one { %v }", desc, errJSON) @@ -393,3 +395,190 @@ func TestFormatJSONWithStack(t *testing.T) { }) } } + +func TestFormatJoinError(t *testing.T) { + tests := map[string]struct { + input error + withTrace bool + withExternal bool + stringOutput string + regexOutput *regexp.Regexp + rootOutput map[string]any + wrapOutput []map[string]any + externalsOutput []map[string]any + }{ + "without trace and external": { + input: eris.Wrap(eris.Join( + fmt.Errorf("fmt error"), + eris.Wrap(eris.Wrap(fmt.Errorf("external"), "wrap2"), "wrap1"), + eris.New("eris error"), + ), "outer wrap"), + withTrace: false, + withExternal: false, + stringOutput: "code(internal) outer wrap: code(unknown) join error", + rootOutput: map[string]any{ + "message": "join error", + }, + wrapOutput: []map[string]any{ + { + "message": "outer wrap", + }, + }, + }, + "without trace": { + input: eris.Wrap(eris.Join( + fmt.Errorf("fmt error"), + eris.Wrap(eris.Wrap(fmt.Errorf("external"), "wrap2"), "wrap1"), + eris.New("eris error"), + ), "outer wrap"), + withTrace: false, + withExternal: true, + stringOutput: `code(internal) outer wrap: code(unknown) join error +0> fmt error +1> code(internal) wrap1: code(internal) wrap2: external +2> code(unknown) eris error`, + rootOutput: map[string]any{ + "message": "join error", + }, + wrapOutput: []map[string]any{ + { + "message": "outer wrap", + }, + }, + externalsOutput: []map[string]any{ + { + "external": "fmt error", + }, { + "external": "external", + "root": map[string]any{}, + "wrap": []map[string]any{}, + }, { + "root": map[string]any{}, + }, + }, + }, + "with trace": { + input: eris.Wrap(eris.Join( + fmt.Errorf("fmt error"), + eris.Wrap(eris.Wrap(fmt.Errorf("external"), "wrap2"), "wrap1"), + eris.New("eris error"), + ), "outer wrap"), + withTrace: true, + withExternal: true, + regexOutput: regexp.MustCompile(`code\(internal\) outer wrap + eris_test\.TestFormatJoinError:\S+:\d+ +code\(unknown\) join error + eris_test\.TestFormatJoinError:\S+:\d+ + eris_test\.TestFormatJoinError:\S+:\d+ +0> fmt error +1> code\(internal\) wrap1 + eris_test\.TestFormatJoinError:\S+:\d+ + code\(internal\) wrap2 + eris_test\.TestFormatJoinError:\S+:\d+ + eris_test\.TestFormatJoinError:\S+:\d+ + external +2> code\(unknown\) eris error + eris_test\.TestFormatJoinError:\S+:\d+`), + rootOutput: map[string]any{ + "message": "join error", + }, + wrapOutput: []map[string]any{ + { + "message": "outer wrap", + }, + }, + externalsOutput: []map[string]any{ + { + "external": "fmt error", + }, { + "external": "external", + "root": map[string]any{}, + "wrap": []map[string]any{}, + }, { + "root": map[string]any{}, + }, + }, + }, + } + + for desc, tt := range tests { + t.Run(desc, func(t *testing.T) { + errStr := eris.ToCustomString(tt.input, eris.NewDefaultStringFormat(eris.FormatOptions{ + WithTrace: tt.withTrace, + WithExternal: tt.withExternal, + })) + if tt.stringOutput != "" && tt.stringOutput != errStr { + t.Errorf("%v: expected { %v } got { %v }", desc, tt.stringOutput, errStr) + } + if tt.regexOutput != nil && !tt.regexOutput.MatchString(errStr) { + t.Errorf("%v: expected match { %v } got { %v }", desc, tt.regexOutput.String(), errStr) + } + + errJSON := eris.ToCustomJSON(tt.input, eris.NewDefaultJSONFormat(eris.FormatOptions{ + WithTrace: tt.withTrace, + WithExternal: tt.withExternal, + })) + + // make sure messages are correct and stack elements exist (actual stack validation is in stack_test.go) + if rootMap, ok := errJSON["root"].(map[string]any); ok { + if _, exists := rootMap["message"]; !exists { + t.Fatalf("%v: expected a 'message' field in the output but didn't find one { %v }", desc, errJSON) + } + if rootMap["message"] != tt.rootOutput["message"] { + t.Errorf("%v: expected { %v } got { %v }", desc, tt.rootOutput["message"], rootMap["message"]) + } + if _, exists := rootMap["stack"]; tt.withTrace && !exists { + t.Fatalf("%v: expected a 'stack' field in the output but didn't find one { %v }", desc, errJSON) + } + } else { + t.Errorf("%v: expected root error is malformed { %v }", desc, errJSON) + } + + // make sure messages are correct and stack elements exist (actual stack validation is in stack_test.go) + if wrapMap, ok := errJSON["wrap"].([]map[string]any); ok { + if len(tt.wrapOutput) != len(wrapMap) { + t.Fatalf("%v: expected number of wrap layers { %v } doesn't match actual { %v }", desc, len(tt.wrapOutput), len(wrapMap)) + } + for i := 0; i < len(wrapMap); i++ { + if _, exists := wrapMap[i]["message"]; !exists { + t.Fatalf("%v: expected a 'message' field in the output but didn't find one { %v }", desc, errJSON) + } + if wrapMap[i]["message"] != tt.wrapOutput[i]["message"] { + t.Errorf("%v: expected { %v } got { %v }", desc, tt.wrapOutput[i]["message"], wrapMap[i]["message"]) + } + if _, exists := wrapMap[i]["stack"]; tt.withTrace && !exists { + t.Fatalf("%v: expected a 'stack' field in the output but didn't find one { %v }", desc, errJSON) + } + } + } else { + t.Errorf("%v: expected wrap error is malformed { %v }", desc, errJSON) + } + + if externalsMap, ok := errJSON["externals"].([]map[string]any); ok { + if len(tt.externalsOutput) != len(externalsMap) { + t.Fatalf("%v: expected number of externals errors { %v } doesn't match actual { %v }", desc, len(tt.externalsOutput), len(externalsMap)) + } + for i, externalsOutputItem := range tt.externalsOutput { + for key, val := range externalsOutputItem { + switch val.(type) { + case string: + if externalsMap[i][key] != val { + t.Errorf("%v: expected externals[%d][%s] { %v } got { %v }", desc, i, key, val, externalsMap[i][key]) + } + case map[string]any: + if _, ok := externalsMap[i][key].(map[string]any); !ok { + t.Errorf("%v: expected externals[%d][%s] is object got { %v }", desc, i, key, externalsMap[i][key]) + } + case []map[string]any: + if _, ok := externalsMap[i][key].([]map[string]any); !ok { + t.Errorf("%v: expected externals[%d][%s] is object array got { %v }", desc, i, key, externalsMap[i][key]) + } + } + } + } + } else if tt.withExternal { + t.Errorf("%v: expected externals error is malformed { %v }", desc, errJSON) + } + }) + } +} diff --git a/go.mod b/go.mod index bb974c8..06a6631 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,13 @@ module github.com/risingwavelabs/eris go 1.18 -require google.golang.org/grpc v1.53.0 +require ( + github.com/stretchr/testify v1.9.0 + google.golang.org/grpc v1.53.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index d8f5001..e610fca 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f h1:BWUVssLB0HVOSY78gIdvk1dTVYtT1y8SBWtPYuTJ/6w= google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc= google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=