From 010dea5bd524c16630f0875c1b0972ab28c11ef4 Mon Sep 17 00:00:00 2001 From: willlv Date: Mon, 23 Mar 2026 16:00:36 +0800 Subject: [PATCH] fix: unwrap errors.Join() created joined errors in Context.Error() (#4237) Automatically unwrap errors created by errors.Join() when passed to Context.Error(), so they are added as separate error entries instead of a single combined error. This improves error log readability and structure. Before: Error #01: gin error Error #02: service error store error Error #03: other error After: Error #01: gin error Error #02: service error Error #03: store error Error #04: other error Closes #4237 --- context.go | 16 ++++++++++++++++ errors_test.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/context.go b/context.go index 5174033e..c48cb4b3 100644 --- a/context.go +++ b/context.go @@ -254,6 +254,22 @@ func (c *Context) Error(err error) *Error { panic("err is nil") } + // Unwrap errors.Join() created joinErr + type unwrapper interface { + Unwrap() []error + } + if joinErr, ok := err.(unwrapper); ok { + errs := joinErr.Unwrap() + if len(errs) > 0 { + // Recursively add each error from the joined errors + for _, e := range errs { + c.Error(e) + } + // Return the last added error + return c.Errors.Last() + } + } + var parsedError *Error ok := errors.As(err, &parsedError) if !ok { diff --git a/errors_test.go b/errors_test.go index 6d8df278..40a9e1fb 100644 --- a/errors_test.go +++ b/errors_test.go @@ -7,6 +7,7 @@ package gin import ( "errors" "fmt" + "net/http/httptest" "testing" "github.com/gin-gonic/gin/codec/json" @@ -138,3 +139,54 @@ func TestErrorUnwrap(t *testing.T) { var testErrNonPointer TestErr require.ErrorAs(t, wrappedErr, &testErrNonPointer) } + +// TestErrorJoinUnwrap tests that gin.Error() automatically unwraps errors.Join() created joined errors. +func TestErrorJoinUnwrap(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + + // Test with errors.Join() + err1 := errors.New("service error") + err2 := errors.New("store error") + joinedErr := errors.Join(err1, err2) + + c.Error(joinedErr) + + // Should be unwrapped into 2 separate errors + assert.Len(t, c.Errors, 2) + assert.Equal(t, "service error", c.Errors[0].Error()) + assert.Equal(t, "store error", c.Errors[1].Error()) + + // Test mixed usage + c2, _ := CreateTestContext(httptest.NewRecorder()) + c2.Error(errors.New("gin error")) + c2.Error(errors.Join(err1, err2)) + c2.Error(errors.New("other error")) + + assert.Len(t, c2.Errors, 4) + expected := `Error #01: gin error +Error #02: service error +Error #03: store error +Error #04: other error +` + assert.Equal(t, expected, c2.Errors.String()) + + // Test empty join (edge case) + c3, _ := CreateTestContext(httptest.NewRecorder()) + emptyJoin := errors.Join() // Creates nil error + if emptyJoin != nil { + c3.Error(emptyJoin) + // errors.Join() with no arguments returns nil, so this shouldn't panic + } + + // Test nested joins + c4, _ := CreateTestContext(httptest.NewRecorder()) + err3 := errors.New("nested1") + err4 := errors.New("nested2") + nestedJoin := errors.Join(errors.Join(err3, err4), errors.New("outer")) + c4.Error(nestedJoin) + assert.Len(t, c4.Errors, 3) + assert.Equal(t, "nested1", c4.Errors[0].Error()) + assert.Equal(t, "nested2", c4.Errors[1].Error()) + assert.Equal(t, "outer", c4.Errors[2].Error()) +} +