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
This commit is contained in:
willlv 2026-03-23 16:00:36 +08:00
parent d3ffc99852
commit 010dea5bd5
2 changed files with 68 additions and 0 deletions

View File

@ -254,6 +254,22 @@ func (c *Context) Error(err error) *Error {
panic("err is nil") 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 var parsedError *Error
ok := errors.As(err, &parsedError) ok := errors.As(err, &parsedError)
if !ok { if !ok {

View File

@ -7,6 +7,7 @@ package gin
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/http/httptest"
"testing" "testing"
"github.com/gin-gonic/gin/codec/json" "github.com/gin-gonic/gin/codec/json"
@ -138,3 +139,54 @@ func TestErrorUnwrap(t *testing.T) {
var testErrNonPointer TestErr var testErrNonPointer TestErr
require.ErrorAs(t, wrappedErr, &testErrNonPointer) 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())
}