feat(context): unwrap joined errors in Context.Error()

This commit is contained in:
Lev 2026-04-15 21:48:28 +03:00
parent e3a2d6f577
commit 1b544a6c82
2 changed files with 78 additions and 0 deletions

View File

@ -244,16 +244,37 @@ func (c *Context) AbortWithError(code int, err error) *Error {
/********* ERROR MANAGEMENT *********/
/************************************/
// joinedError is an interface for errors that wrap multiple inner errors,
// such as those created by errors.Join.
type joinedError interface {
Unwrap() []error
}
// Error attaches an error to the current context. The error is pushed to a list of errors.
// It's a good idea to call Error for each error that occurred during the resolution of a request.
// A middleware can be used to collect all the errors and push them to a database together,
// print a log, or append it in the HTTP response.
// Error will panic if err is nil.
// If err is a joined error (created by errors.Join), it is unwrapped and each
// individual error is added as a separate entry. The last *Error added is returned.
func (c *Context) Error(err error) *Error {
if err == nil {
panic("err is nil")
}
// Unwrap joined errors so each one becomes a separate entry.
if joined, ok := err.(joinedError); ok {
errs := joined.Unwrap()
if len(errs) > 0 {
var last *Error
for _, e := range errs {
last = c.Error(e)
}
return last
}
// Fall through for empty joined errors — store as-is.
}
var parsedError *Error
ok := errors.As(err, &parsedError)
if !ok {

View File

@ -1982,6 +1982,63 @@ func TestContextTypedError(t *testing.T) {
assert.Equal(t, []string{"externo 0", "interno 0"}, c.Errors.Errors())
}
func TestContextErrorJoined(t *testing.T) {
c, _ := CreateTestContext(httptest.NewRecorder())
// Single errors around the joined one.
c.Error(errors.New("first")) //nolint: errcheck
joined := errors.Join(errors.New("service error"), errors.New("store error"))
last := c.Error(joined) //nolint: errcheck
c.Error(errors.New("last")) //nolint: errcheck
// Joined error must be unwrapped into separate entries.
assert.Len(t, c.Errors, 4)
assert.Equal(t, "first", c.Errors[0].Error())
assert.Equal(t, "service error", c.Errors[1].Error())
assert.Equal(t, "store error", c.Errors[2].Error())
assert.Equal(t, "last", c.Errors[3].Error())
// Return value is the last unwrapped entry.
assert.Equal(t, "store error", last.Error())
// All unwrapped entries default to ErrorTypePrivate.
for _, e := range c.Errors {
assert.Equal(t, ErrorTypePrivate, e.Type)
}
// String output should list each error individually.
expected := "Error #01: first\nError #02: service error\nError #03: store error\nError #04: last\n"
assert.Equal(t, expected, c.Errors.String())
}
func TestContextErrorNestedJoined(t *testing.T) {
c, _ := CreateTestContext(httptest.NewRecorder())
inner := errors.Join(errors.New("a"), errors.New("b"))
outer := errors.Join(inner, errors.New("c"))
c.Error(outer) //nolint: errcheck
assert.Len(t, c.Errors, 3)
assert.Equal(t, []string{"a", "b", "c"}, c.Errors.Errors())
}
func TestContextErrorJoinedWithTypedError(t *testing.T) {
c, _ := CreateTestContext(httptest.NewRecorder())
typedErr := &Error{Err: errors.New("typed"), Type: ErrorTypePublic, Meta: "meta"}
joined := errors.Join(errors.New("plain"), typedErr)
c.Error(joined) //nolint: errcheck
assert.Len(t, c.Errors, 2)
assert.Equal(t, "plain", c.Errors[0].Error())
assert.Equal(t, ErrorTypePrivate, c.Errors[0].Type)
assert.Equal(t, "typed", c.Errors[1].Error())
assert.Equal(t, ErrorTypePublic, c.Errors[1].Type)
assert.Equal(t, "meta", c.Errors[1].Meta)
}
func TestContextAbortWithError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)