internal/retry/http.go (94 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.
package retry
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"time"
"github.com/hashicorp/go-retryablehttp"
)
const (
defaultRetryWaitMin = 1 * time.Second
defaultRetryWaitMax = 5 * time.Second
)
type HTTPOptions struct {
RetryMax int
retryWaitMin time.Duration
retryWaitMax time.Duration
}
func WrapHTTPClient(client *http.Client, opts HTTPOptions) *http.Client {
if opts.RetryMax <= 0 {
return client
}
retryWaitMin := opts.retryWaitMin
if retryWaitMin == 0 {
retryWaitMin = defaultRetryWaitMin
}
retryWaitMax := opts.retryWaitMax
if retryWaitMax == 0 {
retryWaitMax = defaultRetryWaitMax
}
if client == nil {
client = &http.Client{}
}
if client.CheckRedirect == nil {
client.CheckRedirect = checkRedirect
}
retryClient := retryablehttp.NewClient()
retryClient.HTTPClient = client
retryClient.CheckRetry = checkRetry
retryClient.ErrorHandler = retryablehttp.PassthroughErrorHandler
retryClient.RetryMax = opts.RetryMax
retryClient.RetryWaitMin = retryWaitMin
retryClient.RetryWaitMax = retryWaitMax
// It needs to be a logger with support for attributes as key-value pairs.
retryClient.Logger = slog.Default()
return retryClient.StandardClient()
}
var (
maxRedirects = 10
errTooManyRedirects = fmt.Errorf("stopped after %d redirects", maxRedirects)
)
// checkRedirect reimplements default http redirect policy but returning a typed error.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return errTooManyRedirects
}
return nil
}
// checkRetry reimplements retryablehttp.DefaultRetryPolicy with better error checking.
func checkRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
if ctx.Err() != nil {
return false, ctx.Err()
}
if err != nil {
if errors.Is(err, errTooManyRedirects) {
// Too many redirects, let's stop here.
return false, nil
}
var urlError *url.Error
if errors.As(err, &urlError) {
// URL is invalid, not recoverable.
return false, nil
}
var certVerificationError *tls.CertificateVerificationError
if errors.As(err, &certVerificationError) {
// Something failed while verifying certificates.
return false, nil
}
var certError *x509.CertificateInvalidError
if errors.As(err, &certError) {
// Invalid certificate, not recoverable.
return false, nil
}
var caError x509.UnknownAuthorityError
if errors.As(err, &caError) {
// Unknown CA, not recoverable.
return false, nil
}
// Consider other errors as recoverable and retry.
return true, nil
}
// 429 Too Many Requests is recoverable. Sometimes the server puts
// a Retry-After response header to indicate when the server is
// available to start processing request from client.
if resp.StatusCode == http.StatusTooManyRequests {
return true, nil
}
// Check the response code. We retry on 500-range responses to allow
// the server time to recover, as 500's are typically not permanent
// errors and may relate to outages on the server side. This will catch
// invalid response codes as well, like 0 and 999.
if resp.StatusCode == 0 || (resp.StatusCode >= 500 && resp.StatusCode != http.StatusNotImplemented) {
// Return the underlying error, that will probably be nil.
// retryablehttp.DefaultRetryPolicy did generate an error for these cases,
// but this is not what the default HTTP client does.
return true, err
}
return false, nil
}