mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
344 lines
8.6 KiB
Go
344 lines
8.6 KiB
Go
package circuit
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestStateString_Unknown(t *testing.T) {
|
|
if got := State(99).String(); got != "unknown" {
|
|
t.Fatalf("expected unknown, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestNewBreaker_DefaultsApplied(t *testing.T) {
|
|
b := NewBreaker("defaults", Config{})
|
|
|
|
if b.config.FailureThreshold != 3 {
|
|
t.Fatalf("expected default FailureThreshold, got %d", b.config.FailureThreshold)
|
|
}
|
|
if b.config.SuccessThreshold != 2 {
|
|
t.Fatalf("expected default SuccessThreshold, got %d", b.config.SuccessThreshold)
|
|
}
|
|
if b.config.InitialBackoff != time.Second {
|
|
t.Fatalf("expected default InitialBackoff, got %s", b.config.InitialBackoff)
|
|
}
|
|
if b.config.MaxBackoff != 5*time.Minute {
|
|
t.Fatalf("expected default MaxBackoff, got %s", b.config.MaxBackoff)
|
|
}
|
|
if b.config.BackoffMultiplier != 2.0 {
|
|
t.Fatalf("expected default BackoffMultiplier, got %.1f", b.config.BackoffMultiplier)
|
|
}
|
|
if b.config.HalfOpenTimeout != 30*time.Second {
|
|
t.Fatalf("expected default HalfOpenTimeout, got %s", b.config.HalfOpenTimeout)
|
|
}
|
|
}
|
|
|
|
func TestBreaker_CanAllow_DoesNotTransition(t *testing.T) {
|
|
b := NewBreaker("test", DefaultConfig())
|
|
b.mu.Lock()
|
|
b.state = StateOpen
|
|
b.currentBackoff = time.Hour
|
|
b.openedAt = time.Now().Add(-2 * time.Hour)
|
|
b.mu.Unlock()
|
|
|
|
if !b.CanAllow() {
|
|
t.Fatalf("expected CanAllow to return true after backoff")
|
|
}
|
|
if b.State() != StateOpen {
|
|
t.Fatalf("expected state to remain open on CanAllow")
|
|
}
|
|
}
|
|
|
|
func TestBreaker_RecordFailure_InvalidDoesNotTrip(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.FailureThreshold = 1
|
|
b := NewBreaker("test", cfg)
|
|
|
|
b.RecordFailureWithCategory(errors.New("bad request"), ErrorCategoryInvalid)
|
|
if b.State() != StateClosed {
|
|
t.Fatalf("expected invalid error not to trip circuit")
|
|
}
|
|
}
|
|
|
|
func TestBreaker_RecordFailure_RateLimitTrips(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.FailureThreshold = 5
|
|
b := NewBreaker("test", cfg)
|
|
|
|
b.RecordFailureWithCategory(errors.New("rate limit"), ErrorCategoryRateLimit)
|
|
if b.State() != StateOpen {
|
|
t.Fatalf("expected rate limit error to trip circuit")
|
|
}
|
|
}
|
|
|
|
func TestBreaker_RecordFailure_HalfOpenBackoffCaps(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.MaxBackoff = 100 * time.Millisecond
|
|
cfg.BackoffMultiplier = 2.0
|
|
b := NewBreaker("test", cfg)
|
|
|
|
b.mu.Lock()
|
|
b.state = StateHalfOpen
|
|
b.currentBackoff = 80 * time.Millisecond
|
|
b.mu.Unlock()
|
|
|
|
b.RecordFailureWithCategory(errors.New("fail"), ErrorCategoryTransient)
|
|
if b.State() != StateOpen {
|
|
t.Fatalf("expected state to be open after half-open failure")
|
|
}
|
|
if b.currentBackoff != cfg.MaxBackoff {
|
|
t.Fatalf("expected backoff to cap at max, got %s", b.currentBackoff)
|
|
}
|
|
}
|
|
|
|
func TestBreaker_Callbacks(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.FailureThreshold = 1
|
|
b := NewBreaker("test", cfg)
|
|
|
|
stateCh := make(chan State, 1)
|
|
tripCh := make(chan error, 1)
|
|
b.SetOnStateChange(func(_, to State) {
|
|
stateCh <- to
|
|
})
|
|
b.SetOnTrip(func(err error) {
|
|
tripCh <- err
|
|
})
|
|
|
|
testErr := errors.New("boom")
|
|
b.RecordFailure(testErr)
|
|
|
|
select {
|
|
case state := <-stateCh:
|
|
if state != StateOpen {
|
|
t.Fatalf("expected state change to open, got %s", state.String())
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatalf("expected state change callback to fire")
|
|
}
|
|
|
|
select {
|
|
case err := <-tripCh:
|
|
if err == nil || err.Error() != testErr.Error() {
|
|
t.Fatalf("expected trip callback with error")
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatalf("expected trip callback to fire")
|
|
}
|
|
}
|
|
|
|
func TestBreaker_GetStatus_TimeUntilRetry(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.FailureThreshold = 1
|
|
cfg.InitialBackoff = 50 * time.Millisecond
|
|
b := NewBreaker("test", cfg)
|
|
|
|
b.RecordFailure(errors.New("fail"))
|
|
status := b.GetStatus()
|
|
|
|
if status.State != "open" {
|
|
t.Fatalf("expected status open, got %s", status.State)
|
|
}
|
|
if status.LastError == "" {
|
|
t.Fatalf("expected last error to be set")
|
|
}
|
|
if status.TimeUntilRetry <= 0 {
|
|
t.Fatalf("expected time until retry to be set")
|
|
}
|
|
}
|
|
|
|
func TestBreaker_ExecuteWithCategory_InvalidDoesNotTrip(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.FailureThreshold = 1
|
|
b := NewBreaker("test", cfg)
|
|
|
|
err := b.ExecuteWithCategory(func() (error, ErrorCategory) {
|
|
return errors.New("invalid"), ErrorCategoryInvalid
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected error")
|
|
}
|
|
if b.State() != StateClosed {
|
|
t.Fatalf("expected state to remain closed")
|
|
}
|
|
}
|
|
|
|
func TestIsCircuitOpen_Additional(t *testing.T) {
|
|
if !IsCircuitOpen(ErrCircuitOpen) {
|
|
t.Fatalf("expected ErrCircuitOpen to be recognized")
|
|
}
|
|
if IsCircuitOpen(errors.New("other")) {
|
|
t.Fatalf("expected non-circuit error to be false")
|
|
}
|
|
}
|
|
|
|
func TestCategorizeError_Additional(t *testing.T) {
|
|
tests := []struct {
|
|
err error
|
|
expected ErrorCategory
|
|
}{
|
|
{errors.New("rate limit exceeded"), ErrorCategoryRateLimit},
|
|
{errors.New("429 too many requests"), ErrorCategoryRateLimit},
|
|
{errors.New("400 bad request"), ErrorCategoryInvalid},
|
|
{errors.New("unauthorized api key"), ErrorCategoryFatal},
|
|
{errors.New("payment required"), ErrorCategoryFatal},
|
|
{errors.New("random failure"), ErrorCategoryTransient},
|
|
{nil, ErrorCategoryTransient},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := CategorizeError(tt.err); got != tt.expected {
|
|
t.Fatalf("expected %v, got %v for %v", tt.expected, got, tt.err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBreaker_IsOpenClosed(t *testing.T) {
|
|
b := NewBreaker("test", DefaultConfig())
|
|
if !b.IsClosed() || b.IsOpen() {
|
|
t.Fatalf("expected breaker to start closed")
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.state = StateOpen
|
|
b.mu.Unlock()
|
|
|
|
if !b.IsOpen() || b.IsClosed() {
|
|
t.Fatalf("expected breaker to report open")
|
|
}
|
|
}
|
|
|
|
func TestCircuitOpenErrorMessage(t *testing.T) {
|
|
err := circuitOpenError{}
|
|
if err.Error() != "circuit breaker is open" {
|
|
t.Fatalf("unexpected error message: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestBreaker_CanAllow_Branches(t *testing.T) {
|
|
b := NewBreaker("test", DefaultConfig())
|
|
if !b.CanAllow() {
|
|
t.Fatalf("expected CanAllow true for closed")
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.state = StateOpen
|
|
b.openedAt = time.Now()
|
|
b.currentBackoff = time.Hour
|
|
b.mu.Unlock()
|
|
|
|
if b.CanAllow() {
|
|
t.Fatalf("expected CanAllow false before backoff elapses")
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.openedAt = time.Now().Add(-2 * time.Hour)
|
|
b.mu.Unlock()
|
|
if !b.CanAllow() {
|
|
t.Fatalf("expected CanAllow true after backoff")
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.state = StateHalfOpen
|
|
b.mu.Unlock()
|
|
if !b.CanAllow() {
|
|
t.Fatalf("expected CanAllow true for half-open")
|
|
}
|
|
}
|
|
|
|
func TestBreaker_ExecuteWithCategory_SuccessAndOpen(t *testing.T) {
|
|
b := NewBreaker("test", DefaultConfig())
|
|
if err := b.ExecuteWithCategory(func() (error, ErrorCategory) {
|
|
return nil, ErrorCategoryTransient
|
|
}); err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
|
|
cfg := DefaultConfig()
|
|
cfg.FailureThreshold = 1
|
|
cfg.InitialBackoff = time.Hour
|
|
b = NewBreaker("test", cfg)
|
|
b.RecordFailure(errors.New("fail"))
|
|
if err := b.ExecuteWithCategory(func() (error, ErrorCategory) {
|
|
return nil, ErrorCategoryTransient
|
|
}); err != ErrCircuitOpen {
|
|
t.Fatalf("expected ErrCircuitOpen, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStateString_All(t *testing.T) {
|
|
cases := map[State]string{
|
|
StateClosed: "closed",
|
|
StateOpen: "open",
|
|
StateHalfOpen: "half-open",
|
|
}
|
|
for state, expected := range cases {
|
|
if state.String() != expected {
|
|
t.Fatalf("expected %s for state %d", expected, state)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBreaker_Allow_TransitionsOpenToHalfOpen(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
cfg.InitialBackoff = 10 * time.Millisecond
|
|
b := NewBreaker("test", cfg)
|
|
|
|
b.mu.Lock()
|
|
b.state = StateOpen
|
|
b.openedAt = time.Now().Add(-time.Second)
|
|
b.currentBackoff = 10 * time.Millisecond
|
|
b.mu.Unlock()
|
|
|
|
if !b.Allow() {
|
|
t.Fatalf("expected Allow to return true after backoff")
|
|
}
|
|
if b.State() != StateHalfOpen {
|
|
t.Fatalf("expected state to transition to half-open")
|
|
}
|
|
}
|
|
|
|
func TestBreaker_TransitionTo_NoOp(t *testing.T) {
|
|
b := NewBreaker("test", DefaultConfig())
|
|
b.transitionTo(StateClosed)
|
|
if b.State() != StateClosed {
|
|
t.Fatalf("expected state to remain closed")
|
|
}
|
|
}
|
|
|
|
func TestBreaker_Allow_BlocksBeforeBackoff(t *testing.T) {
|
|
b := NewBreaker("test", DefaultConfig())
|
|
b.mu.Lock()
|
|
b.state = StateOpen
|
|
b.openedAt = time.Now()
|
|
b.currentBackoff = time.Hour
|
|
b.mu.Unlock()
|
|
|
|
if b.Allow() {
|
|
t.Fatalf("expected Allow to return false before backoff elapses")
|
|
}
|
|
if b.State() != StateOpen {
|
|
t.Fatalf("expected state to remain open")
|
|
}
|
|
}
|
|
|
|
func TestBreaker_Allow_HalfOpen(t *testing.T) {
|
|
b := NewBreaker("test", DefaultConfig())
|
|
b.mu.Lock()
|
|
b.state = StateHalfOpen
|
|
b.mu.Unlock()
|
|
if !b.Allow() {
|
|
t.Fatalf("expected Allow true in half-open")
|
|
}
|
|
}
|
|
|
|
func TestToLower(t *testing.T) {
|
|
if toLower("AbC123") != "abc123" {
|
|
t.Fatalf("expected toLower to normalize casing")
|
|
}
|
|
if toLower("lower") != "lower" {
|
|
t.Fatalf("expected lowercase input to remain unchanged")
|
|
}
|
|
}
|