Skip to content

Commit 7056338

Browse files
committed
🧪 test(circuitbreaker): add tests for ErrorBreaker
1 parent 1c922fd commit 7056338

File tree

1 file changed

+303
-0
lines changed

1 file changed

+303
-0
lines changed
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
package circuitbreaker
2+
3+
import (
4+
"encoding/json"
5+
"sync"
6+
"testing"
7+
"time"
8+
9+
"github.com/c9s/bbgo/pkg/types"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestErrorBreaker_RecordError(t *testing.T) {
14+
t.Run("should not halt when errors are below threshold", func(t *testing.T) {
15+
breaker := NewErrorBreaker("test", "test-instance", 3, types.Duration(time.Minute))
16+
now := time.Now()
17+
18+
breaker.recordError(now, assert.AnError)
19+
assert.False(t, breaker.isHalted(now))
20+
21+
breaker.recordError(now, assert.AnError)
22+
assert.False(t, breaker.isHalted(now))
23+
})
24+
25+
t.Run("should halt when errors reach threshold", func(t *testing.T) {
26+
breaker := NewErrorBreaker("test", "test-instance", 3, types.Duration(time.Minute))
27+
now := time.Now()
28+
29+
breaker.recordError(now, assert.AnError)
30+
breaker.recordError(now, assert.AnError)
31+
breaker.recordError(now, assert.AnError)
32+
33+
assert.True(t, breaker.isHalted(now))
34+
})
35+
36+
t.Run("should reset when nil error is recorded", func(t *testing.T) {
37+
breaker := NewErrorBreaker("test", "test-instance", 3, types.Duration(time.Minute))
38+
now := time.Now()
39+
40+
breaker.recordError(now, assert.AnError)
41+
breaker.recordError(now, assert.AnError)
42+
assert.Equal(t, 2, breaker.ErrorCount())
43+
44+
// Recording nil error should reset the breaker
45+
breaker.recordError(now, nil)
46+
assert.False(t, breaker.isHalted(now))
47+
assert.Equal(t, 0, breaker.ErrorCount())
48+
})
49+
50+
t.Run("should auto-reset when halt duration expires", func(t *testing.T) {
51+
breaker := NewErrorBreaker("test", "test-instance", 2, types.Duration(100*time.Millisecond))
52+
now := time.Now()
53+
54+
breaker.recordError(now, assert.AnError)
55+
breaker.recordError(now, assert.AnError)
56+
assert.True(t, breaker.isHalted(now))
57+
58+
// Check before halt duration expires - should still be halted
59+
assert.True(t, breaker.isHalted(now.Add(50*time.Millisecond)))
60+
61+
// Check after halt duration expires - should auto-reset
62+
assert.False(t, breaker.isHalted(now.Add(150*time.Millisecond)))
63+
assert.Equal(t, 0, breaker.ErrorCount())
64+
})
65+
66+
t.Run("should call halt callbacks only once when max error count is reached", func(t *testing.T) {
67+
breaker := NewErrorBreaker("test", "test-instance", 2, types.Duration(time.Minute))
68+
now := time.Now()
69+
70+
// Track callback invocations
71+
callbackCalled := false
72+
callCount := 0
73+
var callbackTime time.Time
74+
75+
breaker.OnHalt(func(t time.Time, records []ErrorRecord) {
76+
callbackCalled = true
77+
callCount++
78+
callbackTime = t
79+
})
80+
81+
// Record first error - callback should not be called yet
82+
breaker.recordError(now, assert.AnError)
83+
assert.False(t, callbackCalled, "callback should not be called before threshold")
84+
assert.Equal(t, 0, callCount)
85+
86+
// Record second error to reach threshold - callback should be called once
87+
breaker.recordError(now, assert.AnError)
88+
assert.True(t, callbackCalled, "halt callback should have been called")
89+
assert.Equal(t, 1, callCount)
90+
assert.Equal(t, now, callbackTime)
91+
92+
// Check if halted - callback should not be called again
93+
assert.True(t, breaker.isHalted(now))
94+
assert.Equal(t, 1, callCount, "callback should only be called once")
95+
96+
// Record more errors - callback should still not be called again
97+
breaker.recordError(now, assert.AnError)
98+
assert.Equal(t, 1, callCount, "callback should only be called once even with more errors")
99+
})
100+
}
101+
102+
func TestErrorBreaker_Reset(t *testing.T) {
103+
breaker := NewErrorBreaker("test", "test-instance", 2, types.Duration(time.Minute))
104+
now := time.Now()
105+
106+
breaker.recordError(now, assert.AnError)
107+
breaker.recordError(now, assert.AnError)
108+
109+
assert.True(t, breaker.isHalted(now))
110+
assert.Equal(t, 2, breaker.ErrorCount())
111+
112+
breaker.Reset()
113+
114+
assert.False(t, breaker.isHalted(now))
115+
assert.Equal(t, 0, breaker.ErrorCount())
116+
}
117+
118+
func TestErrorBreaker_ErrorCount(t *testing.T) {
119+
breaker := NewErrorBreaker("test", "test-instance", 5, types.Duration(time.Minute))
120+
now := time.Now()
121+
122+
assert.Equal(t, 0, breaker.ErrorCount())
123+
124+
breaker.recordError(now, assert.AnError)
125+
assert.Equal(t, 1, breaker.ErrorCount())
126+
127+
breaker.recordError(now, assert.AnError)
128+
breaker.recordError(now, assert.AnError)
129+
assert.Equal(t, 3, breaker.ErrorCount())
130+
131+
// Reset via nil error
132+
breaker.recordError(now, nil)
133+
assert.Equal(t, 0, breaker.ErrorCount())
134+
}
135+
136+
func TestErrorBreaker_ConcurrentAccess(t *testing.T) {
137+
breaker := NewErrorBreaker("test", "test-instance", 20, types.Duration(time.Minute))
138+
139+
// Spawn multiple goroutines to record errors concurrently
140+
var wg sync.WaitGroup
141+
for i := 0; i < 5; i++ {
142+
wg.Add(1)
143+
go func(wg *sync.WaitGroup) {
144+
defer wg.Done()
145+
for j := 0; j < 4; j++ {
146+
breaker.RecordError(assert.AnError)
147+
}
148+
}(&wg)
149+
}
150+
151+
// Wait for all goroutines to complete
152+
wg.Wait()
153+
154+
now := time.Now()
155+
assert.True(t, breaker.isHalted(now.Add(time.Second*10)))
156+
assert.Equal(t, 20, breaker.ErrorCount())
157+
}
158+
159+
func TestErrorBreaker_EdgeCases(t *testing.T) {
160+
t.Run("maxErrors of 1 should halt immediately", func(t *testing.T) {
161+
breaker := NewErrorBreaker("test", "test-instance", 1, types.Duration(time.Minute))
162+
now := time.Now()
163+
breaker.recordError(now, assert.AnError)
164+
assert.True(t, breaker.isHalted(now))
165+
})
166+
167+
t.Run("very short halt duration", func(t *testing.T) {
168+
breaker := NewErrorBreaker("test", "test-instance", 2, types.Duration(time.Nanosecond))
169+
now := time.Now()
170+
breaker.recordError(now, assert.AnError)
171+
breaker.recordError(now, assert.AnError)
172+
assert.True(t, breaker.isHalted(now))
173+
// After a microsecond, halt should expire
174+
assert.False(t, breaker.isHalted(now.Add(time.Microsecond)))
175+
})
176+
177+
t.Run("recording errors after halted state", func(t *testing.T) {
178+
breaker := NewErrorBreaker("test", "test-instance", 2, types.Duration(time.Minute))
179+
now := time.Now()
180+
breaker.recordError(now, assert.AnError)
181+
breaker.recordError(now, assert.AnError)
182+
assert.True(t, breaker.isHalted(now))
183+
184+
// Recording more errors should keep it halted
185+
breaker.recordError(now, assert.AnError)
186+
assert.True(t, breaker.isHalted(now))
187+
})
188+
189+
t.Run("nil error should reset breaker", func(t *testing.T) {
190+
breaker := NewErrorBreaker("test", "test-instance", 2, types.Duration(time.Minute))
191+
now := time.Now()
192+
193+
breaker.recordError(now, nil)
194+
assert.False(t, breaker.isHalted(now))
195+
assert.Equal(t, 0, breaker.ErrorCount())
196+
197+
// Recording real error then nil should reset
198+
breaker.recordError(now, assert.AnError)
199+
assert.Equal(t, 1, breaker.ErrorCount())
200+
breaker.recordError(now, nil) // Should reset
201+
assert.Equal(t, 0, breaker.ErrorCount())
202+
assert.False(t, breaker.isHalted(now))
203+
})
204+
}
205+
206+
func TestErrorBreaker_Errors(t *testing.T) {
207+
t.Run("should return all recorded errors", func(t *testing.T) {
208+
breaker := NewErrorBreaker("test", "test-instance", 5, types.Duration(time.Minute))
209+
now := time.Now()
210+
211+
err1 := assert.AnError
212+
err2 := assert.AnError
213+
err3 := assert.AnError
214+
215+
breaker.recordError(now, err1)
216+
breaker.recordError(now, err2)
217+
breaker.recordError(now, err3)
218+
219+
errors := breaker.Errors()
220+
assert.Len(t, errors, 3)
221+
})
222+
223+
t.Run("should return empty slice when no errors", func(t *testing.T) {
224+
breaker := NewErrorBreaker("test", "test-instance", 5, types.Duration(time.Minute))
225+
errors := breaker.Errors()
226+
assert.Empty(t, errors)
227+
})
228+
229+
t.Run("should return empty after reset", func(t *testing.T) {
230+
breaker := NewErrorBreaker("test", "test-instance", 5, types.Duration(time.Minute))
231+
now := time.Now()
232+
233+
breaker.recordError(now, assert.AnError)
234+
breaker.recordError(now, assert.AnError)
235+
assert.Len(t, breaker.Errors(), 2)
236+
237+
breaker.recordError(now, nil) // Reset via nil error
238+
assert.Empty(t, breaker.Errors())
239+
})
240+
}
241+
242+
func TestErrorBreaker_Marshal(t *testing.T) {
243+
breaker := NewErrorBreaker("test-strategy", "test-instance", 5, types.Duration(2*time.Minute))
244+
245+
data, err := json.Marshal(breaker)
246+
assert.NoError(t, err)
247+
assert.NotEmpty(t, data)
248+
249+
// Step 3: Unmarshal the breaker back
250+
var unmarshaledBreaker ErrorBreaker
251+
err = json.Unmarshal(data, &unmarshaledBreaker)
252+
assert.NoError(t, err)
253+
254+
unmarshaledBreaker.SetMetricsInfo("test-strategy", "test-instance")
255+
256+
// The unmarshaled breaker should be treated as reset
257+
t.Run("verify configuration is preserved", func(t *testing.T) {
258+
assert.Equal(t, breaker.MaxConsecutiveErrorCount, unmarshaledBreaker.MaxConsecutiveErrorCount)
259+
assert.Equal(t, breaker.HaltDuration, unmarshaledBreaker.HaltDuration)
260+
assert.Equal(t, breaker.strategyInstance, unmarshaledBreaker.strategyInstance)
261+
})
262+
263+
t.Run("verify breaker starts in reset state", func(t *testing.T) {
264+
// After unmarshaling, breaker should be treated as reset
265+
assert.False(t, unmarshaledBreaker.IsHalted())
266+
})
267+
268+
t.Run("verify RecordError functionality", func(t *testing.T) {
269+
// Add errors to reach threshold (5 errors)
270+
unmarshaledBreaker.RecordError(assert.AnError)
271+
assert.Equal(t, 1, unmarshaledBreaker.ErrorCount())
272+
assert.False(t, unmarshaledBreaker.IsHalted())
273+
274+
unmarshaledBreaker.RecordError(assert.AnError)
275+
unmarshaledBreaker.RecordError(assert.AnError)
276+
unmarshaledBreaker.RecordError(assert.AnError)
277+
assert.Equal(t, 4, unmarshaledBreaker.ErrorCount())
278+
assert.False(t, unmarshaledBreaker.IsHalted())
279+
280+
// 5th error should trigger halt
281+
unmarshaledBreaker.RecordError(assert.AnError)
282+
assert.Equal(t, 5, unmarshaledBreaker.ErrorCount())
283+
assert.True(t, unmarshaledBreaker.IsHalted())
284+
})
285+
286+
t.Run("verify Reset functionality", func(t *testing.T) {
287+
// Reset the breaker
288+
unmarshaledBreaker.Reset()
289+
assert.Equal(t, 0, unmarshaledBreaker.ErrorCount())
290+
assert.False(t, unmarshaledBreaker.IsHalted())
291+
assert.Empty(t, unmarshaledBreaker.Errors())
292+
293+
unmarshaledBreaker.RecordError(assert.AnError)
294+
unmarshaledBreaker.RecordError(assert.AnError)
295+
296+
errors := unmarshaledBreaker.Errors()
297+
assert.NotEmpty(t, errors)
298+
for _, err := range errors {
299+
assert.NotNil(t, err)
300+
}
301+
assert.Equal(t, 2, len(errors))
302+
})
303+
}

0 commit comments

Comments
 (0)