From 9cebe2dc891aa2e24036d6daf0003044d5b1f00f Mon Sep 17 00:00:00 2001 From: vierblatt <1846067379@qq.com> Date: Sun, 5 Jul 2026 02:08:51 +0800 Subject: [PATCH] feat(context): unwrap joined errors in Context.Error() errors.Join() produces a single error implementing Unwrap() []error. Previously, c.Error(errors.Join(e1, e2)) stored the joined error as one entry, confusing errorMsgs output. Now each unwrapped error becomes an individual entry, with gin.Error type preserved when applicable. Fixes #4237 --- context.go | 11 ++++++++++ context_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/context.go b/context.go index 1dc730e3..5821ab14 100644 --- a/context.go +++ b/context.go @@ -264,6 +264,17 @@ func (c *Context) Error(err error) *Error { panic("err is nil") } + // Unwrap joined errors (e.g. from errors.Join), adding each individually + if joined, ok := err.(interface{ Unwrap() []error }); ok { + if errs := joined.Unwrap(); len(errs) > 0 { + var last *Error + for _, e := range errs { + last = c.Error(e) + } + return last + } + } + var parsedError *Error ok := errors.As(err, &parsedError) if !ok { diff --git a/context_test.go b/context_test.go index e8d305e4..ddbd9274 100644 --- a/context_test.go +++ b/context_test.go @@ -2048,6 +2048,61 @@ func TestContextTypedError(t *testing.T) { assert.Equal(t, []string{"externo 0", "interno 0"}, c.Errors.Errors()) } +func TestContextErrorWithJoinedErrors(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + assert.Empty(t, c.Errors) + + firstErr := errors.New("first error") + secondErr := errors.New("second error") + c.Error(errors.Join(firstErr, secondErr)) //nolint: errcheck + + assert.Len(t, c.Errors, 2) + assert.Equal(t, firstErr, c.Errors[0].Err) + assert.Equal(t, secondErr, c.Errors[1].Err) + assert.Equal(t, ErrorTypePrivate, c.Errors[0].Type) + assert.Equal(t, ErrorTypePrivate, c.Errors[1].Type) +} + +func TestContextErrorWithNestedJoinedErrors(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + + c.Error(errors.Join( //nolint: errcheck + errors.New("first"), + errors.Join(errors.New("second"), errors.New("third")), + errors.New("fourth"), + )) + + assert.Len(t, c.Errors, 4) + assert.Equal(t, []string{"first", "second", "third", "fourth"}, c.Errors.Errors()) +} + +func TestContextErrorJoinPreservesGinErrorType(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + + ginErr := &Error{Err: errors.New("typed"), Type: ErrorTypePublic} + c.Error(errors.Join(ginErr, errors.New("plain"))) //nolint: errcheck + + assert.Len(t, c.Errors, 2) + assert.Equal(t, ErrorTypePublic, c.Errors[0].Type) + assert.Equal(t, ErrorTypePrivate, c.Errors[1].Type) +} + +func TestContextErrorWithEmptyUnwrap(t *testing.T) { + // Custom error that implements Unwrap() []error but returns empty slice + c, _ := CreateTestContext(httptest.NewRecorder()) + + emptyJoinErr := emptyJoinError{errors.New("wrapped")} + c.Error(emptyJoinErr) //nolint: errcheck + + assert.Len(t, c.Errors, 1) + assert.Equal(t, ErrorTypePrivate, c.Errors[0].Type) +} + +type emptyJoinError struct{ err error } + +func (e emptyJoinError) Error() string { return e.err.Error() } +func (e emptyJoinError) Unwrap() []error { return nil } + func TestContextAbortWithError(t *testing.T) { w := httptest.NewRecorder() c, _ := CreateTestContext(w)