Files
homelab-frontend/internal/resilience/retry.go
T

136 lines
3.3 KiB
Go
Raw Normal View History

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
}