Files
goclaw/internal/providers/retry.go
T

196 lines
4.6 KiB
Go

package providers
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"math/rand"
"net"
"strconv"
"strings"
"time"
)
// RetryConfig configures retry behavior for provider requests.
type RetryConfig struct {
Attempts int // max attempts (default 3, 1 = no retry)
MinDelay time.Duration // initial delay (default 300ms)
MaxDelay time.Duration // delay cap (default 30s)
Jitter float64 // jitter factor ±N (default 0.1 = ±10%)
}
// RetryHookFunc is called before each retry attempt.
// attempt is the failed attempt number (1-based), maxAttempts is the total.
type RetryHookFunc func(attempt, maxAttempts int, err error)
type retryHookKey struct{}
// WithRetryHook injects a retry notification callback into the context.
// RetryDo will call this hook before each retry attempt.
func WithRetryHook(ctx context.Context, fn RetryHookFunc) context.Context {
return context.WithValue(ctx, retryHookKey{}, fn)
}
// retryHookFromContext returns the retry hook from context, or nil.
func retryHookFromContext(ctx context.Context) RetryHookFunc {
fn, _ := ctx.Value(retryHookKey{}).(RetryHookFunc)
return fn
}
// DefaultRetryConfig returns sensible defaults matching TS provider retry behavior.
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
Attempts: 3,
MinDelay: 300 * time.Millisecond,
MaxDelay: 30 * time.Second,
Jitter: 0.1,
}
}
// HTTPError represents an HTTP error with status code and optional Retry-After.
type HTTPError struct {
Status int
Body string
RetryAfter time.Duration // parsed from Retry-After header (0 if absent)
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
}
// IsRetryableError checks if an error is retryable.
// Retryable: 429 (rate limit), 500, 502, 503, 504, connection errors, timeouts.
func IsRetryableError(err error) bool {
if err == nil {
return false
}
// Check for HTTPError
var httpErr *HTTPError
if errors.As(err, &httpErr) {
switch httpErr.Status {
case 429, 500, 502, 503, 504:
return true
}
return false
}
// Check for network errors
var netErr net.Error
if errors.As(err, &netErr) {
return true // includes timeouts
}
// Check for connection reset / broken pipe / EOF in error string
errStr := err.Error()
if strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "broken pipe") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "timeout") {
return true
}
return false
}
// RetryDo executes fn with retry logic using exponential backoff and jitter.
func RetryDo[T any](ctx context.Context, cfg RetryConfig, fn func() (T, error)) (T, error) {
if cfg.Attempts <= 0 {
cfg.Attempts = 1
}
var lastErr error
var zero T
for attempt := 1; attempt <= cfg.Attempts; attempt++ {
result, err := fn()
if err == nil {
return result, nil
}
lastErr = err
// Don't retry if not retryable or last attempt
if !IsRetryableError(err) || attempt == cfg.Attempts {
return zero, err
}
// Compute delay
delay := computeDelay(cfg, attempt, err)
slog.Debug("provider retry",
"attempt", attempt,
"maxAttempts", cfg.Attempts,
"delay", delay,
"error", err.Error(),
)
// Notify retry hook (for placeholder updates, etc.)
if hook := retryHookFromContext(ctx); hook != nil {
hook(attempt, cfg.Attempts, err)
}
// Wait with context cancellation support
select {
case <-ctx.Done():
return zero, ctx.Err()
case <-time.After(delay):
}
}
return zero, lastErr
}
// computeDelay calculates the retry delay with exponential backoff, jitter, and Retry-After support.
func computeDelay(cfg RetryConfig, attempt int, err error) time.Duration {
// Check for Retry-After header
var httpErr *HTTPError
if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 {
return httpErr.RetryAfter
}
// Exponential backoff: minDelay * 2^(attempt-1)
delay := float64(cfg.MinDelay) * math.Pow(2, float64(attempt-1))
// Cap at maxDelay
if time.Duration(delay) > cfg.MaxDelay {
delay = float64(cfg.MaxDelay)
}
// Apply jitter: ±jitter%
if cfg.Jitter > 0 {
jitterRange := delay * cfg.Jitter
delay += (rand.Float64()*2 - 1) * jitterRange
}
if delay < 0 {
delay = float64(cfg.MinDelay)
}
return time.Duration(delay)
}
// ParseRetryAfter parses a Retry-After header value (seconds or HTTP-date).
func ParseRetryAfter(value string) time.Duration {
if value == "" {
return 0
}
// Try integer seconds first
if seconds, err := strconv.Atoi(value); err == nil {
return time.Duration(seconds) * time.Second
}
// Try HTTP-date format
if t, err := time.Parse(time.RFC1123, value); err == nil {
d := time.Until(t)
if d > 0 {
return d
}
}
return 0
}