veza/veza-backend-api/internal/recovery/retry.go

277 lines
6.7 KiB
Go

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é avant d'exécuter
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 le contexte est annulé après l'exécution
select {
case <-ctx.Done():
return fmt.Errorf("context cancelled: %w", ctx.Err())
default:
}
// 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
}