Skip to content

Commit 72ac864

Browse files
committed
add error annotations
1 parent 23e9478 commit 72ac864

File tree

2 files changed

+236
-0
lines changed

2 files changed

+236
-0
lines changed

annotate.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package errors
2+
3+
func Annotate(err error, values ...any) error {
4+
if err == nil {
5+
return nil
6+
}
7+
if len(values) == 0 {
8+
return err
9+
}
10+
return &Error{
11+
error: err,
12+
arg: values,
13+
}
14+
}
15+
16+
// Annotation looks at the arguments supplied to Errrof(), Wrapf(), and Annotate(),
17+
// looking to see if any of them match the type T. If so, it returns the value and true.
18+
// Otherwise it returns an empty T and false.
19+
func Annotation[T any](err error) (T, bool) {
20+
var rv T
21+
var found bool
22+
Walk(err, func(ex error) bool {
23+
withArg, ok := ex.(*Error)
24+
if !ok {
25+
return true
26+
}
27+
for _, arg := range withArg.arg {
28+
if v, ok := arg.(T); ok {
29+
rv = v
30+
found = true
31+
return false
32+
}
33+
}
34+
return true
35+
})
36+
return rv, found
37+
}

annotate_test.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package errors_test
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
"github.com/memsql/errors"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestAnnotate(t *testing.T) {
13+
baseErr := fmt.Errorf("base error")
14+
15+
t.Run("nil error returns nil", func(t *testing.T) {
16+
result := errors.Annotate(nil, "some", "values")
17+
require.Nil(t, result)
18+
})
19+
20+
t.Run("no values returns original error", func(t *testing.T) {
21+
result := errors.Annotate(baseErr)
22+
require.Equal(t, baseErr, result)
23+
})
24+
25+
t.Run("empty values returns original error", func(t *testing.T) {
26+
result := errors.Annotate(baseErr, []any{}...)
27+
require.Equal(t, baseErr, result)
28+
})
29+
30+
t.Run("single value annotation", func(t *testing.T) {
31+
annotated := errors.Annotate(baseErr, "test-value")
32+
require.NotEqual(t, baseErr, annotated)
33+
require.Equal(t, "base error", annotated.Error())
34+
35+
// Should be able to unwrap to get original error
36+
require.Equal(t, baseErr, errors.Unwrap(annotated))
37+
})
38+
39+
t.Run("multiple values annotation", func(t *testing.T) {
40+
annotated := errors.Annotate(baseErr, "string", 42, true, struct{ Name string }{"test"})
41+
require.Equal(t, "base error", annotated.Error())
42+
require.Equal(t, baseErr, errors.Unwrap(annotated))
43+
})
44+
45+
t.Run("annotation preserves error chain", func(t *testing.T) {
46+
wrapped := fmt.Errorf("wrapped: %w", baseErr)
47+
annotated := errors.Annotate(wrapped, "metadata")
48+
49+
require.True(t, errors.Is(annotated, baseErr))
50+
require.True(t, errors.Is(annotated, wrapped))
51+
})
52+
}
53+
54+
func TestAnnotation(t *testing.T) {
55+
baseErr := fmt.Errorf("base error")
56+
57+
t.Run("no annotations returns zero value and false", func(t *testing.T) {
58+
value, found := errors.Annotation[string](baseErr)
59+
require.False(t, found)
60+
require.Equal(t, "", value)
61+
62+
intValue, intFound := errors.Annotation[int](baseErr)
63+
require.False(t, intFound)
64+
require.Equal(t, 0, intValue)
65+
})
66+
67+
t.Run("finds string annotation", func(t *testing.T) {
68+
annotated := errors.Annotate(baseErr, "test-string", 42)
69+
70+
value, found := errors.Annotation[string](annotated)
71+
require.True(t, found)
72+
require.Equal(t, "test-string", value)
73+
})
74+
75+
t.Run("finds int annotation", func(t *testing.T) {
76+
annotated := errors.Annotate(baseErr, "test-string", 42, true)
77+
78+
value, found := errors.Annotation[int](annotated)
79+
require.True(t, found)
80+
require.Equal(t, 42, value)
81+
})
82+
83+
t.Run("finds bool annotation", func(t *testing.T) {
84+
annotated := errors.Annotate(baseErr, "test-string", 42, true)
85+
86+
value, found := errors.Annotation[bool](annotated)
87+
require.True(t, found)
88+
require.Equal(t, true, value)
89+
})
90+
91+
t.Run("finds struct annotation", func(t *testing.T) {
92+
type TestStruct struct {
93+
Name string
94+
ID int
95+
}
96+
testStruct := TestStruct{Name: "test", ID: 123}
97+
annotated := errors.Annotate(baseErr, "other", testStruct)
98+
99+
value, found := errors.Annotation[TestStruct](annotated)
100+
require.True(t, found)
101+
require.Equal(t, testStruct, value)
102+
})
103+
104+
t.Run("returns first matching annotation when multiple exist", func(t *testing.T) {
105+
annotated := errors.Annotate(baseErr, "first", "second", "third")
106+
107+
value, found := errors.Annotation[string](annotated)
108+
require.True(t, found)
109+
require.Equal(t, "first", value)
110+
})
111+
112+
t.Run("finds annotation in wrapped errors", func(t *testing.T) {
113+
annotated := errors.Annotate(baseErr, "inner-value")
114+
wrapped := fmt.Errorf("outer error: %w", annotated)
115+
116+
value, found := errors.Annotation[string](wrapped)
117+
require.True(t, found)
118+
require.Equal(t, "inner-value", value)
119+
})
120+
121+
t.Run("finds annotation in deeply nested errors", func(t *testing.T) {
122+
level1 := errors.Annotate(baseErr, "level1")
123+
level2 := errors.Annotate(level1, 42)
124+
level3 := fmt.Errorf("level3: %w", level2)
125+
level4 := errors.Annotate(level3, true)
126+
127+
// Should find annotations from any level
128+
strValue, strFound := errors.Annotation[string](level4)
129+
require.True(t, strFound)
130+
require.Equal(t, "level1", strValue)
131+
132+
intValue, intFound := errors.Annotation[int](level4)
133+
require.True(t, intFound)
134+
require.Equal(t, 42, intValue)
135+
136+
boolValue, boolFound := errors.Annotation[bool](level4)
137+
require.True(t, boolFound)
138+
require.Equal(t, true, boolValue)
139+
})
140+
141+
t.Run("works with Errorf annotations", func(t *testing.T) {
142+
type UserID int
143+
userID := UserID(12345)
144+
145+
// Test that Annotation works with errors created by Errorf
146+
errWithArg := errors.Errorf("user operation failed for user %v", userID)
147+
148+
value, found := errors.Annotation[UserID](errWithArg)
149+
require.True(t, found)
150+
require.Equal(t, userID, value)
151+
})
152+
153+
t.Run("annotation survives error wrapping", func(t *testing.T) {
154+
annotated := errors.Annotate(baseErr, "preserved-value")
155+
wrapped1 := fmt.Errorf("wrap1: %w", annotated)
156+
wrapped2 := fmt.Errorf("wrap2: %w", wrapped1)
157+
158+
value, found := errors.Annotation[string](wrapped2)
159+
require.True(t, found)
160+
require.Equal(t, "preserved-value", value)
161+
})
162+
163+
t.Run("handles pointer types", func(t *testing.T) {
164+
testStr := "pointer-test"
165+
annotated := errors.Annotate(baseErr, &testStr)
166+
167+
value, found := errors.Annotation[*string](annotated)
168+
require.True(t, found)
169+
require.Equal(t, &testStr, value)
170+
require.Equal(t, "pointer-test", *value)
171+
})
172+
173+
t.Run("handles interface types", func(t *testing.T) {
174+
annotated := errors.Annotate(baseErr, time.Second)
175+
176+
value, found := errors.Annotation[fmt.Stringer](annotated)
177+
require.True(t, found)
178+
require.Equal(t, "1s", value.String())
179+
})
180+
}
181+
182+
func TestAnnotateIntegration(t *testing.T) {
183+
t.Run("format preserves annotations", func(t *testing.T) {
184+
baseErr := fmt.Errorf("base error")
185+
annotated := errors.Annotate(baseErr, "metadata", 42)
186+
187+
// The error message should be preserved
188+
require.Equal(t, "base error", annotated.Error())
189+
190+
// And we should still be able to get annotations
191+
strValue, strFound := errors.Annotation[string](annotated)
192+
require.True(t, strFound)
193+
require.Equal(t, "metadata", strValue)
194+
195+
intValue, intFound := errors.Annotation[int](annotated)
196+
require.True(t, intFound)
197+
require.Equal(t, 42, intValue)
198+
})
199+
}

0 commit comments

Comments
 (0)