|
|
@@ -0,0 +1,349 @@
|
|
|
+package circuitbreaker
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "errors"
|
|
|
+ "sync"
|
|
|
+ "sync/atomic"
|
|
|
+ "testing"
|
|
|
+ "time"
|
|
|
+)
|
|
|
+
|
|
|
+// stateLabel returns the label string for a state value.
|
|
|
+func stateLabel(s int) string {
|
|
|
+ switch s {
|
|
|
+ case StateClosed:
|
|
|
+ return "closed"
|
|
|
+ case StateHalfOpen:
|
|
|
+ return "half-open"
|
|
|
+ case StateOpen:
|
|
|
+ return "open"
|
|
|
+ default:
|
|
|
+ return "unknown"
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// errPermanent is a sentinel used in tests to simulate a
|
|
|
+// permanent failure.
|
|
|
+var errPermanent = errors.New("permanent error")
|
|
|
+
|
|
|
+// TestNewClosed verifies a fresh breaker starts in the CLOSED state.
|
|
|
+func TestNewClosed(t *testing.T) {
|
|
|
+ cb := New(DefaultConfig("test"))
|
|
|
+ if cb.State() != StateClosed {
|
|
|
+ t.Fatalf("expected closed, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestFirstCallSucceeds verifies a single successful call leaves the
|
|
|
+// circuit CLOSED and does not record any failures.
|
|
|
+func TestFirstCallSucceeds(t *testing.T) {
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 3,
|
|
|
+ FailureWindow: 100 * time.Millisecond,
|
|
|
+ OpenDuration: 50 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 1,
|
|
|
+ })
|
|
|
+ ctx := context.Background()
|
|
|
+ err := cb.Do(ctx, func() error { return nil })
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("expected nil, got %v", err)
|
|
|
+ }
|
|
|
+ if cb.State() != StateClosed {
|
|
|
+ t.Fatalf("expected closed, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestRetryThenSucceeds verifies a transient failure that eventually
|
|
|
+// succeeds closes the circuit and clears the failure count.
|
|
|
+func TestRetryThenSucceeds(t *testing.T) {
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 3,
|
|
|
+ FailureWindow: 100 * time.Millisecond,
|
|
|
+ OpenDuration: 50 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 1,
|
|
|
+ })
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ // Two failures, then a success.
|
|
|
+ for i := 0; i < 2; i++ {
|
|
|
+ cb.Do(ctx, func() error { return errors.New("transient") })
|
|
|
+ }
|
|
|
+ if cb.State() != StateClosed {
|
|
|
+ t.Fatalf("expected closed after 2 failures, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+ cb.Do(ctx, func() error { return nil })
|
|
|
+ if cb.State() != StateClosed {
|
|
|
+ t.Fatalf("expected closed after success, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestExhaustThresholdTripsOpen verifies that reaching the failure
|
|
|
+// threshold trips the circuit to OPEN.
|
|
|
+func TestExhaustThresholdTripsOpen(t *testing.T) {
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 3,
|
|
|
+ FailureWindow: 100 * time.Millisecond,
|
|
|
+ OpenDuration: 50 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 1,
|
|
|
+ })
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ for i := 0; i < 3; i++ {
|
|
|
+ cb.Do(ctx, func() error { return errors.New("fail") })
|
|
|
+ }
|
|
|
+ if cb.State() != StateOpen {
|
|
|
+ t.Fatalf("expected open after 3 failures, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+
|
|
|
+ // Subsequent calls should be fast-rejected.
|
|
|
+ err := cb.Do(ctx, func() error { return nil })
|
|
|
+ if !errors.Is(err, ErrCircuitOpen) {
|
|
|
+ t.Fatalf("expected ErrCircuitOpen, got %v", err)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestOpenResetsAfterDuration verifies that after OpenDuration elapses,
|
|
|
+// the circuit transitions to HALF-OPEN and admits test calls.
|
|
|
+func TestOpenResetsAfterDuration(t *testing.T) {
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 1,
|
|
|
+ FailureWindow: 10 * time.Millisecond,
|
|
|
+ OpenDuration: 30 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 1,
|
|
|
+ })
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ // Trip it open.
|
|
|
+ cb.Do(ctx, func() error { return errors.New("fail") })
|
|
|
+ if cb.State() != StateOpen {
|
|
|
+ t.Fatalf("expected open, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+
|
|
|
+ // Wait for the open duration to elapse.
|
|
|
+ time.Sleep(45 * time.Millisecond)
|
|
|
+
|
|
|
+ // A new call should transition to HALF-OPEN.
|
|
|
+ err := cb.Do(ctx, func() error { return nil })
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("expected nil from half-open call, got %v", err)
|
|
|
+ }
|
|
|
+ if cb.State() != StateClosed {
|
|
|
+ t.Fatalf("expected closed after half-open success, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestHalfOpenSuccessCloses verifies that a successful call in
|
|
|
+// HALF-OPEN state transitions the circuit back to CLOSED.
|
|
|
+func TestHalfOpenSuccessCloses(t *testing.T) {
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 1,
|
|
|
+ FailureWindow: 10 * time.Millisecond,
|
|
|
+ OpenDuration: 20 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 1,
|
|
|
+ })
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ // Trip it open.
|
|
|
+ cb.Do(ctx, func() error { return errors.New("fail") })
|
|
|
+ time.Sleep(25 * time.Millisecond)
|
|
|
+
|
|
|
+ // In HALF-OPEN, a successful call should close.
|
|
|
+ cb.Do(ctx, func() error { return nil })
|
|
|
+ if cb.State() != StateClosed {
|
|
|
+ t.Fatalf("expected closed after half-open success, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestHalfOpenFailureReopens verifies that a failing call in
|
|
|
+// HALF-OPEN state transitions the circuit back to OPEN.
|
|
|
+func TestHalfOpenFailureReopens(t *testing.T) {
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 1,
|
|
|
+ FailureWindow: 10 * time.Millisecond,
|
|
|
+ OpenDuration: 20 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 1,
|
|
|
+ })
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ // Trip it open.
|
|
|
+ cb.Do(ctx, func() error { return errors.New("fail") })
|
|
|
+ time.Sleep(25 * time.Millisecond)
|
|
|
+
|
|
|
+ // In HALF-OPEN, a failing call should reopen.
|
|
|
+ cb.Do(ctx, func() error { return errors.New("still failing") })
|
|
|
+ if cb.State() != StateOpen {
|
|
|
+ t.Fatalf("expected open after half-open failure, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestMaxHalfOpenRespected verifies that MaxHalfOpen is respected.
|
|
|
+// After the circuit is HALF-OPEN, the first call is admitted and
|
|
|
+// transitions the circuit to CLOSED on success. The test verifies
|
|
|
+// the halfOpenCount is incremented and reset correctly.
|
|
|
+func TestMaxHalfOpenRespected(t *testing.T) {
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 1,
|
|
|
+ FailureWindow: 10 * time.Millisecond,
|
|
|
+ OpenDuration: 50 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 1,
|
|
|
+ })
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ // Trip it open.
|
|
|
+ cb.Do(ctx, func() error { return errors.New("fail") })
|
|
|
+ // Wait for open duration to elapse so the next call is in HALF-OPEN.
|
|
|
+ time.Sleep(55 * time.Millisecond)
|
|
|
+
|
|
|
+ // First call in HALF-OPEN: should succeed and close the circuit.
|
|
|
+ err := cb.Do(ctx, func() error { return nil })
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("expected nil in half-open, got %v", err)
|
|
|
+ }
|
|
|
+ if cb.State() != StateClosed {
|
|
|
+ t.Fatalf("expected closed after half-open success, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestFailureWindowPrunes verifies that failures outside the window
|
|
|
+// are not counted toward the threshold. The sliding window means
|
|
|
+// an old cluster of failures expires once enough time passes,
|
|
|
+// and a new cluster can form in a fresh window.
|
|
|
+func TestFailureWindowPrunes(t *testing.T) {
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 3,
|
|
|
+ FailureWindow: 20 * time.Millisecond,
|
|
|
+ OpenDuration: 500 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 1,
|
|
|
+ })
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ // Three failures rapidly → OPEN.
|
|
|
+ for i := 0; i < 3; i++ {
|
|
|
+ cb.Do(ctx, func() error { return errors.New("fail") })
|
|
|
+ }
|
|
|
+ if cb.State() != StateOpen {
|
|
|
+ t.Fatalf("expected open after 3 rapid failures, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+
|
|
|
+ // Wait for the failure window to expire (20ms). After 30ms,
|
|
|
+ // all three prior failures are outside the window. Also wait
|
|
|
+ // long enough for open duration NOT to elapse (we want to stay OPEN,
|
|
|
+ // not go HALF-OPEN, so the next failures add to the fresh window).
|
|
|
+ time.Sleep(30 * time.Millisecond)
|
|
|
+
|
|
|
+ // Now add failures one at a time with enough spacing that
|
|
|
+ // they each form their own fresh window (30ms gap >> 20ms window).
|
|
|
+ // Each failure is alone in its window → circuit stays OPEN
|
|
|
+ // because we're already OPEN (recording failure in OPEN state
|
|
|
+ // re-trips even if count is 0).
|
|
|
+ cb.Do(ctx, func() error { return errors.New("f1") })
|
|
|
+ if cb.State() != StateOpen {
|
|
|
+ t.Fatalf("expected open after failure in open state, got %s", stateLabel(cb.State()))
|
|
|
+ }
|
|
|
+ // The state machine in OPEN: recordResult does not trip again
|
|
|
+ // (already open) — it just records the failure. The circuit
|
|
|
+ // stays open regardless of count.
|
|
|
+}
|
|
|
+
|
|
|
+// TestMeasureCallback verifies the Measure callback fires on
|
|
|
+// state transitions.
|
|
|
+func TestMeasureCallback(t *testing.T) {
|
|
|
+ var states []int
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 1,
|
|
|
+ FailureWindow: 10 * time.Millisecond,
|
|
|
+ OpenDuration: 20 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 1,
|
|
|
+ })
|
|
|
+ cb.Measure = func(state int, _ error) {
|
|
|
+ states = append(states, state)
|
|
|
+ }
|
|
|
+
|
|
|
+ ctx := context.Background()
|
|
|
+ cb.Do(ctx, func() error { return errors.New("fail") }) // trip → OPEN
|
|
|
+ time.Sleep(25 * time.Millisecond)
|
|
|
+ cb.Do(ctx, func() error { return nil }) // tryHalfOpen → HALF, then success → CLOSED
|
|
|
+
|
|
|
+ // Transitions observed:
|
|
|
+ // 1. StateOpen (recordResult on failure)
|
|
|
+ // 2. StateHalfOpen (tryHalfOpen when open duration elapsed)
|
|
|
+ // 3. StateClosed (recordResult on success in half-open)
|
|
|
+ if len(states) < 3 {
|
|
|
+ t.Fatalf("expected 3 state transitions, got %d: %v", len(states), states)
|
|
|
+ }
|
|
|
+ if states[0] != StateOpen {
|
|
|
+ t.Fatalf("expected first to open (%d), got %d", StateOpen, states[0])
|
|
|
+ }
|
|
|
+ if states[1] != StateHalfOpen {
|
|
|
+ t.Fatalf("expected second to half-open (%d), got %d", StateHalfOpen, states[1])
|
|
|
+ }
|
|
|
+ if states[2] != StateClosed {
|
|
|
+ t.Fatalf("expected third to closed (%d), got %d", StateClosed, states[2])
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestConcurrentAccess verifies the breaker is safe for concurrent use.
|
|
|
+func TestConcurrentAccess(t *testing.T) {
|
|
|
+ cb := New(Config{
|
|
|
+ Name: "test",
|
|
|
+ FailureThreshold: 5,
|
|
|
+ FailureWindow: 100 * time.Millisecond,
|
|
|
+ OpenDuration: 50 * time.Millisecond,
|
|
|
+ MaxHalfOpen: 3,
|
|
|
+ })
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ var wg sync.WaitGroup
|
|
|
+ var errCount atomic.Int64
|
|
|
+
|
|
|
+ for i := 0; i < 20; i++ {
|
|
|
+ wg.Add(1)
|
|
|
+ go func(fail bool) {
|
|
|
+ defer wg.Done()
|
|
|
+ err := cb.Do(ctx, func() error {
|
|
|
+ if fail {
|
|
|
+ errCount.Add(1)
|
|
|
+ return errors.New("concurrent fail")
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ })
|
|
|
+ if fail && err != nil && !errors.Is(err, ErrCircuitOpen) {
|
|
|
+ // Some errors are expected.
|
|
|
+ }
|
|
|
+ }(i%2 == 0)
|
|
|
+ }
|
|
|
+ wg.Wait()
|
|
|
+
|
|
|
+ // No panic means the test passes.
|
|
|
+}
|
|
|
+
|
|
|
+// TestCtxCancel verifies that a canceled context causes Do to
|
|
|
+// return context.Canceled immediately without acquiring the circuit.
|
|
|
+// The fn is never called when the context is already canceled.
|
|
|
+func TestCtxCancel(t *testing.T) {
|
|
|
+ var called bool
|
|
|
+ cb := New(DefaultConfig("test"))
|
|
|
+ ctx, cancel := context.WithCancel(context.Background())
|
|
|
+ cancel() // already canceled before Do()
|
|
|
+
|
|
|
+ err := cb.Do(ctx, func() error {
|
|
|
+ called = true
|
|
|
+ return nil
|
|
|
+ })
|
|
|
+ if !errors.Is(err, context.Canceled) {
|
|
|
+ t.Fatalf("expected context.Canceled, got %v", err)
|
|
|
+ }
|
|
|
+ if called {
|
|
|
+ t.Fatal("fn should not have been called with canceled context")
|
|
|
+ }
|
|
|
+}
|