Initial commit

This commit is contained in:
Gregor Schulte
2026-02-02 08:41:48 +01:00
commit 43b4910a63
30 changed files with 4898 additions and 0 deletions

177
pkg/retry/retry.go Normal file
View 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
View 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)
}
})
}
}