mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
6efb78b7a8
* Smarter approach to the cookies - Single maxCookieSize = 1400 constant with clear documentation - Combined cookie storage for ~40-45% size reduction - Backward compatible migration from legacy cookies * Tuneup the code.
797 lines
20 KiB
Go
797 lines
20 KiB
Go
//go:build !yaegi
|
|
|
|
package recovery
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// =============================================================================
|
|
// RETRY CONFIG TESTS
|
|
// =============================================================================
|
|
|
|
func TestDefaultRetryConfig(t *testing.T) {
|
|
config := DefaultRetryConfig()
|
|
|
|
if config.MaxAttempts != 3 {
|
|
t.Errorf("Expected MaxAttempts to be 3, got %d", config.MaxAttempts)
|
|
}
|
|
|
|
if config.InitialDelay != 100*time.Millisecond {
|
|
t.Errorf("Expected InitialDelay to be 100ms, got %v", config.InitialDelay)
|
|
}
|
|
|
|
if config.MaxDelay != 30*time.Second {
|
|
t.Errorf("Expected MaxDelay to be 30s, got %v", config.MaxDelay)
|
|
}
|
|
|
|
if config.Multiplier != 2.0 {
|
|
t.Errorf("Expected Multiplier to be 2.0, got %f", config.Multiplier)
|
|
}
|
|
|
|
if config.RandomizationFactor != 0.1 {
|
|
t.Errorf("Expected RandomizationFactor to be 0.1, got %f", config.RandomizationFactor)
|
|
}
|
|
|
|
if len(config.RetryableErrors) != 3 {
|
|
t.Errorf("Expected 3 retryable errors, got %d", len(config.RetryableErrors))
|
|
}
|
|
|
|
if len(config.RetryableStatusCodes) != 6 {
|
|
t.Errorf("Expected 6 retryable status codes, got %d", len(config.RetryableStatusCodes))
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// RETRY EXECUTOR TESTS
|
|
// =============================================================================
|
|
|
|
func TestNewRetryExecutor(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := DefaultRetryConfig()
|
|
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
if executor == nil {
|
|
t.Fatal("Expected NewRetryExecutor to return non-nil")
|
|
}
|
|
|
|
if executor.config.MaxAttempts != 3 {
|
|
t.Errorf("Expected MaxAttempts to be 3, got %d", executor.config.MaxAttempts)
|
|
}
|
|
}
|
|
|
|
func TestNewRetryExecutor_InvalidConfig(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
|
|
// Test with invalid MaxAttempts
|
|
config := RetryConfig{
|
|
MaxAttempts: 0, // Invalid
|
|
Multiplier: 0, // Invalid
|
|
}
|
|
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
if executor.config.MaxAttempts != 1 {
|
|
t.Errorf("Expected MaxAttempts to be corrected to 1, got %d", executor.config.MaxAttempts)
|
|
}
|
|
|
|
if executor.config.Multiplier != 1.0 {
|
|
t.Errorf("Expected Multiplier to be corrected to 1.0, got %f", executor.config.Multiplier)
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_ExecuteWithContext_Success(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := DefaultRetryConfig()
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
callCount := 0
|
|
err := executor.ExecuteWithContext(context.Background(), func() error {
|
|
callCount++
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got %v", err)
|
|
}
|
|
|
|
if callCount != 1 {
|
|
t.Errorf("Expected function to be called once, got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_ExecuteWithContext_Retry(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
MaxAttempts: 3,
|
|
InitialDelay: 1 * time.Millisecond,
|
|
MaxDelay: 10 * time.Millisecond,
|
|
Multiplier: 2.0,
|
|
RetryableErrors: []string{"connection refused"},
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
callCount := 0
|
|
err := executor.ExecuteWithContext(context.Background(), func() error {
|
|
callCount++
|
|
if callCount < 3 {
|
|
return errors.New("connection refused")
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
t.Errorf("Expected success after retries, got %v", err)
|
|
}
|
|
|
|
if callCount != 3 {
|
|
t.Errorf("Expected function to be called 3 times, got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_ExecuteWithContext_MaxRetriesExhausted(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
MaxAttempts: 3,
|
|
InitialDelay: 1 * time.Millisecond,
|
|
MaxDelay: 10 * time.Millisecond,
|
|
Multiplier: 2.0,
|
|
RetryableErrors: []string{"timeout"},
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
callCount := 0
|
|
err := executor.ExecuteWithContext(context.Background(), func() error {
|
|
callCount++
|
|
return errors.New("timeout")
|
|
})
|
|
|
|
if err == nil {
|
|
t.Error("Expected error after max retries exhausted")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "all retry attempts failed") {
|
|
t.Errorf("Expected 'all retry attempts failed' error, got %v", err)
|
|
}
|
|
|
|
if callCount != 3 {
|
|
t.Errorf("Expected function to be called 3 times, got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_ExecuteWithContext_NonRetryableError(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
MaxAttempts: 3,
|
|
InitialDelay: 1 * time.Millisecond,
|
|
RetryableErrors: []string{"timeout"},
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
callCount := 0
|
|
err := executor.ExecuteWithContext(context.Background(), func() error {
|
|
callCount++
|
|
return errors.New("permanent error")
|
|
})
|
|
|
|
if err == nil {
|
|
t.Error("Expected error for non-retryable error")
|
|
}
|
|
|
|
if callCount != 1 {
|
|
t.Errorf("Expected function to be called once (non-retryable), got %d", callCount)
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_ExecuteWithContext_ContextCancelled(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
MaxAttempts: 3,
|
|
InitialDelay: 1 * time.Second, // Long delay
|
|
RetryableErrors: []string{"timeout"},
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
callCount := 0
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
|
|
var execErr error
|
|
go func() {
|
|
defer wg.Done()
|
|
execErr = executor.ExecuteWithContext(ctx, func() error {
|
|
callCount++
|
|
return errors.New("timeout")
|
|
})
|
|
}()
|
|
|
|
// Cancel after a short delay
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
|
|
wg.Wait()
|
|
|
|
if execErr == nil {
|
|
t.Error("Expected error when context is cancelled")
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_ExecuteWithContext_ContextCancelledBeforeStart(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := DefaultRetryConfig()
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
err := executor.ExecuteWithContext(ctx, func() error {
|
|
return nil
|
|
})
|
|
|
|
if err == nil {
|
|
t.Error("Expected error when context is already cancelled")
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_Execute(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := DefaultRetryConfig()
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
called := false
|
|
err := executor.Execute(context.Background(), func() error {
|
|
called = true
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
t.Errorf("Expected no error, got %v", err)
|
|
}
|
|
|
|
if !called {
|
|
t.Error("Expected function to be called")
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_isRetryableError(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
RetryableErrors: []string{"connection refused", "timeout"},
|
|
RetryableStatusCodes: []int{500, 503},
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
tests := []struct {
|
|
err error
|
|
name string
|
|
expected bool
|
|
}{
|
|
{name: "nil error", err: nil, expected: false},
|
|
{name: "connection refused", err: errors.New("connection refused"), expected: true},
|
|
{name: "timeout", err: errors.New("TIMEOUT"), expected: true}, // case insensitive
|
|
{name: "EOF", err: errors.New("EOF"), expected: false},
|
|
{name: "random error", err: errors.New("something else"), expected: false},
|
|
{name: "context cancelled", err: context.Canceled, expected: false},
|
|
{name: "context deadline exceeded", err: context.DeadlineExceeded, expected: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := executor.isRetryableError(tt.err)
|
|
if result != tt.expected {
|
|
t.Errorf("Expected %v, got %v", tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_isRetryableError_HTTPError(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
RetryableStatusCodes: []int{500, 503},
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
tests := []struct {
|
|
name string
|
|
statusCode int
|
|
expected bool
|
|
}{
|
|
{"500 error", 500, true},
|
|
{"503 error", 503, true},
|
|
{"502 error (5xx)", 502, true},
|
|
{"400 error", 400, false},
|
|
{"401 error", 401, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
httpErr := &HTTPError{StatusCode: tt.statusCode}
|
|
result := executor.isRetryableError(httpErr)
|
|
if result != tt.expected {
|
|
t.Errorf("Expected %v, got %v", tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_isRetryableError_OIDCError(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := DefaultRetryConfig()
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
// Test retryable OIDC error
|
|
retryableErr := &OIDCError{Code: "temporarily_unavailable", Description: "Server busy"}
|
|
if !executor.isRetryableError(retryableErr) {
|
|
t.Error("Expected temporarily_unavailable to be retryable")
|
|
}
|
|
|
|
// Test non-retryable OIDC error
|
|
nonRetryableErr := &OIDCError{Code: "invalid_token", Description: "Token expired"}
|
|
if executor.isRetryableError(nonRetryableErr) {
|
|
t.Error("Expected invalid_token to not be retryable")
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_calculateDelay(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
InitialDelay: 100 * time.Millisecond,
|
|
MaxDelay: 1 * time.Second,
|
|
Multiplier: 2.0,
|
|
RandomizationFactor: 0.0, // No jitter for predictable tests
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
// Test exponential backoff without jitter
|
|
delay1 := executor.calculateDelay(1)
|
|
if delay1 != 100*time.Millisecond {
|
|
t.Errorf("Expected 100ms for attempt 1, got %v", delay1)
|
|
}
|
|
|
|
delay2 := executor.calculateDelay(2)
|
|
if delay2 != 200*time.Millisecond {
|
|
t.Errorf("Expected 200ms for attempt 2, got %v", delay2)
|
|
}
|
|
|
|
delay3 := executor.calculateDelay(3)
|
|
if delay3 != 400*time.Millisecond {
|
|
t.Errorf("Expected 400ms for attempt 3, got %v", delay3)
|
|
}
|
|
|
|
// Test max delay cap
|
|
delay10 := executor.calculateDelay(10)
|
|
if delay10 > 1*time.Second {
|
|
t.Errorf("Expected delay capped at 1s, got %v", delay10)
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_calculateDelay_WithJitter(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
InitialDelay: 100 * time.Millisecond,
|
|
MaxDelay: 1 * time.Second,
|
|
Multiplier: 2.0,
|
|
RandomizationFactor: 0.5, // 50% jitter
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
// With jitter, delay should be within range
|
|
baseDelay := 100 * time.Millisecond
|
|
minExpected := time.Duration(float64(baseDelay) * 0.5)
|
|
maxExpected := time.Duration(float64(baseDelay) * 1.5)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
delay := executor.calculateDelay(1)
|
|
if delay < minExpected || delay > maxExpected {
|
|
t.Errorf("Delay %v outside expected range [%v, %v]", delay, minExpected, maxExpected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_Reset(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := DefaultRetryConfig()
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
// Generate some metrics
|
|
executor.ExecuteWithContext(context.Background(), func() error { return nil })
|
|
executor.ExecuteWithContext(context.Background(), func() error { return nil })
|
|
|
|
// Reset
|
|
executor.Reset()
|
|
|
|
if atomic.LoadInt64(&executor.totalRetries) != 0 {
|
|
t.Error("Expected totalRetries to be 0 after reset")
|
|
}
|
|
|
|
if atomic.LoadInt64(&executor.maxRetriesHit) != 0 {
|
|
t.Error("Expected maxRetriesHit to be 0 after reset")
|
|
}
|
|
|
|
if atomic.LoadInt64(&executor.totalRequests) != 0 {
|
|
t.Error("Expected totalRequests to be 0 after reset")
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_IsAvailable(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := DefaultRetryConfig()
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
if !executor.IsAvailable() {
|
|
t.Error("Expected IsAvailable to return true")
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_GetMetrics(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := DefaultRetryConfig()
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
// Generate some metrics
|
|
executor.ExecuteWithContext(context.Background(), func() error { return nil })
|
|
|
|
metrics := executor.GetMetrics()
|
|
|
|
// Check required fields
|
|
if _, ok := metrics["totalRetries"]; !ok {
|
|
t.Error("Expected 'totalRetries' in metrics")
|
|
}
|
|
|
|
if _, ok := metrics["maxRetriesHit"]; !ok {
|
|
t.Error("Expected 'maxRetriesHit' in metrics")
|
|
}
|
|
|
|
if _, ok := metrics["config"]; !ok {
|
|
t.Error("Expected 'config' in metrics")
|
|
}
|
|
|
|
if _, ok := metrics["lastRetryTime"]; !ok {
|
|
t.Error("Expected 'lastRetryTime' in metrics")
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_GetMetrics_WithRetries(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
MaxAttempts: 3,
|
|
InitialDelay: 1 * time.Millisecond,
|
|
MaxDelay: 10 * time.Millisecond,
|
|
Multiplier: 2.0,
|
|
RetryableErrors: []string{"retry"},
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
// Generate retries
|
|
callCount := 0
|
|
executor.ExecuteWithContext(context.Background(), func() error {
|
|
callCount++
|
|
if callCount < 2 {
|
|
return errors.New("retry me")
|
|
}
|
|
return nil
|
|
})
|
|
|
|
metrics := executor.GetMetrics()
|
|
|
|
totalRetries := metrics["totalRetries"].(int64)
|
|
if totalRetries < 1 {
|
|
t.Errorf("Expected at least 1 retry, got %d", totalRetries)
|
|
}
|
|
|
|
// Check for average retries calculation
|
|
if _, ok := metrics["averageRetriesPerRequest"]; !ok {
|
|
t.Error("Expected 'averageRetriesPerRequest' in metrics")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// RECOVERY METRICS TESTS
|
|
// =============================================================================
|
|
|
|
func TestNewRecoveryMetrics(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
|
|
if rm == nil {
|
|
t.Fatal("Expected NewRecoveryMetrics to return non-nil")
|
|
}
|
|
|
|
if rm.mechanisms == nil {
|
|
t.Error("Expected mechanisms map to be initialized")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMetrics_RegisterMechanism(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
|
rm.RegisterMechanism("circuit_breaker", cb)
|
|
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
if _, exists := rm.mechanisms["circuit_breaker"]; !exists {
|
|
t.Error("Expected mechanism to be registered")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMetrics_UnregisterMechanism(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
|
rm.RegisterMechanism("circuit_breaker", cb)
|
|
rm.UnregisterMechanism("circuit_breaker")
|
|
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
if _, exists := rm.mechanisms["circuit_breaker"]; exists {
|
|
t.Error("Expected mechanism to be unregistered")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMetrics_GetAllMetrics(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
|
rm.RegisterMechanism("circuit_breaker", cb)
|
|
|
|
re := NewRetryExecutor(DefaultRetryConfig(), logger)
|
|
rm.RegisterMechanism("retry_executor", re)
|
|
|
|
metrics := rm.GetAllMetrics()
|
|
|
|
if _, ok := metrics["circuit_breaker"]; !ok {
|
|
t.Error("Expected 'circuit_breaker' in metrics")
|
|
}
|
|
|
|
if _, ok := metrics["retry_executor"]; !ok {
|
|
t.Error("Expected 'retry_executor' in metrics")
|
|
}
|
|
|
|
if _, ok := metrics["summary"]; !ok {
|
|
t.Error("Expected 'summary' in metrics")
|
|
}
|
|
|
|
summary := metrics["summary"].(map[string]interface{})
|
|
if summary["totalMechanisms"] != 2 {
|
|
t.Errorf("Expected 2 mechanisms, got %v", summary["totalMechanisms"])
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMetrics_GetAllMetrics_WithActivity(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
|
rm.RegisterMechanism("circuit_breaker", cb)
|
|
|
|
// Generate some activity
|
|
cb.Execute(func() error { return nil })
|
|
cb.Execute(func() error { return nil })
|
|
|
|
metrics := rm.GetAllMetrics()
|
|
summary := metrics["summary"].(map[string]interface{})
|
|
|
|
// Should have success rate calculated
|
|
if _, ok := summary["overallSuccessRate"]; !ok {
|
|
t.Error("Expected 'overallSuccessRate' in summary")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMetrics_GetMechanismMetrics(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
|
rm.RegisterMechanism("circuit_breaker", cb)
|
|
|
|
// Test existing mechanism
|
|
metrics, ok := rm.GetMechanismMetrics("circuit_breaker")
|
|
if !ok {
|
|
t.Error("Expected to find circuit_breaker mechanism")
|
|
}
|
|
if metrics == nil {
|
|
t.Error("Expected metrics to be non-nil")
|
|
}
|
|
|
|
// Test non-existing mechanism
|
|
_, ok = rm.GetMechanismMetrics("non_existent")
|
|
if ok {
|
|
t.Error("Expected to not find non_existent mechanism")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMetrics_HealthCheck(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
// Test with healthy mechanism
|
|
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
|
rm.RegisterMechanism("circuit_breaker", cb)
|
|
|
|
health := rm.HealthCheck()
|
|
|
|
if health["status"] != "healthy" {
|
|
t.Errorf("Expected status 'healthy', got %v", health["status"])
|
|
}
|
|
|
|
mechanisms := health["mechanisms"].(map[string]interface{})
|
|
if mechanisms["circuit_breaker"] != "healthy" {
|
|
t.Errorf("Expected circuit_breaker to be 'healthy', got %v", mechanisms["circuit_breaker"])
|
|
}
|
|
|
|
if health["healthy"] != 1 {
|
|
t.Errorf("Expected 1 healthy, got %v", health["healthy"])
|
|
}
|
|
|
|
if health["unhealthy"] != 0 {
|
|
t.Errorf("Expected 0 unhealthy, got %v", health["unhealthy"])
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMetrics_HealthCheck_Degraded(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
// Add a healthy mechanism
|
|
cb1 := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
|
rm.RegisterMechanism("healthy_cb", cb1)
|
|
|
|
// Add an unhealthy mechanism (trip the circuit breaker)
|
|
config := CircuitBreakerConfig{
|
|
FailureThreshold: 1,
|
|
SuccessThreshold: 10,
|
|
Timeout: 1 * time.Hour,
|
|
MaxRequests: 1,
|
|
}
|
|
cb2 := NewCircuitBreaker(config, logger)
|
|
cb2.Execute(func() error { return errors.New("fail") })
|
|
rm.RegisterMechanism("unhealthy_cb", cb2)
|
|
|
|
health := rm.HealthCheck()
|
|
|
|
if health["status"] != "degraded" {
|
|
t.Errorf("Expected status 'degraded', got %v", health["status"])
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMetrics_HealthCheck_Unhealthy(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
// Add only an unhealthy mechanism
|
|
config := CircuitBreakerConfig{
|
|
FailureThreshold: 1,
|
|
SuccessThreshold: 10,
|
|
Timeout: 1 * time.Hour,
|
|
MaxRequests: 1,
|
|
}
|
|
cb := NewCircuitBreaker(config, logger)
|
|
cb.Execute(func() error { return errors.New("fail") })
|
|
rm.RegisterMechanism("unhealthy_cb", cb)
|
|
|
|
health := rm.HealthCheck()
|
|
|
|
if health["status"] != "unhealthy" {
|
|
t.Errorf("Expected status 'unhealthy', got %v", health["status"])
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMetrics_HTTPMetricsHandler(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
|
rm.RegisterMechanism("circuit_breaker", cb)
|
|
|
|
handler := rm.HTTPMetricsHandler()
|
|
|
|
req := httptest.NewRequest("GET", "/metrics", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
contentType := w.Header().Get("Content-Type")
|
|
if contentType != "application/json" {
|
|
t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
if body == "" {
|
|
t.Error("Expected non-empty response body")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// CONCURRENT ACCESS TESTS
|
|
// =============================================================================
|
|
|
|
func TestRecoveryMetrics_ConcurrentAccess(t *testing.T) {
|
|
rm := NewRecoveryMetrics()
|
|
logger := &mockLogger{}
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
// Concurrent registrations
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), logger)
|
|
rm.RegisterMechanism(string(rune('a'+idx)), cb)
|
|
}(i)
|
|
}
|
|
|
|
// Concurrent reads
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_ = rm.GetAllMetrics()
|
|
_ = rm.HealthCheck()
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// Verify no race conditions
|
|
health := rm.HealthCheck()
|
|
if health == nil {
|
|
t.Error("Expected HealthCheck to return non-nil after concurrent access")
|
|
}
|
|
}
|
|
|
|
func TestRetryExecutor_ConcurrentExecution(t *testing.T) {
|
|
logger := &mockLogger{}
|
|
config := RetryConfig{
|
|
MaxAttempts: 3,
|
|
InitialDelay: 1 * time.Millisecond,
|
|
MaxDelay: 10 * time.Millisecond,
|
|
Multiplier: 2.0,
|
|
RetryableErrors: []string{"retry"},
|
|
}
|
|
executor := NewRetryExecutor(config, logger)
|
|
|
|
var wg sync.WaitGroup
|
|
successCount := int64(0)
|
|
|
|
for i := 0; i < 100; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
err := executor.ExecuteWithContext(context.Background(), func() error {
|
|
return nil
|
|
})
|
|
if err == nil {
|
|
atomic.AddInt64(&successCount, 1)
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
if successCount != 100 {
|
|
t.Errorf("Expected 100 successes, got %d", successCount)
|
|
}
|
|
}
|