forked from charmbracelet/fantasy
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathretry.go
More file actions
137 lines (112 loc) · 4.19 KB
/
retry.go
File metadata and controls
137 lines (112 loc) · 4.19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
package fantasy
import (
"context"
"errors"
"strconv"
"time"
)
// RetryFn is a function that returns a value and an error.
type RetryFn[T any] func() (T, error)
// RetryFunction is a function that retries another function.
type RetryFunction[T any] func(ctx context.Context, fn RetryFn[T]) (T, error)
// getRetryDelayInMs calculates the retry delay based on error headers and exponential backoff.
func getRetryDelayInMs(err error, exponentialBackoffDelay time.Duration) time.Duration {
var providerErr *ProviderError
if !errors.As(err, &providerErr) || providerErr.ResponseHeaders == nil {
return exponentialBackoffDelay
}
headers := providerErr.ResponseHeaders
var ms time.Duration
// retry-ms is more precise than retry-after and used by e.g. OpenAI
if retryAfterMs, exists := headers["retry-after-ms"]; exists {
if timeoutMs, err := strconv.ParseFloat(retryAfterMs, 64); err == nil {
ms = time.Duration(timeoutMs) * time.Millisecond
}
}
// About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
if retryAfter, exists := headers["retry-after"]; exists && ms == 0 {
if timeoutSeconds, err := strconv.ParseFloat(retryAfter, 64); err == nil {
ms = time.Duration(timeoutSeconds) * time.Second
} else {
// Try parsing as HTTP date
if t, err := time.Parse(time.RFC1123, retryAfter); err == nil {
ms = time.Until(t)
}
}
}
// Check that the delay is reasonable:
// 0 <= ms < 60 seconds or ms < exponentialBackoffDelay
if ms > 0 && (ms < 60*time.Second || ms < exponentialBackoffDelay) {
return ms
}
return exponentialBackoffDelay
}
// isAbortError checks if the error is a context cancellation error.
func isAbortError(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
// RetryWithExponentialBackoffRespectingRetryHeaders creates a retry function that retries
// a failed operation with exponential backoff, while respecting rate limit headers
// (retry-after-ms and retry-after) if they are provided and reasonable (0-60 seconds).
func RetryWithExponentialBackoffRespectingRetryHeaders[T any](options RetryOptions) RetryFunction[T] {
return func(ctx context.Context, fn RetryFn[T]) (T, error) {
return retryWithExponentialBackoff(ctx, fn, options, nil)
}
}
// RetryOptions configures the retry behavior.
type RetryOptions struct {
MaxRetries int
InitialDelayIn time.Duration
BackoffFactor float64
OnRetry OnRetryCallback
}
// OnRetryCallback defines a function that is called when a retry occurs.
type OnRetryCallback = func(err *ProviderError, delay time.Duration)
// DefaultRetryOptions returns the default retry options.
// DefaultRetryOptions returns the default retry options.
func DefaultRetryOptions() RetryOptions {
return RetryOptions{
MaxRetries: 2,
InitialDelayIn: 2000 * time.Millisecond,
BackoffFactor: 2.0,
}
}
// retryWithExponentialBackoff implements the retry logic with exponential backoff.
func retryWithExponentialBackoff[T any](ctx context.Context, fn RetryFn[T], options RetryOptions, allErrors []error) (T, error) {
var zero T
result, err := fn()
if err == nil {
return result, nil
}
if isAbortError(err) {
return zero, err // don't retry when the request was aborted
}
if options.MaxRetries == 0 {
return zero, err // don't wrap the error when retries are disabled
}
newErrors := append(allErrors, err)
tryNumber := len(newErrors)
if tryNumber > options.MaxRetries {
return zero, &RetryError{newErrors}
}
var providerErr *ProviderError
if errors.As(err, &providerErr) && providerErr.IsRetryable() && tryNumber <= options.MaxRetries {
delay := getRetryDelayInMs(err, options.InitialDelayIn)
if options.OnRetry != nil {
options.OnRetry(providerErr, delay)
}
select {
case <-time.After(delay):
// Continue with retry
case <-ctx.Done():
return zero, ctx.Err()
}
newOptions := options
newOptions.InitialDelayIn = time.Duration(float64(options.InitialDelayIn) * options.BackoffFactor)
return retryWithExponentialBackoff(ctx, fn, newOptions, newErrors)
}
if tryNumber == 1 {
return zero, err // don't wrap the error when a non-retryable error occurs on the first try
}
return zero, &RetryError{newErrors}
}