package resilience import ( "context" "math/rand" "net/http" "time" ) // RetryConfig holds retry settings. type RetryConfig struct { // MaxAttempts is the maximum number of attempts (includes initial). MaxAttempts int // InitialBackoff is the initial backoff duration. InitialBackoff time.Duration // MaxBackoff is the maximum backoff duration. MaxBackoff time.Duration // BackoffMultiplier is the exponential backoff multiplier. BackoffMultiplier float64 } // DefaultRetryConfig provides sensible defaults. func DefaultRetryConfig() *RetryConfig { return &RetryConfig{ MaxAttempts: 3, InitialBackoff: 100 * time.Millisecond, MaxBackoff: 2 * time.Second, BackoffMultiplier: 2.0, } } // RetryFunc executes a function with blind retry on 5xx. // Returns the response and any error from the function itself (not retry logic). type RetryFunc func(ctx context.Context, attempt int) (*http.Response, error) // DoRetry executes the function with exponential backoff on 5xx responses. // Returns the final response (could be 5xx if all retries exhausted) and any error. func DoRetry(ctx context.Context, cfg *RetryConfig, fn RetryFunc) (*http.Response, error) { if cfg == nil { cfg = DefaultRetryConfig() } var lastResp *http.Response var lastErr error for attempt := 0; attempt < cfg.MaxAttempts; attempt++ { // Check context before attempting select { case <-ctx.Done(): if lastResp != nil { lastResp.Body.Close() } return nil, ctx.Err() default: } resp, err := fn(ctx, attempt) if err != nil { lastErr = err // Don't retry on network errors in the retry loop itself // Let caller decide if those should be retried return nil, err } // Success (not 5xx) if resp.StatusCode < 500 { return resp, nil } // 5xx — close and retry if lastResp != nil { lastResp.Body.Close() } lastResp = resp // If this was the last attempt, return the 5xx response if attempt == cfg.MaxAttempts-1 { return resp, nil } // Calculate backoff with jitter backoff := calculateBackoff(attempt, cfg) select { case <-ctx.Done(): resp.Body.Close() return nil, ctx.Err() case <-time.After(backoff): // Continue to next attempt } } return lastResp, lastErr } // calculateBackoff computes exponential backoff with jitter. func calculateBackoff(attempt int, cfg *RetryConfig) time.Duration { // Exponential: initial * (multiplier ^ attempt) backoff := time.Duration(float64(cfg.InitialBackoff) * (pow(cfg.BackoffMultiplier, float64(attempt)))) // Cap at max if backoff > cfg.MaxBackoff { backoff = cfg.MaxBackoff } // Add jitter: ±20% jitterRange := backoff / 5 if jitterRange <= 0 { return backoff } jitter := time.Duration(rand.Int63n(int64(2 * jitterRange)) - int64(jitterRange)) return backoff + jitter } func pow(base, exp float64) float64 { result := 1.0 for i := 0; i < int(exp); i++ { result *= base } return result } // RetryPolicy determines whether to retry based on response and config. type RetryPolicy struct { Retryable bool // Whether this adapter allows retries } // ShouldRetry determines if a response should be retried. func (p *RetryPolicy) ShouldRetry(resp *http.Response) bool { if !p.Retryable { return false } return resp != nil && resp.StatusCode >= 500 }