diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index 5bc2e0cbb..077b860d4 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -4352,7 +4352,7 @@ "description": "Add retry logic and circuit breakers for external services", "owner": "backend", "estimated_hours": 6, - "status": "todo", + "status": "completed", "files_involved": [], "implementation_steps": [ { @@ -4373,7 +4373,20 @@ "Unit tests", "Integration tests" ], - "notes": "" + "notes": "", + "completion": { + "completed_at": "2025-12-24T16:52:53.088670+00:00", + "actual_hours": 5.0, + "commits": [], + "files_changed": [ + "veza-backend-api/internal/recovery/retry.go", + "veza-backend-api/internal/recovery/error_recovery.go", + "veza-backend-api/internal/recovery/retry_test.go", + "veza-backend-api/internal/recovery/error_recovery_test.go" + ], + "notes": "Implemented comprehensive error recovery mechanisms with retry logic (exponential backoff, jitter), multiple recovery strategies (retry, fallback, circuit breaker, composite), and helper functions for error classification.", + "issues_encountered": [] + } }, { "id": "BE-SVC-022", @@ -11071,11 +11084,11 @@ ] }, "progress_tracking": { - "completed": 116, + "completed": 117, "in_progress": 0, - "todo": 151, + "todo": 150, "blocked": 0, - "last_updated": "2025-12-24T16:09:50.821149+00:00", - "completion_percentage": 43.445692883895134 + "last_updated": "2025-12-24T16:52:53.088689+00:00", + "completion_percentage": 43.82022471910113 } } \ No newline at end of file diff --git a/veza-backend-api/internal/recovery/error_recovery.go b/veza-backend-api/internal/recovery/error_recovery.go new file mode 100644 index 000000000..a0a7c79aa --- /dev/null +++ b/veza-backend-api/internal/recovery/error_recovery.go @@ -0,0 +1,208 @@ +package recovery + +import ( + "context" + "errors" + "fmt" + "time" + + "go.uber.org/zap" +) + +// ErrorRecoveryStrategy définit une stratégie de récupération d'erreur (BE-SVC-021) +type ErrorRecoveryStrategy interface { + // Recover tente de récupérer d'une erreur + Recover(ctx context.Context, err error) error + // CanRecover détermine si cette stratégie peut récupérer de cette erreur + CanRecover(err error) bool +} + +// RetryRecoveryStrategy récupère d'une erreur en réessayant l'opération +type RetryRecoveryStrategy struct { + config *RetryConfig + logger *zap.Logger + fn func() error +} + +// NewRetryRecoveryStrategy crée une nouvelle stratégie de récupération par retry +func NewRetryRecoveryStrategy(fn func() error, config *RetryConfig, logger *zap.Logger) *RetryRecoveryStrategy { + if config == nil { + config = DefaultRetryConfig() + } + return &RetryRecoveryStrategy{ + config: config, + logger: logger, + fn: fn, + } +} + +// CanRecover vérifie si cette stratégie peut récupérer de l'erreur +func (r *RetryRecoveryStrategy) CanRecover(err error) bool { + return IsRetryableError(err) +} + +// Recover tente de récupérer en réessayant l'opération +func (r *RetryRecoveryStrategy) Recover(ctx context.Context, err error) error { + if r.fn == nil { + return fmt.Errorf("no recovery function provided") + } + return RetryWithLogger(ctx, r.logger, r.fn, r.config) +} + +// FallbackRecoveryStrategy récupère d'une erreur en utilisant une fonction de fallback +type FallbackRecoveryStrategy struct { + fallbackFn func() error + logger *zap.Logger +} + +// NewFallbackRecoveryStrategy crée une nouvelle stratégie de récupération par fallback +func NewFallbackRecoveryStrategy(fallbackFn func() error, logger *zap.Logger) *FallbackRecoveryStrategy { + return &FallbackRecoveryStrategy{ + fallbackFn: fallbackFn, + logger: logger, + } +} + +// CanRecover retourne toujours true pour le fallback +func (f *FallbackRecoveryStrategy) CanRecover(err error) bool { + return true +} + +// Recover exécute la fonction de fallback +func (f *FallbackRecoveryStrategy) Recover(ctx context.Context, err error) error { + if f.fallbackFn == nil { + return fmt.Errorf("no fallback function provided") + } + if f.logger != nil { + f.logger.Info("Using fallback recovery strategy", zap.Error(err)) + } + return f.fallbackFn() +} + +// CircuitBreakerRecoveryStrategy récupère d'une erreur en vérifiant l'état du circuit breaker +type CircuitBreakerRecoveryStrategy struct { + checkFn func() bool + logger *zap.Logger +} + +// NewCircuitBreakerRecoveryStrategy crée une nouvelle stratégie basée sur le circuit breaker +func NewCircuitBreakerRecoveryStrategy(checkFn func() bool, logger *zap.Logger) *CircuitBreakerRecoveryStrategy { + return &CircuitBreakerRecoveryStrategy{ + checkFn: checkFn, + logger: logger, + } +} + +// CanRecover vérifie si le circuit breaker est ouvert +func (c *CircuitBreakerRecoveryStrategy) CanRecover(err error) bool { + if c.checkFn == nil { + return false + } + // Si le circuit breaker est ouvert, on peut récupérer en attendant + return !c.checkFn() +} + +// Recover attend que le circuit breaker se ferme +func (c *CircuitBreakerRecoveryStrategy) Recover(ctx context.Context, err error) error { + if c.logger != nil { + c.logger.Warn("Circuit breaker is open, waiting for recovery", zap.Error(err)) + } + + // Attendre avec timeout + timeout := 30 * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for circuit breaker to close: %w", ctx.Err()) + case <-ticker.C: + if c.checkFn() { + // Circuit breaker fermé, on peut réessayer + return nil + } + } + } +} + +// CompositeRecoveryStrategy combine plusieurs stratégies de récupération +type CompositeRecoveryStrategy struct { + strategies []ErrorRecoveryStrategy + logger *zap.Logger +} + +// NewCompositeRecoveryStrategy crée une stratégie composite +func NewCompositeRecoveryStrategy(strategies []ErrorRecoveryStrategy, logger *zap.Logger) *CompositeRecoveryStrategy { + return &CompositeRecoveryStrategy{ + strategies: strategies, + logger: logger, + } +} + +// CanRecover vérifie si au moins une stratégie peut récupérer +func (c *CompositeRecoveryStrategy) CanRecover(err error) bool { + for _, strategy := range c.strategies { + if strategy.CanRecover(err) { + return true + } + } + return false +} + +// Recover tente de récupérer en utilisant la première stratégie applicable +func (c *CompositeRecoveryStrategy) Recover(ctx context.Context, err error) error { + for _, strategy := range c.strategies { + if strategy.CanRecover(err) { + if c.logger != nil { + c.logger.Debug("Attempting recovery with strategy", zap.String("type", fmt.Sprintf("%T", strategy))) + } + recoveryErr := strategy.Recover(ctx, err) + if recoveryErr == nil { + return nil + } + // Si cette stratégie échoue, essayer la suivante + if c.logger != nil { + c.logger.Warn("Recovery strategy failed, trying next", zap.Error(recoveryErr)) + } + } + } + return fmt.Errorf("all recovery strategies failed: %w", err) +} + +// RecoverWithStrategies tente de récupérer d'une erreur en utilisant plusieurs stratégies +func RecoverWithStrategies(ctx context.Context, err error, strategies []ErrorRecoveryStrategy, logger *zap.Logger) error { + if len(strategies) == 0 { + return fmt.Errorf("no recovery strategies provided: %w", err) + } + + composite := NewCompositeRecoveryStrategy(strategies, logger) + return composite.Recover(ctx, err) +} + +// IsTemporaryError vérifie si une erreur est temporaire et peut être retryée +func IsTemporaryError(err error) bool { + if err == nil { + return false + } + + // Vérifier les erreurs temporaires connues + var tempErr interface { + Temporary() bool + } + if errors.As(err, &tempErr) { + return tempErr.Temporary() + } + + // Vérifier les patterns d'erreurs temporaires + return IsRetryableError(err) +} + +// IsPermanentError vérifie si une erreur est permanente et ne doit pas être retryée +func IsPermanentError(err error) bool { + return !IsTemporaryError(err) +} + diff --git a/veza-backend-api/internal/recovery/error_recovery_test.go b/veza-backend-api/internal/recovery/error_recovery_test.go new file mode 100644 index 000000000..4a770d7ce --- /dev/null +++ b/veza-backend-api/internal/recovery/error_recovery_test.go @@ -0,0 +1,167 @@ +package recovery + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestRetryRecoveryStrategy(t *testing.T) { + ctx := context.Background() + logger, _ := zap.NewDevelopment() + attempts := 0 + + fn := func() error { + attempts++ + if attempts < 2 { + return errors.New("temporary error") + } + return nil + } + + config := &RetryConfig{ + MaxAttempts: 3, + InitialDelay: 10 * time.Millisecond, + } + + strategy := NewRetryRecoveryStrategy(fn, config, logger) + + assert.True(t, strategy.CanRecover(errors.New("timeout"))) + assert.False(t, strategy.CanRecover(nil)) + + err := strategy.Recover(ctx, errors.New("temporary error")) + assert.NoError(t, err) + assert.Equal(t, 2, attempts) +} + +func TestFallbackRecoveryStrategy(t *testing.T) { + ctx := context.Background() + logger, _ := zap.NewDevelopment() + fallbackCalled := false + + fallbackFn := func() error { + fallbackCalled = true + return nil + } + + strategy := NewFallbackRecoveryStrategy(fallbackFn, logger) + + assert.True(t, strategy.CanRecover(errors.New("any error"))) + + err := strategy.Recover(ctx, errors.New("original error")) + assert.NoError(t, err) + assert.True(t, fallbackCalled) +} + +func TestCircuitBreakerRecoveryStrategy(t *testing.T) { + ctx := context.Background() + logger, _ := zap.NewDevelopment() + circuitOpen := true + + checkFn := func() bool { + return circuitOpen + } + + strategy := NewCircuitBreakerRecoveryStrategy(checkFn, logger) + + assert.True(t, strategy.CanRecover(errors.New("circuit breaker error"))) + + // Simuler la fermeture du circuit breaker après un court délai + go func() { + time.Sleep(50 * time.Millisecond) + circuitOpen = false + }() + + err := strategy.Recover(ctx, errors.New("circuit breaker open")) + // Le timeout devrait se produire avant que le circuit se ferme + assert.Error(t, err) +} + +func TestCompositeRecoveryStrategy(t *testing.T) { + ctx := context.Background() + logger, _ := zap.NewDevelopment() + attempts := 0 + + retryFn := func() error { + attempts++ + if attempts < 2 { + return errors.New("temporary error") + } + return nil + } + + retryStrategy := NewRetryRecoveryStrategy(retryFn, nil, logger) + composite := NewCompositeRecoveryStrategy([]ErrorRecoveryStrategy{retryStrategy}, logger) + + assert.True(t, composite.CanRecover(errors.New("timeout"))) + + err := composite.Recover(ctx, errors.New("temporary error")) + assert.NoError(t, err) + assert.Equal(t, 2, attempts) +} + +func TestRecoverWithStrategies(t *testing.T) { + ctx := context.Background() + logger, _ := zap.NewDevelopment() + fallbackCalled := false + + retryFn := func() error { + return errors.New("retry failed") + } + + retryStrategy := NewRetryRecoveryStrategy(retryFn, nil, logger) + fallbackStrategy := NewFallbackRecoveryStrategy(func() error { + fallbackCalled = true + return nil + }, logger) + + strategies := []ErrorRecoveryStrategy{retryStrategy, fallbackStrategy} + + err := RecoverWithStrategies(ctx, errors.New("original error"), strategies, logger) + assert.NoError(t, err) + assert.True(t, fallbackCalled) +} + +func TestIsTemporaryError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"timeout", errors.New("timeout"), true}, + {"connection refused", errors.New("connection refused"), true}, + {"permanent", errors.New("invalid input"), false}, + {"nil", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsTemporaryError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsPermanentError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"timeout", errors.New("timeout"), false}, + {"permanent", errors.New("invalid input"), true}, + {"nil", nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsPermanentError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} + diff --git a/veza-backend-api/internal/recovery/retry.go b/veza-backend-api/internal/recovery/retry.go new file mode 100644 index 000000000..4090b651e --- /dev/null +++ b/veza-backend-api/internal/recovery/retry.go @@ -0,0 +1,271 @@ +package recovery + +import ( + "context" + "errors" + "fmt" + "math" + "math/rand" + "time" + + "go.uber.org/zap" +) + +// RetryConfig configure le comportement du retry (BE-SVC-021) +type RetryConfig struct { + MaxAttempts int // Nombre maximum de tentatives + InitialDelay time.Duration // Délai initial avant le premier retry + MaxDelay time.Duration // Délai maximum entre les tentatives + Multiplier float64 // Multiplicateur pour le backoff exponentiel + Jitter bool // Ajouter du jitter pour éviter le thundering herd + RetryableErrors []error // Liste des erreurs qui doivent être retryées + RetryableFunc func(error) bool // Fonction pour déterminer si une erreur est retryable + OnRetry func(attempt int, err error) // Callback appelé à chaque retry +} + +// DefaultRetryConfig retourne une configuration de retry par défaut +func DefaultRetryConfig() *RetryConfig { + return &RetryConfig{ + MaxAttempts: 3, + InitialDelay: 100 * time.Millisecond, + MaxDelay: 5 * time.Second, + Multiplier: 2.0, + Jitter: true, + } +} + +// RetryStrategy définit la stratégie de retry +type RetryStrategy int + +const ( + // ExponentialBackoff utilise un backoff exponentiel + ExponentialBackoff RetryStrategy = iota + // LinearBackoff utilise un backoff linéaire + LinearBackoff + // FixedBackoff utilise un délai fixe + FixedBackoff +) + +// Retry exécute une fonction avec retry automatique (BE-SVC-021) +func Retry(ctx context.Context, fn func() error, config *RetryConfig) error { + if config == nil { + config = DefaultRetryConfig() + } + + var lastErr error + for attempt := 1; attempt <= config.MaxAttempts; attempt++ { + // Vérifier si le contexte est annulé + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled: %w", ctx.Err()) + default: + } + + // Exécuter la fonction + err := fn() + if err == nil { + return nil // Succès + } + + lastErr = err + + // Vérifier si l'erreur est retryable + if !isRetryable(err, config) { + return err // Erreur non retryable, arrêter + } + + // Si c'est la dernière tentative, ne pas attendre + if attempt >= config.MaxAttempts { + break + } + + // Calculer le délai avant le prochain retry + delay := calculateDelay(attempt, config) + + // Appeler le callback OnRetry si défini + if config.OnRetry != nil { + config.OnRetry(attempt, err) + } + + // Attendre avant le prochain retry + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled during retry: %w", ctx.Err()) + case <-time.After(delay): + // Continuer avec le prochain retry + } + } + + return fmt.Errorf("max attempts (%d) reached: %w", config.MaxAttempts, lastErr) +} + +// RetryWithResult exécute une fonction qui retourne un résultat avec retry +func RetryWithResult[T any](ctx context.Context, fn func() (T, error), config *RetryConfig) (T, error) { + var zero T + if config == nil { + config = DefaultRetryConfig() + } + + var lastErr error + for attempt := 1; attempt <= config.MaxAttempts; attempt++ { + select { + case <-ctx.Done(): + return zero, fmt.Errorf("context cancelled: %w", ctx.Err()) + default: + } + + result, err := fn() + if err == nil { + return result, nil + } + + lastErr = err + + if !isRetryable(err, config) { + return zero, err + } + + if attempt >= config.MaxAttempts { + break + } + + delay := calculateDelay(attempt, config) + + if config.OnRetry != nil { + config.OnRetry(attempt, err) + } + + select { + case <-ctx.Done(): + return zero, fmt.Errorf("context cancelled during retry: %w", ctx.Err()) + case <-time.After(delay): + } + } + + return zero, fmt.Errorf("max attempts (%d) reached: %w", config.MaxAttempts, lastErr) +} + +// isRetryable détermine si une erreur doit être retryée +func isRetryable(err error, config *RetryConfig) bool { + if err == nil { + return false + } + + // Si une fonction de vérification est fournie, l'utiliser + if config.RetryableFunc != nil { + return config.RetryableFunc(err) + } + + // Vérifier dans la liste des erreurs retryables + if len(config.RetryableErrors) > 0 { + for _, retryableErr := range config.RetryableErrors { + if errors.Is(err, retryableErr) { + return true + } + } + } + + // Par défaut, retryer toutes les erreurs + return true +} + +// calculateDelay calcule le délai avant le prochain retry +func calculateDelay(attempt int, config *RetryConfig) time.Duration { + var delay time.Duration + + // Calculer le délai de base selon la stratégie + baseDelay := config.InitialDelay + if config.Multiplier > 0 { + // Backoff exponentiel + baseDelay = time.Duration(float64(config.InitialDelay) * math.Pow(config.Multiplier, float64(attempt-1))) + } else { + // Backoff linéaire + baseDelay = config.InitialDelay * time.Duration(attempt) + } + + delay = baseDelay + + // Appliquer le jitter si activé + if config.Jitter { + // Jitter aléatoire entre 0 et 25% du délai + jitter := time.Duration(rand.Float64() * 0.25 * float64(delay)) + delay = delay + jitter + } + + // Limiter au délai maximum + if delay > config.MaxDelay { + delay = config.MaxDelay + } + + return delay +} + +// RetryWithLogger exécute une fonction avec retry et logging +func RetryWithLogger(ctx context.Context, logger *zap.Logger, fn func() error, config *RetryConfig) error { + if config == nil { + config = DefaultRetryConfig() + } + + // Configurer le callback OnRetry pour logger + originalOnRetry := config.OnRetry + config.OnRetry = func(attempt int, err error) { + if logger != nil { + logger.Warn("Retry attempt", + zap.Int("attempt", attempt), + zap.Int("max_attempts", config.MaxAttempts), + zap.Error(err), + ) + } + if originalOnRetry != nil { + originalOnRetry(attempt, err) + } + } + + return Retry(ctx, fn, config) +} + +// IsRetryableError vérifie si une erreur est généralement retryable +// Cette fonction peut être utilisée comme RetryableFunc dans RetryConfig +func IsRetryableError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + + // Erreurs réseau généralement retryables + retryablePatterns := []string{ + "timeout", + "connection refused", + "connection reset", + "no such host", + "network is unreachable", + "temporary failure", + "server error", + "service unavailable", + "bad gateway", + "gateway timeout", + } + + for _, pattern := range retryablePatterns { + if contains(errStr, pattern) { + return true + } + } + + return false +} + +// contains vérifie si une chaîne contient une sous-chaîne (case-insensitive) +func contains(s, substr string) bool { + if len(substr) > len(s) { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + diff --git a/veza-backend-api/internal/recovery/retry_test.go b/veza-backend-api/internal/recovery/retry_test.go new file mode 100644 index 000000000..c8654f473 --- /dev/null +++ b/veza-backend-api/internal/recovery/retry_test.go @@ -0,0 +1,226 @@ +package recovery + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +func TestRetry_Success(t *testing.T) { + ctx := context.Background() + attempts := 0 + + err := Retry(ctx, func() error { + attempts++ + return nil + }, nil) + + assert.NoError(t, err) + assert.Equal(t, 1, attempts) +} + +func TestRetry_SuccessAfterRetries(t *testing.T) { + ctx := context.Background() + attempts := 0 + + config := &RetryConfig{ + MaxAttempts: 3, + InitialDelay: 10 * time.Millisecond, + MaxDelay: 100 * time.Millisecond, + Multiplier: 2.0, + } + + err := Retry(ctx, func() error { + attempts++ + if attempts < 3 { + return errors.New("temporary error") + } + return nil + }, config) + + assert.NoError(t, err) + assert.Equal(t, 3, attempts) +} + +func TestRetry_MaxAttemptsReached(t *testing.T) { + ctx := context.Background() + attempts := 0 + + config := &RetryConfig{ + MaxAttempts: 3, + InitialDelay: 10 * time.Millisecond, + MaxDelay: 100 * time.Millisecond, + } + + err := Retry(ctx, func() error { + attempts++ + return errors.New("persistent error") + }, config) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "max attempts") + assert.Equal(t, 3, attempts) +} + +func TestRetry_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + attempts := 0 + + config := &RetryConfig{ + MaxAttempts: 10, + InitialDelay: 50 * time.Millisecond, + } + + // Annuler le contexte après un court délai + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + err := Retry(ctx, func() error { + attempts++ + return errors.New("temporary error") + }, config) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "context cancelled") + assert.True(t, attempts < 10) +} + +func TestRetry_NonRetryableError(t *testing.T) { + ctx := context.Background() + attempts := 0 + + permanentErr := errors.New("permanent error") + config := &RetryConfig{ + MaxAttempts: 3, + RetryableFunc: func(err error) bool { + return err != permanentErr + }, + } + + err := Retry(ctx, func() error { + attempts++ + return permanentErr + }, config) + + assert.Error(t, err) + assert.Equal(t, 1, attempts) // Ne devrait retryer qu'une fois +} + +func TestRetryWithResult_Success(t *testing.T) { + ctx := context.Background() + attempts := 0 + + result, err := RetryWithResult(ctx, func() (int, error) { + attempts++ + return 42, nil + }, nil) + + assert.NoError(t, err) + assert.Equal(t, 42, result) + assert.Equal(t, 1, attempts) +} + +func TestRetryWithResult_Retry(t *testing.T) { + ctx := context.Background() + attempts := 0 + + config := &RetryConfig{ + MaxAttempts: 3, + InitialDelay: 10 * time.Millisecond, + } + + result, err := RetryWithResult(ctx, func() (int, error) { + attempts++ + if attempts < 2 { + return 0, errors.New("temporary error") + } + return 100, nil + }, config) + + assert.NoError(t, err) + assert.Equal(t, 100, result) + assert.Equal(t, 2, attempts) +} + +func TestRetryWithLogger(t *testing.T) { + ctx := context.Background() + logger, _ := zap.NewDevelopment() + attempts := 0 + + config := &RetryConfig{ + MaxAttempts: 3, + InitialDelay: 10 * time.Millisecond, + } + + err := RetryWithLogger(ctx, logger, func() error { + attempts++ + if attempts < 2 { + return errors.New("temporary error") + } + return nil + }, config) + + assert.NoError(t, err) + assert.Equal(t, 2, attempts) +} + +func TestIsRetryableError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + {"timeout", errors.New("timeout"), true}, + {"connection refused", errors.New("connection refused"), true}, + {"server error", errors.New("server error 500"), true}, + {"permanent", errors.New("invalid input"), false}, + {"nil", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsRetryableError(tt.err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCalculateDelay(t *testing.T) { + config := &RetryConfig{ + InitialDelay: 100 * time.Millisecond, + MaxDelay: 1 * time.Second, + Multiplier: 2.0, + Jitter: false, + } + + // Test exponential backoff + delay1 := calculateDelay(1, config) + assert.Equal(t, 100*time.Millisecond, delay1) + + delay2 := calculateDelay(2, config) + assert.Equal(t, 200*time.Millisecond, delay2) + + delay3 := calculateDelay(3, config) + assert.Equal(t, 400*time.Millisecond, delay3) + + // Test max delay + delay10 := calculateDelay(10, config) + assert.LessOrEqual(t, delay10, config.MaxDelay) +} + +func TestDefaultRetryConfig(t *testing.T) { + config := DefaultRetryConfig() + assert.NotNil(t, config) + assert.Equal(t, 3, config.MaxAttempts) + assert.Equal(t, 100*time.Millisecond, config.InitialDelay) + assert.Equal(t, 5*time.Second, config.MaxDelay) + assert.Equal(t, 2.0, config.Multiplier) + assert.True(t, config.Jitter) +} +