diff --git a/doc.go b/doc.go index aeb207e..8554036 100644 --- a/doc.go +++ b/doc.go @@ -180,7 +180,9 @@ // # Standard Go Error Compatibility // // TrogonError implements the standard Go error interface and works with -// errors.Is, errors.As, and error wrapping: +// errors.Is, errors.As, and error wrapping. Both the error wrapped via +// WithWrap and every cause added via WithCause are reachable, so errors.Is +// and errors.As also match errors nested anywhere in the cause chain: // // // Type assertion // if tErr, ok := err.(*trogonerror.TrogonError); ok { diff --git a/error.go b/error.go index b76f30e..b1c215d 100644 --- a/error.go +++ b/error.go @@ -91,7 +91,7 @@ type TrogonError struct { domain string reason string metadata Metadata - causes []*TrogonError + causes causes visibility Visibility subject string id string @@ -187,12 +187,37 @@ func (e TrogonError) Is(target error) bool { case TrogonError: return e.domain == t.domain && e.reason == t.reason default: - return errors.Is(e.wrappedErr, target) + return errors.Is(e.Unwrap(), target) } } func (e TrogonError) Unwrap() error { - return e.wrappedErr + switch { + case len(e.causes) == 0: + return e.wrappedErr + case e.wrappedErr == nil: + return e.causes + default: + return errors.Join(e.wrappedErr, e.causes) + } +} + +type causes []*TrogonError + +func (c causes) Error() string { + messages := make([]string, len(c)) + for i, cause := range c { + messages[i] = cause.Error() + } + return strings.Join(messages, "\n") +} + +func (c causes) Unwrap() []error { + errs := make([]error, len(c)) + for i, cause := range c { + errs[i] = cause + } + return errs } func (c Code) Message() string { @@ -339,7 +364,7 @@ func NewError(domain, reason string, options ...ErrorOption) *TrogonError { domain: domain, reason: reason, metadata: make(Metadata), - causes: make([]*TrogonError, 0), + causes: make(causes, 0), visibility: VisibilityInternal, } @@ -541,7 +566,11 @@ func WithRetryTime(retryTime time.Time) ErrorOption { // WithCause adds one or more causes to the error func WithCause(causes ...*TrogonError) ErrorOption { return func(e *TrogonError) { - e.causes = append(e.causes, causes...) + for _, cause := range causes { + if cause != nil { + e.causes = append(e.causes, cause) + } + } } } @@ -584,7 +613,7 @@ func (e *TrogonError) copy() *TrogonError { } if len(e.causes) > 0 { - clonedErr.causes = make([]*TrogonError, len(e.causes)) + clonedErr.causes = make(causes, len(e.causes)) copy(clonedErr.causes, e.causes) } diff --git a/error_test.go b/error_test.go index 66ac842..e228cd0 100644 --- a/error_test.go +++ b/error_test.go @@ -971,3 +971,143 @@ func TestErrorTemplate(t *testing.T) { assert.Len(t, err.Help().Links(), 1) }) } + +func TestTrogonErrorCauseTraversal(t *testing.T) { + t.Run("errors.Is finds a TrogonError cause", func(t *testing.T) { + inner := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT") + innerSentinel := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT") + unrelated := trogonerror.NewError("shopify.network", "NETWORK_UNAVAILABLE") + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithCause(inner)) + + assert.True(t, errors.Is(outer, innerSentinel)) + assert.False(t, errors.Is(outer, unrelated)) + }) + + t.Run("errors.Is finds a nested cause", func(t *testing.T) { + leaf := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT") + leafSentinel := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT") + + middle := trogonerror.NewError("shopify.network", "NETWORK_UNAVAILABLE", + trogonerror.WithCause(leaf)) + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithCause(middle)) + + assert.True(t, errors.Is(outer, leafSentinel)) + }) + + t.Run("errors.Is finds a standard sentinel error wrapped inside a cause", func(t *testing.T) { + sentinel := errors.New("sentinel error") + + inner := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT", + trogonerror.WithWrap(sentinel)) + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithCause(inner)) + + assert.True(t, errors.Is(outer, sentinel)) + }) + + t.Run("errors.Is still finds the directly wrapped error alongside causes", func(t *testing.T) { + sentinel := errors.New("sentinel error") + cause := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT") + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithWrap(sentinel), + trogonerror.WithCause(cause)) + + assert.True(t, errors.Is(outer, sentinel)) + }) + + t.Run("errors.Is still finds the directly wrapped error without causes", func(t *testing.T) { + sentinel := errors.New("sentinel error") + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithWrap(sentinel)) + + assert.True(t, errors.Is(outer, sentinel)) + }) + + t.Run("errors.As finds a custom error type stored inside a cause's wrapped error", func(t *testing.T) { + custom := CustomError{msg: "custom error"} + + cause := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT", + trogonerror.WithWrap(custom)) + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithCause(cause)) + + var target CustomError + assert.True(t, errors.As(outer, &target)) + assert.Equal(t, custom, target) + }) + + t.Run("errors.As returns the outer error, not a cause, when the outer is a TrogonError", func(t *testing.T) { + cause := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT") + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithCause(cause)) + + var target *trogonerror.TrogonError + assert.True(t, errors.As(outer, &target)) + assert.Equal(t, "shopify.payments", target.Domain()) + assert.Equal(t, "PAYMENT_DECLINED", target.Reason()) + }) + + t.Run("Unwrap returns exactly wrappedErr when there are no causes", func(t *testing.T) { + sentinel := errors.New("sentinel error") + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithWrap(sentinel)) + + assert.True(t, outer.Unwrap() == sentinel) + }) + + t.Run("Unwrap returns nil when neither wrappedErr nor causes are set", func(t *testing.T) { + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED") + + assert.Nil(t, outer.Unwrap()) + }) + + t.Run("An outer error does not match errors.Is for an unrelated TrogonError", func(t *testing.T) { + cause := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT") + unrelated := trogonerror.NewError("shopify.unrelated", "UNRELATED_REASON") + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithCause(cause)) + + assert.False(t, errors.Is(outer, unrelated)) + }) +} + +func TestTrogonErrorIsDirectCall(t *testing.T) { + sentinel := errors.New("sentinel error") + cause := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT", + trogonerror.WithWrap(sentinel)) + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithCause(cause)) + + assert.True(t, outer.Is(sentinel)) + assert.False(t, outer.Is(errors.New("other"))) +} + +func TestWithCauseIgnoresNil(t *testing.T) { + sentinel := errors.New("sentinel error") + cause := trogonerror.NewError("shopify.database", "CONNECTION_TIMEOUT", + trogonerror.WithWrap(sentinel)) + + outer := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithCause(nil, cause, nil)) + + assert.Len(t, outer.Causes(), 1) + assert.True(t, errors.Is(outer, sentinel)) + assert.False(t, errors.Is(outer, errors.New("other"))) + assert.NotPanics(t, func() { _ = outer.Unwrap().Error() }) + + onlyNil := trogonerror.NewError("shopify.payments", "PAYMENT_DECLINED", + trogonerror.WithCause(nil)) + assert.Empty(t, onlyNil.Causes()) + assert.Nil(t, onlyNil.Unwrap()) +}