Skip to content

Commit a3aeef9

Browse files
Fix error counter keying on dynamic error messages (#182)
* Fix error counter keying on dynamic error messages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add tests for label-based error counter keying Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Require at least one label in ErrorCounter API The previous approach silently dropped the error message from the key but kept labels optional. This meant callers without labels would share a single global counter — a worse behaviour change. Now the interface requires at least one label (label string, extras ...string), making the contract explicit. All existing callers already pass two labels (processName, runID) so no call sites change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove unused err parameter from ErrorCounter interface The error value was accepted but ignored for keying — drop it entirely to make the label-only keying explicit in the API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use null byte separator in makeKey to prevent key collisions Labels containing hyphens (e.g. "a-b","c" vs "a","b-c") could map to the same key. Use \x00 as the separator since it cannot appear in human-readable label strings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * pause: remove unused originalErr param from maybePause Now that error counter keys only labels (not err.Error()), the error value passed into maybePause is never read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 654166e commit a3aeef9

9 files changed

Lines changed: 102 additions & 79 deletions

File tree

errors.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ var (
1010
ErrInvalidTransition = errors.New("invalid transition")
1111
)
1212

13-
// ErrorCounter defines an interface for counting occurrences of errors with optional labels.
13+
// ErrorCounter defines an interface for counting errors keyed by stable labels.
14+
// At least one label is required — labels should identify the process and run (e.g. processName, runID).
1415
type ErrorCounter interface {
15-
Add(err error, labels ...string) int
16-
Count(err error, labels ...string) int
17-
Clear(err error, labels ...string)
16+
Add(label string, extras ...string) int
17+
Count(label string, extras ...string) int
18+
Clear(label string, extras ...string)
1819
}

internal/errorcounter/errorcounter.go

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,34 @@ type Counter struct {
1616
store map[string]int
1717
}
1818

19-
func (c *Counter) Add(err error, labels ...string) int {
19+
func (c *Counter) Add(label string, extras ...string) int {
2020
c.mu.Lock()
2121
defer c.mu.Unlock()
2222

23-
errMsg := err.Error()
24-
errMsg += strings.Join(labels, "-")
25-
c.store[errMsg] += 1
26-
return c.store[errMsg]
23+
key := makeKey(label, extras)
24+
c.store[key] += 1
25+
return c.store[key]
2726
}
2827

29-
func (c *Counter) Count(err error, labels ...string) int {
28+
func (c *Counter) Count(label string, extras ...string) int {
3029
c.mu.Lock()
3130
defer c.mu.Unlock()
3231

33-
errMsg := err.Error()
34-
errMsg += strings.Join(labels, "-")
35-
return c.store[errMsg]
32+
key := makeKey(label, extras)
33+
return c.store[key]
3634
}
3735

38-
func (c *Counter) Clear(err error, labels ...string) {
36+
func (c *Counter) Clear(label string, extras ...string) {
3937
c.mu.Lock()
4038
defer c.mu.Unlock()
4139

42-
errMsg := err.Error()
43-
errMsg += strings.Join(labels, "-")
44-
c.store[errMsg] = 0
45-
return
40+
key := makeKey(label, extras)
41+
delete(c.store, key)
42+
}
43+
44+
func makeKey(label string, extras []string) string {
45+
if len(extras) == 0 {
46+
return label
47+
}
48+
return label + "\x00" + strings.Join(extras, "\x00")
4649
}
Lines changed: 71 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package errorcounter_test
22

33
import (
4-
"errors"
54
"testing"
65

76
"github.com/stretchr/testify/require"
@@ -10,52 +9,75 @@ import (
109
)
1110

1211
func TestErrorCounter(t *testing.T) {
13-
testCases := []struct {
14-
name string
15-
inputErr error
16-
labels []string
17-
iterationCount int
18-
expectedCount int
19-
}{
20-
{
21-
name: "Add 3 and get 3",
22-
inputErr: errors.New("test error"),
23-
labels: []string{"label 1", "label 2"},
24-
iterationCount: 3,
25-
expectedCount: 3,
26-
},
27-
{
28-
name: "Add 1 and get 1 - no labels",
29-
inputErr: errors.New("test error"),
30-
labels: []string{},
31-
iterationCount: 3,
32-
expectedCount: 3,
33-
},
34-
{
35-
name: "Add 0 and get 0",
36-
inputErr: errors.New("test error"),
37-
labels: []string{"label 1"},
38-
iterationCount: 0,
39-
expectedCount: 0,
40-
},
41-
}
42-
43-
for _, tc := range testCases {
44-
t.Run(tc.name, func(t *testing.T) {
45-
c := errorcounter.New()
46-
47-
var currentCount int
48-
for i := 0; i < tc.iterationCount; i++ {
49-
currentCount = c.Add(tc.inputErr, tc.labels...)
50-
}
51-
require.Equal(t, tc.expectedCount, currentCount)
52-
53-
count := c.Count(tc.inputErr, tc.labels...)
54-
require.Equal(t, tc.expectedCount, count)
55-
56-
c.Clear(tc.inputErr, tc.labels...)
57-
count = c.Count(tc.inputErr, tc.labels...)
58-
require.Equal(t, 0, count)
59-
})
60-
}
12+
t.Run("Add 3 and get 3", func(t *testing.T) {
13+
c := errorcounter.New()
14+
15+
c.Add("label 1", "label 2")
16+
c.Add("label 1", "label 2")
17+
count := c.Add("label 1", "label 2")
18+
require.Equal(t, 3, count)
19+
20+
require.Equal(t, 3, c.Count("label 1", "label 2"))
21+
22+
c.Clear("label 1", "label 2")
23+
require.Equal(t, 0, c.Count("label 1", "label 2"))
24+
})
25+
26+
t.Run("Single label", func(t *testing.T) {
27+
c := errorcounter.New()
28+
29+
c.Add("only-label")
30+
count := c.Add("only-label")
31+
require.Equal(t, 2, count)
32+
33+
require.Equal(t, 2, c.Count("only-label"))
34+
35+
c.Clear("only-label")
36+
require.Equal(t, 0, c.Count("only-label"))
37+
})
38+
39+
t.Run("Add 0 and get 0", func(t *testing.T) {
40+
c := errorcounter.New()
41+
require.Equal(t, 0, c.Count("label 1"))
42+
})
43+
}
44+
45+
func TestErrorCounter_ClearRemovesKey(t *testing.T) {
46+
c := errorcounter.New()
47+
48+
c.Add("process", "run-1")
49+
c.Add("process", "run-1")
50+
require.Equal(t, 2, c.Count("process", "run-1"))
51+
52+
c.Clear("process", "run-1")
53+
54+
// After clear, count should be 0 and next Add should return 1.
55+
require.Equal(t, 0, c.Count("process", "run-1"))
56+
require.Equal(t, 1, c.Add("process", "run-1"))
57+
}
58+
59+
func TestErrorCounter_DifferentLabelsSeparateCounters(t *testing.T) {
60+
c := errorcounter.New()
61+
62+
c.Add("process-a", "run-1")
63+
c.Add("process-a", "run-1")
64+
c.Add("process-b", "run-2")
65+
66+
require.Equal(t, 2, c.Count("process-a", "run-1"))
67+
require.Equal(t, 1, c.Count("process-b", "run-2"))
68+
}
69+
70+
func TestErrorCounter_NoKeyCollision(t *testing.T) {
71+
c := errorcounter.New()
72+
73+
// ("a-b", "c") and ("a", "b-c") must map to distinct keys.
74+
c.Add("a-b", "c")
75+
c.Add("a", "b-c")
76+
77+
require.Equal(t, 1, c.Count("a-b", "c"))
78+
require.Equal(t, 1, c.Count("a", "b-c"))
79+
80+
c.Clear("a-b", "c")
81+
require.Equal(t, 0, c.Count("a-b", "c"))
82+
require.Equal(t, 1, c.Count("a", "b-c"))
6183
}

pause.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ func maybePause[Type any, Status StatusType](
1414
ctx context.Context,
1515
pauseAfterErrCount int,
1616
counter ErrorCounter,
17-
originalErr error,
1817
processName string,
1918
run *Run[Type, Status],
2019
logger Logger,
@@ -24,7 +23,7 @@ func maybePause[Type any, Status StatusType](
2423
return false, nil
2524
}
2625

27-
count := counter.Add(originalErr, processName, run.RunID)
26+
count := counter.Add(processName, run.RunID)
2827
if count < pauseAfterErrCount {
2928
return false, nil
3029
}
@@ -41,7 +40,7 @@ func maybePause[Type any, Status StatusType](
4140
})
4241

4342
// Run paused - now clear the error counter.
44-
counter.Clear(originalErr, processName, run.RunID)
43+
counter.Clear(processName, run.RunID)
4544
return true, nil
4645
}
4746

pause_internal_test.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
func Test_maybeAutoPause(t *testing.T) {
1616
ctx := t.Context()
1717
counter := errorcounter.New()
18-
testErr := errors.New("test error")
1918
pauseErr := errors.New("pause error")
2019
processName := "process"
2120

@@ -69,16 +68,15 @@ func Test_maybeAutoPause(t *testing.T) {
6968
RunID: "run-id",
7069
}, "test", WithPauseFn(tc.pauseFn))
7170

72-
counter.Clear(testErr, processName, r.RunID)
71+
counter.Clear(processName, r.RunID)
7372
for range tc.errCount {
74-
counter.Add(testErr, processName, r.RunID)
73+
counter.Add(processName, r.RunID)
7574
}
7675

7776
paused, err := maybePause(
7877
ctx,
7978
tc.pauseAfterErrCount,
8079
counter,
81-
testErr,
8280
processName,
8381
r,
8482
&logger{},

step.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ func stepConsumer[Type any, Status StatusType](
175175
next, err := stepLogic(ctx, run)
176176
if err != nil {
177177
originalErr := err
178-
paused, err := maybePause(ctx, pauseAfterErrCount, errorCounter, originalErr, processName, run, logger)
178+
paused, err := maybePause(ctx, pauseAfterErrCount, errorCounter, processName, run, logger)
179179
if err != nil {
180180
return fmt.Errorf("pause error: %v, meta: %v", err, map[string]string{
181181
"run_id": record.RunID,

step_internal_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ func Test_stepConsumer(t *testing.T) {
208208
})
209209

210210
t.Run("Pause record after exceeding allowed error count", func(t *testing.T) {
211-
counter.Clear(testErr, processName, current.RunID)
211+
counter.Clear(processName, current.RunID)
212212

213213
calls := map[string]int{
214214
"consumerFunc": 0,

timeout.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func processTimeout[Type any, Status StatusType](
128128

129129
next, err := config.TimeoutFunc(ctx, run, w.clock.Now())
130130
if err != nil {
131-
_, err := maybePause(ctx, pauseAfterErrCount, w.errorCounter, err, processName, run, w.logger)
131+
_, err := maybePause(ctx, pauseAfterErrCount, w.errorCounter, processName, run, w.logger)
132132
if err != nil {
133133
return fmt.Errorf("pause error: %v, meta: %v", err, map[string]string{
134134
"run_id": record.RunID,

timeout_internal_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,9 @@ func TestProcessTimeout(t *testing.T) {
178178
}
179179
for _, tc := range testCases {
180180
t.Run(tc.name, func(t *testing.T) {
181-
counter.Clear(testErr, processName, tc.record.RunID)
181+
counter.Clear(processName, tc.record.RunID)
182182
for range tc.currentErrCount {
183-
counter.Add(testErr, processName, tc.record.RunID)
183+
counter.Add(processName, tc.record.RunID)
184184
}
185185

186186
calls := map[string]int{}

0 commit comments

Comments
 (0)