Pulse/internal/monitoring/backoff_test.go
rcourtman 24ae6d8d78 test: add comprehensive unit tests for backoff and circuit breaker (Phase 2 Task 9a)
Added 30+ test cases covering:

Backoff tests (backoff_test.go):
- Exponential growth with multiplier
- Jitter distribution and bounds
- Max delay capping
- Edge cases (negative attempts, zero config values)
- Realistic production scenarios

Circuit breaker tests (circuit_breaker_test.go):
- State transitions: closed → open → half-open → closed
- Retry interval backoff with bit-shifting (5s << failureCount)
- Half-open window behavior
- Concurrent access safety
- Default parameter validation

All tests pass with proper handling of time-based state transitions
and exponential backoff mechanics (bit-shift based retry intervals).
2025-10-20 15:13:38 +00:00

264 lines
6.1 KiB
Go

package monitoring
import (
"testing"
"time"
)
func TestBackoffConfig_NextDelay(t *testing.T) {
tests := []struct {
name string
config backoffConfig
attempt int
rng float64
wantMin time.Duration
wantMax time.Duration
wantExact time.Duration // for tests without jitter
checkExact bool
}{
{
name: "first attempt with defaults",
config: backoffConfig{
Initial: 5 * time.Second,
Multiplier: 2,
Jitter: 0,
Max: 5 * time.Minute,
},
attempt: 0,
rng: 0.5,
wantExact: 5 * time.Second,
checkExact: true,
},
{
name: "second attempt doubles delay",
config: backoffConfig{
Initial: 5 * time.Second,
Multiplier: 2,
Jitter: 0,
Max: 5 * time.Minute,
},
attempt: 1,
rng: 0.5,
wantExact: 10 * time.Second,
checkExact: true,
},
{
name: "third attempt quadruples delay",
config: backoffConfig{
Initial: 5 * time.Second,
Multiplier: 2,
Jitter: 0,
Max: 5 * time.Minute,
},
attempt: 2,
rng: 0.5,
wantExact: 20 * time.Second,
checkExact: true,
},
{
name: "respects max delay cap",
config: backoffConfig{
Initial: 5 * time.Second,
Multiplier: 2,
Jitter: 0,
Max: 30 * time.Second,
},
attempt: 10, // would be 5120 seconds without cap
rng: 0.5,
wantExact: 30 * time.Second,
checkExact: true,
},
{
name: "jitter adds randomness within bounds",
config: backoffConfig{
Initial: 10 * time.Second,
Multiplier: 2,
Jitter: 0.2, // ±20%
Max: 5 * time.Minute,
},
attempt: 0,
rng: 0.5, // neutral jitter
wantMin: 8 * time.Second, // 10s * (1 - 0.2)
wantMax: 12 * time.Second, // 10s * (1 + 0.2)
},
{
name: "jitter at max (rng=1.0) increases delay",
config: backoffConfig{
Initial: 10 * time.Second,
Multiplier: 2,
Jitter: 0.2,
Max: 5 * time.Minute,
},
attempt: 0,
rng: 1.0,
wantExact: 12 * time.Second, // 10s * (1 + 0.2)
checkExact: true,
},
{
name: "jitter at min (rng=0.0) decreases delay",
config: backoffConfig{
Initial: 10 * time.Second,
Multiplier: 2,
Jitter: 0.2,
Max: 5 * time.Minute,
},
attempt: 0,
rng: 0.0,
wantExact: 8 * time.Second, // 10s * (1 - 0.2)
checkExact: true,
},
{
name: "negative attempt treated as zero",
config: backoffConfig{
Initial: 5 * time.Second,
Multiplier: 2,
Jitter: 0,
Max: 5 * time.Minute,
},
attempt: -1,
rng: 0.5,
wantExact: 5 * time.Second,
checkExact: true,
},
{
name: "zero initial uses default 2s",
config: backoffConfig{
Initial: 0,
Multiplier: 2,
Jitter: 0,
Max: 5 * time.Minute,
},
attempt: 0,
rng: 0.5,
wantExact: 2 * time.Second,
checkExact: true,
},
{
name: "multiplier <= 1 defaults to 2",
config: backoffConfig{
Initial: 5 * time.Second,
Multiplier: 1,
Jitter: 0,
Max: 5 * time.Minute,
},
attempt: 1,
rng: 0.5,
wantExact: 10 * time.Second, // uses multiplier of 2
checkExact: true,
},
{
name: "jitter > 1 capped at 1",
config: backoffConfig{
Initial: 10 * time.Second,
Multiplier: 2,
Jitter: 2.0, // should be capped at 1.0
Max: 5 * time.Minute,
},
attempt: 0,
rng: 0.5,
wantMin: 0 * time.Second, // 10s * (1 - 1.0)
wantMax: 20 * time.Second, // 10s * (1 + 1.0)
},
{
name: "realistic production config",
config: backoffConfig{
Initial: 5 * time.Second,
Multiplier: 2,
Jitter: 0.2,
Max: 5 * time.Minute,
},
attempt: 5, // 160s base
rng: 0.5,
wantMin: 128 * time.Second, // 160s * 0.8
wantMax: 192 * time.Second, // 160s * 1.2
},
{
name: "max delay applies after jitter",
config: backoffConfig{
Initial: 10 * time.Second,
Multiplier: 2,
Jitter: 0.2,
Max: 15 * time.Second,
},
attempt: 2, // base would be 40s
rng: 1.0,
// 40s * 1.2 = 48s, but capped at 15s
wantExact: 15 * time.Second,
checkExact: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.config.nextDelay(tt.attempt, tt.rng)
if tt.checkExact {
if got != tt.wantExact {
t.Errorf("nextDelay() = %v, want %v", got, tt.wantExact)
}
} else {
if got < tt.wantMin || got > tt.wantMax {
t.Errorf("nextDelay() = %v, want between %v and %v", got, tt.wantMin, tt.wantMax)
}
}
})
}
}
func TestBackoffConfig_ExponentialGrowth(t *testing.T) {
cfg := backoffConfig{
Initial: 5 * time.Second,
Multiplier: 2,
Jitter: 0,
Max: 10 * time.Minute,
}
expected := []time.Duration{
5 * time.Second, // attempt 0
10 * time.Second, // attempt 1
20 * time.Second, // attempt 2
40 * time.Second, // attempt 3
80 * time.Second, // attempt 4
160 * time.Second, // attempt 5
320 * time.Second, // attempt 6
10 * time.Minute, // attempt 7 (capped)
10 * time.Minute, // attempt 8 (capped)
}
for i, want := range expected {
got := cfg.nextDelay(i, 0.5)
if got != want {
t.Errorf("attempt %d: got %v, want %v", i, got, want)
}
}
}
func TestBackoffConfig_JitterDistribution(t *testing.T) {
cfg := backoffConfig{
Initial: 10 * time.Second,
Multiplier: 2,
Jitter: 0.2,
Max: 5 * time.Minute,
}
// Test that different RNG values produce different delays
results := make(map[time.Duration]bool)
rngValues := []float64{0.0, 0.25, 0.5, 0.75, 1.0}
for _, rng := range rngValues {
delay := cfg.nextDelay(0, rng)
results[delay] = true
}
// Should have multiple distinct values due to jitter
if len(results) < 3 {
t.Errorf("jitter not producing enough variation: got %d unique values, want at least 3", len(results))
}
// All results should be within expected bounds
for delay := range results {
if delay < 8*time.Second || delay > 12*time.Second {
t.Errorf("delay %v outside expected jitter range [8s, 12s]", delay)
}
}
}