|
5 | 5 | "bytes" |
6 | 6 | "fmt" |
7 | 7 | "io" |
| 8 | + "log/slog" |
8 | 9 | "net/http" |
9 | 10 | "net/http/httputil" |
10 | 11 | "os" |
@@ -145,3 +146,129 @@ func Save(url string, dir string, filename string) (string, error) { |
145 | 146 |
|
146 | 147 | return path, nil |
147 | 148 | } |
| 149 | + |
| 150 | +// ExponentialBackoffOptions contains options for the exponential backoff retry |
| 151 | +// mechanism. |
| 152 | +type ExponentialBackoffOptions struct { |
| 153 | + client *http.Client |
| 154 | + maxRetries int |
| 155 | + initialBackoff time.Duration |
| 156 | + maxBackoff time.Duration |
| 157 | + backoffMultiplier float64 |
| 158 | + shouldRetry func(resp *http.Response, err error) bool |
| 159 | + logger *slog.Logger |
| 160 | +} |
| 161 | + |
| 162 | +// ExponentialBackoffOption is a function that configures |
| 163 | +// ExponentialBackoffOptions. |
| 164 | +type ExponentialBackoffOption func(*ExponentialBackoffOptions) |
| 165 | + |
| 166 | +// ExponentialBackoffWithClient sets the HTTP client to be used when sending the |
| 167 | +// API requests. By default, http.DefaultClient is used. |
| 168 | +func ExponentialBackoffWithClient(client *http.Client) ExponentialBackoffOption { |
| 169 | + return func(o *ExponentialBackoffOptions) { |
| 170 | + o.client = client |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +// ExponentialBackoffWithConfig sets the configuration for the exponential |
| 175 | +// backoff retry mechanism. By default, it will retry up to 3 times, starting |
| 176 | +// with a 100ms backoff, doubling each time up to a maximum of 5s. |
| 177 | +func ExponentialBackoffWithConfig( |
| 178 | + maxRetries int, |
| 179 | + initialBackoff, maxBackoff time.Duration, |
| 180 | + backoffMultiplier float64, |
| 181 | +) ExponentialBackoffOption { |
| 182 | + return func(o *ExponentialBackoffOptions) { |
| 183 | + o.maxRetries = maxRetries |
| 184 | + o.initialBackoff = initialBackoff |
| 185 | + o.maxBackoff = maxBackoff |
| 186 | + o.backoffMultiplier = backoffMultiplier |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +// ExponentialBackoffWithShouldRetry sets the function to determine whether a |
| 191 | +// request should be retried based on the response and error. By default, it |
| 192 | +// retries on any error, as well as on HTTP 5xx and 429 status codes. |
| 193 | +func ExponentialBackoffWithShouldRetry( |
| 194 | + shouldRetry func(resp *http.Response, err error) bool, |
| 195 | +) ExponentialBackoffOption { |
| 196 | + return func(o *ExponentialBackoffOptions) { |
| 197 | + o.shouldRetry = shouldRetry |
| 198 | + } |
| 199 | +} |
| 200 | + |
| 201 | +// ExponentialBackoffWithLogger sets the logger to be used for logging retry |
| 202 | +// attempts. By default, a no-op logger is used. |
| 203 | +func ExponentialBackoffWithLogger(logger *slog.Logger) ExponentialBackoffOption { |
| 204 | + return func(o *ExponentialBackoffOptions) { |
| 205 | + o.logger = logger |
| 206 | + } |
| 207 | +} |
| 208 | + |
| 209 | +// DoExponentialBackoff will send an API request using exponential backoff until |
| 210 | +// it either succeeds or the maximum number of retries is reached. |
| 211 | +func DoExponentialBackoff(req *http.Request, options ...ExponentialBackoffOption) (*http.Response, error) { |
| 212 | + o := ExponentialBackoffOptions{ |
| 213 | + client: http.DefaultClient, |
| 214 | + maxRetries: 3, |
| 215 | + initialBackoff: 100 * time.Millisecond, |
| 216 | + maxBackoff: 5 * time.Second, |
| 217 | + backoffMultiplier: 2.0, |
| 218 | + shouldRetry: func(resp *http.Response, err error) bool { |
| 219 | + if err != nil { |
| 220 | + return true |
| 221 | + } |
| 222 | + if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { |
| 223 | + return true |
| 224 | + } |
| 225 | + return false |
| 226 | + }, |
| 227 | + logger: slog.New(slog.DiscardHandler), |
| 228 | + } |
| 229 | + for _, option := range options { |
| 230 | + option(&o) |
| 231 | + } |
| 232 | + |
| 233 | + backoff := o.initialBackoff |
| 234 | + |
| 235 | + for attempt := 0; attempt <= o.maxRetries; attempt++ { |
| 236 | + reqClone := req.Clone(req.Context()) |
| 237 | + if req.Body != nil { |
| 238 | + if seeker, ok := req.Body.(interface { |
| 239 | + Seek(int64, int) (int64, error) |
| 240 | + }); ok { |
| 241 | + _, _ = seeker.Seek(0, 0) |
| 242 | + } |
| 243 | + reqClone.Body = req.Body |
| 244 | + } |
| 245 | + |
| 246 | + resp, err := o.client.Do(reqClone) |
| 247 | + if !o.shouldRetry(resp, err) || attempt >= o.maxRetries { |
| 248 | + return resp, nil |
| 249 | + } |
| 250 | + |
| 251 | + logArgs := []any{ |
| 252 | + slog.Int("attempt", attempt+1), |
| 253 | + slog.Duration("backoff", backoff), |
| 254 | + } |
| 255 | + if err != nil { |
| 256 | + logArgs = append(logArgs, slog.String("error", err.Error())) |
| 257 | + } |
| 258 | + if resp != nil { |
| 259 | + if err := resp.Body.Close(); err != nil { |
| 260 | + o.logger.Error("failed to close response body", |
| 261 | + slog.Int("attempt", attempt+1), |
| 262 | + slog.String("error", err.Error()), |
| 263 | + ) |
| 264 | + } |
| 265 | + logArgs = append(logArgs, slog.Int("status_code", resp.StatusCode)) |
| 266 | + } |
| 267 | + |
| 268 | + o.logger.Debug("request failed", logArgs...) |
| 269 | + time.Sleep(backoff) |
| 270 | + backoff = min(time.Duration(float64(backoff)*o.backoffMultiplier), o.maxBackoff) |
| 271 | + } |
| 272 | + |
| 273 | + return nil, fmt.Errorf("request failed after %d attempts", o.maxRetries+1) |
| 274 | +} |
0 commit comments