Files
traefikoidc/internal/cache/resilience/circuit_breaker_test.go
T
lukaszraczyloGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
e64fc7f730 Add redis support for distributed caching (#83)
* Add redis support for distributed caching

* Move towards the self-provided Redis connection pool and RESP protocol implementation.
Official redis client library won't work with yaegi.

* fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.

* fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.

* fixup! fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.

* fixup! fixup! fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.

* fixup! fixup! fixup! fixup! fixup! Move towards the self-provided Redis connection pool and RESP protocol implementation. Official redis client library won't work with yaegi.

* ... and another all nighter.

* fixup! ... and another all nighter.

* fixup! fixup! ... and another all nighter.

* fixup! fixup! fixup! ... and another all nighter.

* Resolve issue #85 by adding ability to set custom claims in JWT tokens

* Remove redundant validation in auth middleware ( issue #89 )

* Add ability to set cookie prefix for session cookies ( #87 )

* fixup! Add ability to set cookie prefix for session cookies ( #87 )

* Add ability to set cookie max age - issue #91

* Potential fix for code scanning alert no. 10: Size computation for allocation may overflow

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fixup! Merge main into 0.8.0-redis: resolve conflicts

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-11-30 02:18:46 +00:00

554 lines
13 KiB
Go

package resilience
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestCircuitBreaker_StateTransitions tests state machine transitions
func TestCircuitBreaker_StateTransitions(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 3,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 2,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
t.Run("Initial state is closed", func(t *testing.T) {
assert.Equal(t, StateClosed, cb.GetState())
})
t.Run("Closed to Open after max failures", func(t *testing.T) {
cb.Reset()
// Simulate failures
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
})
t.Run("Open to HalfOpen after timeout", func(t *testing.T) {
// Open the circuit
cb.Reset()
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
// Wait for timeout
time.Sleep(150 * time.Millisecond)
// Should allow request and transition to half-open
err := cb.Execute(ctx, func() error {
return nil
})
assert.NoError(t, err)
assert.Equal(t, StateHalfOpen, cb.GetState())
})
t.Run("HalfOpen to Closed after successful requests", func(t *testing.T) {
// Open circuit then wait for half-open
cb.Reset()
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
time.Sleep(150 * time.Millisecond)
// First request transitions to half-open and succeeds
err := cb.Execute(ctx, func() error {
return nil
})
assert.NoError(t, err)
// Should be in half-open after first request
state := cb.GetState()
assert.True(t, state == StateHalfOpen || state == StateClosed,
"After first successful request, should be half-open or potentially closed")
if state == StateHalfOpen {
// Need more successful requests to close
// The exact number depends on implementation but should be within HalfOpenMaxRequests
for i := 0; i < config.HalfOpenMaxRequests; i++ {
cb.Execute(ctx, func() error {
return nil
})
}
// After multiple successful requests, should eventually close
finalState := cb.GetState()
assert.True(t, finalState == StateClosed || finalState == StateHalfOpen,
"After successful requests, circuit should transition towards closed")
}
})
t.Run("HalfOpen to Open on failure", func(t *testing.T) {
// Open circuit then wait for half-open
cb.Reset()
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
time.Sleep(150 * time.Millisecond)
// First call transitions to half-open, second failure reopens
cb.Execute(ctx, func() error {
return errors.New("test error")
})
assert.Equal(t, StateOpen, cb.GetState())
})
}
// TestCircuitBreaker_OpenCircuitBlocks tests that open circuit blocks requests
func TestCircuitBreaker_OpenCircuitBlocks(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 1 * time.Second,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Trigger failures to open circuit
for i := 0; i < 2; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
// Requests should be blocked
err := cb.Execute(ctx, func() error {
t.Fatal("Should not execute function when circuit is open")
return nil
})
assert.Error(t, err)
assert.Equal(t, ErrCircuitOpen, err)
}
// TestCircuitBreaker_HalfOpenMaxRequests tests max requests in half-open state
func TestCircuitBreaker_HalfOpenMaxRequests(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 3,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 2,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Open circuit then wait for half-open
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
time.Sleep(150 * time.Millisecond)
// After timeout, circuit should allow transition to half-open
// Execute HalfOpenMaxRequests successful requests
successCount := 0
for i := 0; i < config.HalfOpenMaxRequests; i++ {
err := cb.Execute(ctx, func() error {
successCount++
return nil
})
// Should allow up to HalfOpenMaxRequests
assert.NoError(t, err)
}
// Verify we executed the expected number
assert.Equal(t, config.HalfOpenMaxRequests, successCount)
// After successful requests, circuit behavior depends on implementation
// It could close (allowing more requests) or stay half-open (blocking)
// The important thing is that we allowed exactly HalfOpenMaxRequests
}
// TestCircuitBreaker_SuccessResetsFailures tests failure counter reset
func TestCircuitBreaker_SuccessResetsFailures(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 3,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Have some failures (but less than max)
cb.Execute(ctx, func() error {
return errors.New("error")
})
cb.Execute(ctx, func() error {
return errors.New("error")
})
assert.Equal(t, StateClosed, cb.GetState())
stats := cb.Stats()
assert.Equal(t, int32(2), stats.ConsecutiveFailures)
// One success should reset failures
cb.Execute(ctx, func() error {
return nil
})
assert.Equal(t, StateClosed, cb.GetState())
stats = cb.Stats()
assert.Equal(t, int32(0), stats.ConsecutiveFailures)
}
// TestCircuitBreaker_ConcurrentAccess tests thread safety
func TestCircuitBreaker_ConcurrentAccess(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 10,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 5,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
var wg sync.WaitGroup
goroutines := 20
iterations := 50
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
// Mix of successes and failures
cb.Execute(ctx, func() error {
if (id+j)%3 == 0 {
return errors.New("test error")
}
return nil
})
// Random state checks
_ = cb.GetState()
_ = cb.Stats()
}
}(i)
}
wg.Wait()
// Should complete without panics
stats := cb.Stats()
assert.NotNil(t, stats)
}
// TestCircuitBreaker_Stats tests statistics tracking
func TestCircuitBreaker_Stats(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 5,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 2,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Execute some requests
cb.Execute(ctx, func() error { return nil }) // Success
cb.Execute(ctx, func() error { return errors.New("error") }) // Failure
cb.Execute(ctx, func() error { return errors.New("error") }) // Failure
stats := cb.Stats()
assert.Equal(t, StateClosed, stats.State)
assert.Equal(t, int64(3), stats.TotalRequests)
assert.Equal(t, int64(2), stats.TotalFailures)
assert.Equal(t, int32(2), stats.ConsecutiveFailures)
}
// TestCircuitBreaker_Reset tests circuit reset
func TestCircuitBreaker_Reset(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Open the circuit
for i := 0; i < 2; i++ {
cb.Execute(ctx, func() error {
return errors.New("error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
// Reset
cb.Reset()
assert.Equal(t, StateClosed, cb.GetState())
stats := cb.Stats()
assert.Equal(t, int32(0), stats.ConsecutiveFailures)
assert.Equal(t, int64(0), stats.TotalRequests)
assert.Equal(t, int64(0), stats.TotalFailures)
}
// TestCircuitBreaker_StateChangeCallback tests state change notifications
func TestCircuitBreaker_StateChangeCallback(t *testing.T) {
t.Parallel()
var transitions []string
var mu sync.Mutex
config := &CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 50 * time.Millisecond,
HalfOpenMaxRequests: 1,
OnStateChange: func(from, to State) {
mu.Lock()
defer mu.Unlock()
transitions = append(transitions, from.String()+"->"+to.String())
},
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Trigger state transitions
// Closed -> Open
for i := 0; i < 2; i++ {
cb.Execute(ctx, func() error {
return errors.New("error")
})
}
// Should be open now
assert.Equal(t, StateOpen, cb.GetState())
// Wait for timeout to allow half-open transition
time.Sleep(100 * time.Millisecond)
// Open -> HalfOpen on first request after timeout
err := cb.Execute(ctx, func() error {
return nil
})
assert.NoError(t, err)
// Execute more successful requests to trigger HalfOpen -> Closed
for i := 0; i < config.HalfOpenMaxRequests-1; i++ {
cb.Execute(ctx, func() error {
return nil
})
}
mu.Lock()
defer mu.Unlock()
assert.Contains(t, transitions, "closed->open")
assert.Contains(t, transitions, "open->half-open")
}
// TestCircuitBreaker_IsHealthy tests health check
func TestCircuitBreaker_IsHealthy(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Initially healthy
assert.True(t, cb.IsHealthy())
// Open circuit
for i := 0; i < 2; i++ {
cb.Execute(ctx, func() error {
return errors.New("error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
assert.False(t, cb.IsHealthy(), "Should not be healthy when open")
// Wait for timeout and allow successful request
time.Sleep(150 * time.Millisecond)
cb.Execute(ctx, func() error {
return nil
})
// Should be healthy after recovery
assert.True(t, cb.IsHealthy(), "Should be healthy after recovery")
}
// TestCircuitBreaker_RapidFailures tests rapid consecutive failures
func TestCircuitBreaker_RapidFailures(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 5,
Timeout: 200 * time.Millisecond,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Rapid failures
for i := 0; i < 10; i++ {
cb.Execute(ctx, func() error {
return errors.New("rapid error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
stats := cb.Stats()
assert.GreaterOrEqual(t, stats.TotalFailures, int64(5))
}
// TestCircuitBreaker_TimeoutAccuracy tests timeout precision
func TestCircuitBreaker_TimeoutAccuracy(t *testing.T) {
t.Parallel()
timeout := 100 * time.Millisecond
config := &CircuitBreakerConfig{
MaxFailures: 1,
Timeout: timeout,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Open circuit
cb.Execute(ctx, func() error {
return errors.New("error")
})
assert.Equal(t, StateOpen, cb.GetState())
// Wait just before timeout
time.Sleep(timeout - 20*time.Millisecond)
assert.False(t, cb.IsHealthy())
// Wait until after timeout
time.Sleep(40 * time.Millisecond)
// After timeout, AllowRequest should return true for transition to half-open
assert.True(t, cb.AllowRequest())
}
// TestCircuitBreaker_DefaultConfig tests default configuration
func TestCircuitBreaker_DefaultConfig(t *testing.T) {
t.Parallel()
cb := NewCircuitBreaker(nil) // Should use defaults
assert.NotNil(t, cb)
assert.Equal(t, StateClosed, cb.GetState())
// Verify defaults by triggering circuit breaker behavior
ctx := context.Background()
// Test that it takes 5 failures to open (default MaxFailures)
for i := 0; i < 4; i++ {
cb.Execute(ctx, func() error {
return errors.New("error")
})
}
assert.Equal(t, StateClosed, cb.GetState(), "Should still be closed after 4 failures")
// 5th failure should open it
cb.Execute(ctx, func() error {
return errors.New("error")
})
assert.Equal(t, StateOpen, cb.GetState(), "Should be open after 5 failures (default threshold)")
}
// TestCircuitBreaker_StateString tests state string representation
func TestCircuitBreaker_StateString(t *testing.T) {
t.Parallel()
assert.Equal(t, "closed", StateClosed.String())
assert.Equal(t, "open", StateOpen.String())
assert.Equal(t, "half-open", StateHalfOpen.String())
assert.Equal(t, "unknown", State(999).String())
}
// Benchmark circuit breaker performance
func BenchmarkCircuitBreaker_Execute(b *testing.B) {
config := &CircuitBreakerConfig{
MaxFailures: 100,
Timeout: 1 * time.Second,
HalfOpenMaxRequests: 10,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cb.Execute(ctx, func() error {
return nil
})
}
}
func BenchmarkCircuitBreaker_ExecuteWithFailures(b *testing.B) {
config := &CircuitBreakerConfig{
MaxFailures: 1000,
Timeout: 1 * time.Second,
HalfOpenMaxRequests: 10,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cb.Execute(ctx, func() error {
if i%10 == 0 {
return errors.New("error")
}
return nil
})
}
}