Skip to content

Commit 466bac0

Browse files
committed
Update and refactor error handling: enhance MultiError, add thread-safety, improve DataError functionality, and extend test coverage
1 parent c18e0e6 commit 466bac0

8 files changed

Lines changed: 311 additions & 106 deletions

File tree

doc.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
1+
// Package errors provides an extended error type with structured key-value
2+
// fields, stack traces, and cause chaining, along with a thread-safe
3+
// multi-error container. It is a drop-in superset of the standard library
4+
// errors package: Unwrap, Is, and As are re-exported so callers only need to
5+
// import this package.
16
package errors

error.go

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,41 @@ import (
44
"errors"
55
"fmt"
66
"reflect"
7+
"runtime"
78
)
89

10+
// DataError is an error enriched with structured key-value fields, an optional
11+
// cause chain, and a captured stack trace. All methods that add data return a
12+
// new copy; the original is never mutated.
913
type DataError struct {
1014
err error
1115
data map[string]any
1216
cause error
17+
stack []uintptr
1318
}
1419

20+
// New creates a DataError from the given text and captures the current stack
21+
// trace.
1522
func New(text string) *DataError {
1623
return &DataError{
17-
err: errors.New(text),
24+
err: errors.New(text),
25+
stack: callers(1),
1826
}
1927
}
2028

29+
// Newf creates a DataError from a formatted string and captures the current
30+
// stack trace.
2131
func Newf(format string, v ...any) *DataError {
2232
return &DataError{
23-
err: fmt.Errorf(format, v...),
33+
err: fmt.Errorf(format, v...),
34+
stack: callers(1),
2435
}
2536
}
2637

38+
// Wrap converts any value into a *DataError and captures the current stack
39+
// trace. If err is nil, Wrap returns nil. If err is already a *DataError it is
40+
// returned unchanged. If err implements error it is wrapped directly; otherwise
41+
// its string representation is used as the error message.
2742
func Wrap(err any) *DataError {
2843
if err == nil {
2944
return nil
@@ -40,22 +55,45 @@ func Wrap(err any) *DataError {
4055
}
4156

4257
return &DataError{
43-
err: e,
58+
err: e,
59+
stack: callers(1),
4460
}
4561
}
4662

63+
// callers returns up to 32 program counters starting skip frames above the
64+
// caller.
65+
func callers(skip int) []uintptr {
66+
stack := make([]uintptr, 32)
67+
n := runtime.Callers(skip+2, stack)
68+
69+
return stack[:n]
70+
}
71+
72+
// Error returns the error message string.
4773
func (e *DataError) Error() string {
4874
return e.err.Error()
4975
}
5076

77+
// Fields returns the structured key-value data attached to this error.
5178
func (e *DataError) Fields() map[string]any {
5279
return e.data
5380
}
5481

82+
// StackTrace returns the program counters captured when the error was created.
83+
func (e *DataError) StackTrace() []uintptr {
84+
return e.stack
85+
}
86+
87+
// WithField returns a copy of the error with the given key-value field added.
88+
// The original error is not modified.
5589
func (e *DataError) WithField(key string, value any) *DataError {
5690
return e.WithFields(map[string]any{key: value})
5791
}
5892

93+
// WithFields returns a copy of the error with the given fields merged in.
94+
// The original error is not modified. Function values (including pointers to
95+
// functions) are silently ignored because they are not safely comparable or
96+
// serialisable.
5997
func (e *DataError) WithFields(values map[string]any) *DataError {
6098
data := make(map[string]any, len(e.data)+len(values))
6199
for k, v := range e.data {
@@ -77,17 +115,23 @@ func (e *DataError) WithFields(values map[string]any) *DataError {
77115
err: e.err,
78116
data: data,
79117
cause: e.cause,
118+
stack: e.stack,
80119
}
81120
}
82121

122+
// WithCause returns a copy of the error with the given cause attached. The
123+
// cause is returned by Unwrap, making it visible to errors.Is and errors.As.
83124
func (e *DataError) WithCause(err error) *DataError {
84125
return &DataError{
85126
err: e.err,
86127
data: e.data,
87128
cause: err,
129+
stack: e.stack,
88130
}
89131
}
90132

133+
// Unwrap returns the cause if one was set via WithCause; otherwise it returns
134+
// the underlying error created by New, Newf, or Wrap.
91135
func (e *DataError) Unwrap() error {
92136
if e.cause != nil {
93137
return e.cause
@@ -96,13 +140,18 @@ func (e *DataError) Unwrap() error {
96140
return e.err
97141
}
98142

143+
// Is reports whether e matches target. Two *DataError values are considered
144+
// equal when their messages match and every field present in target also
145+
// appears in e with the same value. This allows errors.Is to find a sentinel
146+
// DataError anywhere in a chain, optionally scoped by fields.
99147
func (e *DataError) Is(target error) bool {
100-
err, ok := target.(*DataError)
148+
var err *DataError
149+
ok := errors.As(target, &err)
101150
if !ok {
102151
return false
103152
}
104153

105-
if e.Error() != target.Error() {
154+
if e.Error() != err.Error() {
106155
return false
107156
}
108157

error_test.go

Lines changed: 131 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -8,88 +8,155 @@ import (
88
"github.com/gravitton/assert"
99
)
1010

11-
func TestFields(t *testing.T) {
12-
err := Newf("Test DataError #%d: %s", 5, "failed to spawn")
13-
assert.Equal(t, err.Error(), "Test DataError #5: failed to spawn")
14-
assert.Length(t, err.Fields(), 0)
11+
func TestNew(t *testing.T) {
12+
err := New("test")
1513

16-
err = Wrap("test")
1714
assert.Equal(t, err.Error(), "test")
15+
assert.Empty(t, err.Fields())
1816

19-
err = New("test3").WithField("action", "call")
20-
assert.Equal(t, err.Error(), "test3")
21-
assert.Equal(t, err.Fields()["action"], "call")
22-
assert.NotContains(t, err.Fields(), "type")
17+
cause := err.Unwrap()
18+
assert.Equal(t, reflect.TypeOf(cause).String(), "*errors.errorString")
19+
}
2320

24-
err1 := Wrap(err)
25-
assert.Same(t, err, err1)
21+
func TestNewf(t *testing.T) {
22+
err := Newf("Test DataError #%d: %s", 5, "failed to spawn")
2623

27-
err2 := err.WithFields(map[string]any{"type": "warning"})
28-
assert.NotSame(t, err1, err2)
29-
assert.NotContains(t, err.Fields(), "type")
30-
assert.Equal(t, err.Error(), "test3")
31-
assert.Equal(t, err2.Fields()["type"], "warning")
32-
33-
err3 := err.WithField("action", "send")
34-
assert.Equal(t, err2.Fields()["action"], "call")
35-
assert.Equal(t, err3.Fields()["action"], "send")
36-
37-
err4 := New("error")
38-
err5 := New("error")
39-
assert.Equal(t, err4, err5)
40-
assert.NotSame(t, err4, err5)
24+
assert.Equal(t, err.Error(), "Test DataError #5: failed to spawn")
25+
assert.Empty(t, err.Fields())
26+
27+
cause := err.Unwrap()
28+
assert.Equal(t, reflect.TypeOf(cause).String(), "*errors.errorString")
29+
}
30+
31+
func TestWrapNil(t *testing.T) {
32+
err := Wrap(nil)
33+
34+
assert.NoError(t, err)
4135
}
4236

43-
func TestUnwrap(t *testing.T) {
44-
err := New("original error 1")
37+
func TestWrapNonError(t *testing.T) {
38+
err := Wrap("something went wrong")
39+
40+
assert.Equal(t, err.Error(), "something went wrong")
4541

4642
cause := err.Unwrap()
43+
assert.Equal(t, reflect.TypeOf(cause).String(), "*errors.errorString")
44+
}
45+
46+
func TestWrapNonErrorNonString(t *testing.T) {
47+
err := Wrap(159.5)
4748

49+
assert.Equal(t, err.Error(), "159.5")
50+
51+
cause := err.Unwrap()
4852
assert.Equal(t, reflect.TypeOf(cause).String(), "*errors.errorString")
49-
assert.Equal(t, "original error 1", cause.Error())
53+
}
54+
55+
func TestWrapError(t *testing.T) {
56+
original := errors.New("original")
57+
err := Wrap(original)
58+
59+
assert.Equal(t, err.Error(), "original")
60+
61+
cause := err.Unwrap()
62+
assert.Equal(t, cause, original)
63+
}
64+
65+
func TestWrapDataError(t *testing.T) {
66+
original := New("original")
67+
err := Wrap(original)
68+
69+
assert.Same(t, err, original)
70+
}
71+
72+
func TestStackTrace(t *testing.T) {
73+
err1 := New("test")
74+
err2 := Newf("test %d", 1)
75+
err3 := Wrap(errors.New("std"))
76+
77+
assert.NotEmpty(t, err1.StackTrace())
78+
assert.NotEmpty(t, err2.StackTrace())
79+
assert.NotEmpty(t, err3.StackTrace())
80+
}
5081

51-
oErr := errors.New("original error 2")
52-
err = Wrap(oErr)
82+
func TestFields(t *testing.T) {
83+
err1 := New("test")
84+
85+
assert.Empty(t, err1.Fields())
5386

54-
assert.Same(t, oErr, err.Unwrap())
87+
err2 := err1.WithField("action", "call")
88+
89+
assert.NotSame(t, err1, err2)
90+
assert.Empty(t, err1.Fields())
91+
assert.Equal(t, err2.Fields(), map[string]any{"action": "call"})
5592

56-
oErr2 := errors.New("original error 3")
57-
err = err.WithCause(oErr2)
58-
assert.Same(t, oErr2, err.Unwrap())
93+
err3 := err2.WithFields(map[string]any{"type": "warning"})
94+
95+
assert.NotSame(t, err2, err3)
96+
assert.Equal(t, err2.Fields(), map[string]any{"action": "call"})
97+
assert.Equal(t, err3.Fields(), map[string]any{"action": "call", "type": "warning"})
98+
99+
err4 := err3.WithFields(map[string]any{"type": "error", "debug": true, "line": 15})
100+
101+
assert.NotSame(t, err3, err4)
102+
assert.Equal(t, err3.Fields(), map[string]any{"action": "call", "type": "warning"})
103+
assert.Equal(t, err4.Fields(), map[string]any{"action": "call", "type": "error", "debug": true, "line": 15})
104+
}
105+
106+
func TestWithFieldsDropsFunctions(t *testing.T) {
107+
err := New("test").WithFields(map[string]any{"key": "value", "func": func() {}})
108+
109+
assert.Equal(t, err.Fields(), map[string]any{"key": "value"})
110+
}
111+
112+
func TestWithCause(t *testing.T) {
113+
err1 := New("test")
114+
original := errors.New("original error")
115+
116+
err2 := err1.WithCause(original)
117+
118+
assert.NotSame(t, err1, err2)
119+
assert.Same(t, err2.Unwrap(), original)
59120
}
60121

61122
func TestErrorsIs(t *testing.T) {
62-
tests := []struct {
63-
name string
64-
err error
65-
}{
66-
{
67-
name: "*errors.errorString",
68-
err: errors.New("dummy error"),
69-
},
70-
{
71-
name: "*DataError",
72-
err: New("dummy error"),
73-
},
74-
}
75-
76-
for _, test := range tests {
77-
t.Run(test.name, func(t *testing.T) {
78-
assert.ErrorIs(t, test.err, test.err)
79-
assert.ErrorIs(t, Wrap(test.err), test.err)
80-
assert.ErrorIs(t, Wrap(test.err).WithField("action", "call"), test.err)
81-
assert.ErrorIs(t, Wrap(test.err).WithField("action", "call").WithField("type", "warning"), test.err)
82-
assert.ErrorIs(t, Wrap(test.err).WithFields(map[string]any{"type": "warning"}), test.err)
83-
})
84-
}
123+
original := errors.New("original error")
124+
err1 := New("original error")
125+
126+
assert.NotErrorIs(t, err1, original)
127+
128+
err2 := Wrap(original)
129+
130+
assert.ErrorIs(t, err2, original)
131+
132+
err3 := err1.WithCause(original)
133+
134+
assert.ErrorIs(t, err3, original)
135+
136+
err4 := err2.WithField("action", "call")
137+
138+
assert.ErrorIs(t, err4, original)
85139
}
86140

87-
func TestErrorsIsWithFields(t *testing.T) {
88-
assert.ErrorIs(t, New("dummy error").WithField("action", "call"), New("dummy error"))
89-
assert.NotErrorIs(t, New("dummy error"), New("dummy error").WithField("action", "call"))
90-
assert.NotErrorIs(t, New("dummy error").WithField("action", "call"), New("dummy error").WithField("action", "send"))
141+
func TestErrorsIsDataError(t *testing.T) {
142+
err1 := New("test")
143+
err2 := New("test2")
144+
err3 := New("test").WithFields(map[string]any{"action": "call", "type": "error"})
145+
err4 := New("test").WithFields(map[string]any{"type": "warn"})
146+
147+
assert.NotErrorIs(t, err1, err2) // different error
148+
assert.NotErrorIs(t, err1, err3) // additional fields
149+
assert.NotErrorIs(t, err1, err4) // additional fields
150+
151+
assert.NotErrorIs(t, err2, err1) // different error
152+
assert.NotErrorIs(t, err2, err3) // different error
153+
assert.NotErrorIs(t, err2, err4) // different error
154+
155+
assert.ErrorIs(t, err3, err1) // missing fields
156+
assert.NotErrorIs(t, err3, err2) // different error
157+
assert.NotErrorIs(t, err3, err4) // different fields
91158

92-
err := New("dummy error").WithField("module", "http")
93-
assert.ErrorIs(t, err.WithField("add", false), err)
94-
assert.NotErrorIs(t, err, err.WithField("add", false))
159+
assert.ErrorIs(t, err4, err1) // missing fields
160+
assert.NotErrorIs(t, err4, err2) // different error
161+
assert.NotErrorIs(t, err4, err3) // different fields
95162
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ module github.com/gravitton/errors
22

33
go 1.26
44

5-
require github.com/gravitton/assert v1.0.0
5+
require github.com/gravitton/assert v1.2.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
github.com/gravitton/assert v1.0.0 h1:VhxPuIN6KNpDXIdMtoKa0tGB/bkxZ0h1mm6jTzBkhnc=
2-
github.com/gravitton/assert v1.0.0/go.mod h1:wQGHJvwsxQQ7qdX++NLofrI2cJvYIdJJtAo7A15qcAY=
1+
github.com/gravitton/assert v1.2.0 h1:j9nvHz03FoaxSebCTd0Jr6RoDirs2lb51Eb2eVbV7a4=
2+
github.com/gravitton/assert v1.2.0/go.mod h1:AdPZ1NXH/Gfj2OkQq+2FvffggEk7lq2PzGPCe30qBe4=

0 commit comments

Comments
 (0)