Initial commit
This commit is contained in:
177
pkg/retry/retry.go
Normal file
177
pkg/retry/retry.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Package retry provides retry logic with exponential backoff for HTTP requests.
|
||||
package retry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config defines the configuration for the retry mechanism.
|
||||
type Config struct {
|
||||
// MaxAttempts is the maximum number of retry attempts (including the first try).
|
||||
// Default: 3
|
||||
MaxAttempts int
|
||||
|
||||
// InitialDelay is the initial delay between retries.
|
||||
// Default: 1 second
|
||||
InitialDelay time.Duration
|
||||
|
||||
// MaxDelay is the maximum delay between retries.
|
||||
// Default: 30 seconds
|
||||
MaxDelay time.Duration
|
||||
|
||||
// BackoffFactor is the multiplier for exponential backoff.
|
||||
// Default: 2.0 (exponential)
|
||||
BackoffFactor float64
|
||||
|
||||
// RetryableErrors are HTTP status codes that should trigger a retry.
|
||||
// Default: 429, 500, 502, 503, 504
|
||||
RetryableErrors []int
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default retry configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
RetryableErrors: []int{429, 500, 502, 503, 504},
|
||||
}
|
||||
}
|
||||
|
||||
// Retrier handles retry logic for HTTP requests.
|
||||
type Retrier struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
// New creates a new Retrier with the given configuration.
|
||||
func New(config Config) *Retrier {
|
||||
// Apply defaults if not set
|
||||
if config.MaxAttempts == 0 {
|
||||
config.MaxAttempts = DefaultConfig().MaxAttempts
|
||||
}
|
||||
if config.InitialDelay == 0 {
|
||||
config.InitialDelay = DefaultConfig().InitialDelay
|
||||
}
|
||||
if config.MaxDelay == 0 {
|
||||
config.MaxDelay = DefaultConfig().MaxDelay
|
||||
}
|
||||
if config.BackoffFactor == 0 {
|
||||
config.BackoffFactor = DefaultConfig().BackoffFactor
|
||||
}
|
||||
if len(config.RetryableErrors) == 0 {
|
||||
config.RetryableErrors = DefaultConfig().RetryableErrors
|
||||
}
|
||||
|
||||
return &Retrier{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Do executes the given function with retry logic.
|
||||
// It returns the response and any error encountered.
|
||||
func (r *Retrier) Do(ctx context.Context, fn func() (*http.Response, error)) (*http.Response, error) {
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
for attempt := 1; attempt <= r.config.MaxAttempts; attempt++ {
|
||||
// Check if context is cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Execute the function
|
||||
resp, err = fn()
|
||||
|
||||
// If no error and response is not retryable, return success
|
||||
if err == nil && !r.isRetryable(resp) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// If this was the last attempt, return the error
|
||||
if attempt == r.config.MaxAttempts {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff and jitter
|
||||
delay := r.calculateDelay(attempt, resp)
|
||||
|
||||
// Wait before retrying
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
// Continue to next attempt
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// isRetryable checks if the HTTP response indicates a retryable error.
|
||||
func (r *Retrier) isRetryable(resp *http.Response) bool {
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, code := range r.config.RetryableErrors {
|
||||
if resp.StatusCode == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// calculateDelay calculates the delay before the next retry attempt.
|
||||
// It implements exponential backoff with jitter and respects Retry-After header.
|
||||
func (r *Retrier) calculateDelay(attempt int, resp *http.Response) time.Duration {
|
||||
// Check for Retry-After header (for 429 rate limiting)
|
||||
if resp != nil && resp.StatusCode == 429 {
|
||||
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
|
||||
// Try parsing as seconds
|
||||
if seconds, err := strconv.ParseInt(retryAfter, 10, 64); err == nil {
|
||||
delay := time.Duration(seconds) * time.Second
|
||||
if delay > r.config.MaxDelay {
|
||||
delay = r.config.MaxDelay
|
||||
}
|
||||
return delay
|
||||
}
|
||||
// Try parsing as HTTP date
|
||||
if t, err := http.ParseTime(retryAfter); err == nil {
|
||||
delay := time.Until(t)
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
if delay > r.config.MaxDelay {
|
||||
delay = r.config.MaxDelay
|
||||
}
|
||||
return delay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate exponential backoff
|
||||
backoff := float64(r.config.InitialDelay) * math.Pow(r.config.BackoffFactor, float64(attempt-1))
|
||||
|
||||
// Apply maximum delay cap
|
||||
if backoff > float64(r.config.MaxDelay) {
|
||||
backoff = float64(r.config.MaxDelay)
|
||||
}
|
||||
|
||||
// Add jitter (random factor between 0.5 and 1.0)
|
||||
jitter := 0.5 + rand.Float64()*0.5
|
||||
delay := time.Duration(backoff * jitter)
|
||||
|
||||
return delay
|
||||
}
|
||||
219
pkg/retry/retry_test.go
Normal file
219
pkg/retry/retry_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package retry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
want Config
|
||||
}{
|
||||
{
|
||||
name: "default config",
|
||||
config: Config{},
|
||||
want: DefaultConfig(),
|
||||
},
|
||||
{
|
||||
name: "custom config",
|
||||
config: Config{
|
||||
MaxAttempts: 5,
|
||||
InitialDelay: 2 * time.Second,
|
||||
MaxDelay: 60 * time.Second,
|
||||
BackoffFactor: 3.0,
|
||||
RetryableErrors: []int{500},
|
||||
},
|
||||
want: Config{
|
||||
MaxAttempts: 5,
|
||||
InitialDelay: 2 * time.Second,
|
||||
MaxDelay: 60 * time.Second,
|
||||
BackoffFactor: 3.0,
|
||||
RetryableErrors: []int{500},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := New(tt.config)
|
||||
if r.config.MaxAttempts != tt.want.MaxAttempts {
|
||||
t.Errorf("MaxAttempts = %v, want %v", r.config.MaxAttempts, tt.want.MaxAttempts)
|
||||
}
|
||||
if r.config.InitialDelay != tt.want.InitialDelay {
|
||||
t.Errorf("InitialDelay = %v, want %v", r.config.InitialDelay, tt.want.InitialDelay)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrier_isRetryable(t *testing.T) {
|
||||
r := New(DefaultConfig())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
want bool
|
||||
}{
|
||||
{"429 rate limit", 429, true},
|
||||
{"500 server error", 500, true},
|
||||
{"502 bad gateway", 502, true},
|
||||
{"503 service unavailable", 503, true},
|
||||
{"504 gateway timeout", 504, true},
|
||||
{"200 ok", 200, false},
|
||||
{"400 bad request", 400, false},
|
||||
{"404 not found", 404, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp := &http.Response{StatusCode: tt.statusCode}
|
||||
if got := r.isRetryable(resp); got != tt.want {
|
||||
t.Errorf("isRetryable() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrier_Do_Success(t *testing.T) {
|
||||
r := New(DefaultConfig())
|
||||
ctx := context.Background()
|
||||
|
||||
attempts := 0
|
||||
fn := func() (*http.Response, error) {
|
||||
attempts++
|
||||
return &http.Response{StatusCode: 200}, nil
|
||||
}
|
||||
|
||||
resp, err := r.Do(ctx, fn)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("attempts = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrier_Do_Retry(t *testing.T) {
|
||||
config := Config{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 10 * time.Millisecond,
|
||||
MaxDelay: 100 * time.Millisecond,
|
||||
BackoffFactor: 2.0,
|
||||
RetryableErrors: []int{500},
|
||||
}
|
||||
r := New(config)
|
||||
ctx := context.Background()
|
||||
|
||||
attempts := 0
|
||||
fn := func() (*http.Response, error) {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return &http.Response{StatusCode: 500}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200}, nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := r.Do(ctx, fn)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Errorf("attempts = %d, want 3", attempts)
|
||||
}
|
||||
// Should have waited at least once
|
||||
if elapsed < 10*time.Millisecond {
|
||||
t.Errorf("elapsed time %v too short, expected at least 10ms", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrier_Do_ContextCancellation(t *testing.T) {
|
||||
config := Config{
|
||||
MaxAttempts: 5,
|
||||
InitialDelay: 100 * time.Millisecond,
|
||||
MaxDelay: 1 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
RetryableErrors: []int{500},
|
||||
}
|
||||
r := New(config)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
attempts := 0
|
||||
fn := func() (*http.Response, error) {
|
||||
attempts++
|
||||
return &http.Response{StatusCode: 500}, nil
|
||||
}
|
||||
|
||||
_, err := r.Do(ctx, fn)
|
||||
if err != context.DeadlineExceeded {
|
||||
t.Errorf("error = %v, want context.DeadlineExceeded", err)
|
||||
}
|
||||
if attempts > 2 {
|
||||
t.Errorf("attempts = %d, should be cancelled early", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrier_calculateDelay(t *testing.T) {
|
||||
config := Config{
|
||||
InitialDelay: time.Second,
|
||||
MaxDelay: 30 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
r := New(config)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
attempt int
|
||||
resp *http.Response
|
||||
minWant time.Duration
|
||||
maxWant time.Duration
|
||||
}{
|
||||
{
|
||||
name: "first retry",
|
||||
attempt: 1,
|
||||
resp: &http.Response{StatusCode: 500},
|
||||
minWant: 500 * time.Millisecond, // with jitter min 0.5
|
||||
maxWant: 1 * time.Second, // with jitter max 1.0
|
||||
},
|
||||
{
|
||||
name: "second retry",
|
||||
attempt: 2,
|
||||
resp: &http.Response{StatusCode: 500},
|
||||
minWant: 1 * time.Second, // 2^1 * 1s * 0.5
|
||||
maxWant: 2 * time.Second, // 2^1 * 1s * 1.0
|
||||
},
|
||||
{
|
||||
name: "with Retry-After header",
|
||||
attempt: 1,
|
||||
resp: &http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"Retry-After": []string{"5"}},
|
||||
},
|
||||
minWant: 5 * time.Second,
|
||||
maxWant: 5 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
delay := r.calculateDelay(tt.attempt, tt.resp)
|
||||
if delay < tt.minWant || delay > tt.maxWant {
|
||||
t.Errorf("delay = %v, want between %v and %v", delay, tt.minWant, tt.maxWant)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user