From c2b8c9a03c30f96ab8124c4414cb9e0869659808 Mon Sep 17 00:00:00 2001 From: Gogo Date: Thu, 13 Jun 2024 13:33:03 +0800 Subject: [PATCH] feat: join error (#26) --- .github/workflows/ci.yaml | 11 +-- eris.go | 14 +++ eris_test.go | 23 +++++ format.go | 49 ++++++++-- format_test.go | 193 +++++++++++++++++++++++++++++++++++++- 5 files changed, 275 insertions(+), 15 deletions(-) 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/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..d309802 100644 --- a/eris_test.go +++ b/eris_test.go @@ -938,3 +938,26 @@ 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") + } + } +} 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) + } + }) + } +}