Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 35 additions & 6 deletions error.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ type TrogonError struct {
domain string
reason string
metadata Metadata
causes []*TrogonError
causes causes
visibility Visibility
subject string
id string
Expand Down Expand Up @@ -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
Comment thread
yordis marked this conversation as resolved.
}

func (c Code) Message() string {
Expand Down Expand Up @@ -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,
}

Expand Down Expand Up @@ -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)
}
}
}
}

Expand Down Expand Up @@ -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)
}

Expand Down
140 changes: 140 additions & 0 deletions error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Loading