mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-18 03:30:53 +00:00
196 lines
4.6 KiB
Go
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
|
|
}
|