136 lines
3.3 KiB
Go
136 lines
3.3 KiB
Go
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
|
|
}
|