diff --git a/circuitbreaker/breaker.go b/circuitbreaker/breaker.go index b4f2629..7a26fab 100644 --- a/circuitbreaker/breaker.go +++ b/circuitbreaker/breaker.go @@ -72,7 +72,7 @@ func (b *Breaker) State() State { // stateLocked returns the effective state, promoting Open → HalfOpen when the // open duration has elapsed. Caller must hold b.mu. func (b *Breaker) stateLocked() State { - if b.state == StateOpen && time.Since(b.openedAt) >= b.opts.openDuration { + if b.state == StateOpen && b.opts.clk.Since(b.openedAt) >= b.opts.openDuration { b.state = StateHalfOpen b.halfOpenCur = 0 } @@ -142,7 +142,7 @@ func (b *Breaker) record(success bool) { // tripLocked transitions to the Open state and records the timestamp. func (b *Breaker) tripLocked() { b.state = StateOpen - b.openedAt = time.Now() + b.openedAt = b.opts.clk.Now() b.halfOpenCur = 0 } diff --git a/circuitbreaker/breaker_test.go b/circuitbreaker/breaker_test.go index 0388a37..7781100 100644 --- a/circuitbreaker/breaker_test.go +++ b/circuitbreaker/breaker_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "git.codelab.vc/pkg/httpx/internal/clock" "git.codelab.vc/pkg/httpx/middleware" ) @@ -80,9 +81,11 @@ func TestBreaker_OpenRejectsRequests(t *testing.T) { func TestBreaker_TransitionsToHalfOpenAfterDuration(t *testing.T) { const openDuration = 50 * time.Millisecond + clk := clock.Mock(time.Now()) b := NewBreaker( WithFailureThreshold(1), WithOpenDuration(openDuration), + withClock(clk), ) // Trip the breaker. @@ -96,8 +99,8 @@ func TestBreaker_TransitionsToHalfOpenAfterDuration(t *testing.T) { t.Fatalf("state = %v, want %v", s, StateOpen) } - // Wait for the open duration to elapse. - time.Sleep(openDuration + 10*time.Millisecond) + // Advance past the open duration. + clk.Advance(openDuration + time.Millisecond) if s := b.State(); s != StateHalfOpen { t.Fatalf("state = %v, want %v", s, StateHalfOpen) @@ -106,9 +109,11 @@ func TestBreaker_TransitionsToHalfOpenAfterDuration(t *testing.T) { func TestBreaker_HalfOpenToClosedOnSuccess(t *testing.T) { const openDuration = 50 * time.Millisecond + clk := clock.Mock(time.Now()) b := NewBreaker( WithFailureThreshold(1), WithOpenDuration(openDuration), + withClock(clk), ) // Trip the breaker. @@ -118,8 +123,8 @@ func TestBreaker_HalfOpenToClosedOnSuccess(t *testing.T) { } done(false) - // Wait for half-open. - time.Sleep(openDuration + 10*time.Millisecond) + // Advance into half-open. + clk.Advance(openDuration + time.Millisecond) // A successful request in half-open should close the breaker. done, err = b.Allow() @@ -135,9 +140,11 @@ func TestBreaker_HalfOpenToClosedOnSuccess(t *testing.T) { func TestBreaker_HalfOpenToOpenOnFailure(t *testing.T) { const openDuration = 50 * time.Millisecond + clk := clock.Mock(time.Now()) b := NewBreaker( WithFailureThreshold(1), WithOpenDuration(openDuration), + withClock(clk), ) // Trip the breaker. @@ -147,8 +154,8 @@ func TestBreaker_HalfOpenToOpenOnFailure(t *testing.T) { } done(false) - // Wait for half-open. - time.Sleep(openDuration + 10*time.Millisecond) + // Advance into half-open. + clk.Advance(openDuration + time.Millisecond) // A failed request in half-open should re-open the breaker. done, err = b.Allow() diff --git a/circuitbreaker/options.go b/circuitbreaker/options.go index 57bd770..bcfdfc0 100644 --- a/circuitbreaker/options.go +++ b/circuitbreaker/options.go @@ -1,11 +1,16 @@ package circuitbreaker -import "time" +import ( + "time" + + "git.codelab.vc/pkg/httpx/internal/clock" +) type options struct { failureThreshold int // consecutive failures to trip openDuration time.Duration // how long to stay open before half-open halfOpenMax int // max concurrent requests in half-open + clk clock.Clock // time source (real by default) } func defaults() options { @@ -13,12 +18,23 @@ func defaults() options { failureThreshold: 5, openDuration: 30 * time.Second, halfOpenMax: 1, + clk: clock.System(), } } // Option configures a Breaker. type Option func(*options) +// withClock sets the clock used for state-transition timing. Unexported; for +// deterministic tests. +func withClock(c clock.Clock) Option { + return func(o *options) { + if c != nil { + o.clk = c + } + } +} + // WithFailureThreshold sets the number of consecutive failures required to // trip the breaker from Closed to Open. Default is 5. func WithFailureThreshold(n int) Option {