277 lines
6.7 KiB
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
|
|
}
|