Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions breaker.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,18 @@ type EWMABreaker struct {
// Compared to the [SlidingWindowBreaker], this breaker responds faster to failure bursts, but is more lenient with
// constant failure rates.
//
// The sample count is used to determine how fast previous observations "decay". A value of 1 causes a single sample to
// be considered. A higher value slows down convergence. As a rule of thumb, breakers with higher throughput should use
// higher sample counts to avoid opening up on small hiccups.
// The sample count must be at least 1 and is used to determine how fast previous observations "decay". A value of 1
// causes only the newest sample to be considered. A higher value slows down convergence. As a rule of thumb, breakers
// with higher throughput should use higher sample counts to avoid opening up on small hiccups. A sample count of 0 is
// rejected by [NewCircuit].
//
// The failureThreshold is the failure rate above which the breaker should open (0.0-1.0).
func NewEWMABreaker(sampleCount uint, failureThreshold float64) *EWMABreaker {
e := &EWMABreaker{
// https://en.wikipedia.org/wiki/Exponential_smoothing
decay: 2 / (float64(sampleCount)/2 + 1),
// Span-based exponential smoothing factor: https://en.wikipedia.org/wiki/Exponential_smoothing
// decay = 2/(N+1) lies in (0,1] for every sampleCount >= 1 (and is exactly 1 at sampleCount == 1, i.e. only the
// newest sample counts). sampleCount == 0 yields decay = 2, which is rejected in apply.
decay: 2 / (float64(sampleCount) + 1),
threshold: failureThreshold,
}

Expand Down Expand Up @@ -134,6 +137,12 @@ func (e *EWMABreaker) apply(o *options) error {
return fmt.Errorf("EWMABreaker threshold must be between 0 and 1")
}

// decay > 1 means an out-of-range smoothing factor, which only happens for a sample count of 0 (see
// [NewEWMABreaker]). The zero-value breaker has decay == 0 and is deliberately allowed (it never opens).
if e.decay > 1 {
return fmt.Errorf("EWMABreaker requires a sample count of at least 1")
}

return nil
}

Expand Down
11 changes: 11 additions & 0 deletions breaker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ func TestEWMABreaker_zero_value_does_not_panic(t *testing.T) {
})
}

func TestEWMABreaker_sample_count_of_1_is_single_sample(t *testing.T) {
// sampleCount == 1 must yield decay == 1 (only the newest sample counts), i.e. a valid in-range smoothing factor.
b := NewEWMABreaker(1, 0.5)
assert.Equal(t, 1.0, b.decay)
}

func TestEWMABreaker_sample_count_of_0_is_rejected(t *testing.T) {
_, err := NewCircuit(NewEWMABreaker(0, 0.5), WithHalfOpenDelay(time.Second))
assert.Error(t, err, "expected a sample count of 0 to be rejected")
}

// testSeed is a fixed seed for the per-subtest RNG so the statistically-driven EWMA stages are deterministic and
// reproducible across runs (chosen so the "constant low/high failure rate" cases land on their expected state).
const testSeed = 0
Expand Down
Loading