retries.go raw

   1  package session
   2  
   3  import (
   4  	"context"
   5  	"errors"
   6  	"fmt"
   7  	"net/http"
   8  	"net/url"
   9  	"path"
  10  	"strings"
  11  	"time"
  12  
  13  	"github.com/akamai/AkamaiOPEN-edgegrid-golang/v11/pkg/log"
  14  	"github.com/hashicorp/go-retryablehttp"
  15  )
  16  
  17  // RetryConfig struct contains http retry configuration.
  18  //
  19  // ExcludedEndpoints field is a list of  shell patterns.
  20  // The pattern syntax is:
  21  //
  22  //	pattern:
  23  //		{ term }
  24  //	term:
  25  //		'*'         matches any sequence of non-/ characters
  26  //		'?'         matches any single non-/ character
  27  //		'[' [ '^' ] { character-range } ']'
  28  //		            character class (must be non-empty)
  29  //		c           matches character c (c != '*', '?', '\\', '[')
  30  //		'\\' c      matches character c
  31  //
  32  //	character-range:
  33  //		c           matches character c (c != '\\', '-', ']')
  34  //		'\\' c      matches character c
  35  //		lo '-' hi   matches character c for lo <= c <= hi
  36  type RetryConfig struct {
  37  	RetryMax          int
  38  	RetryWaitMin      time.Duration
  39  	RetryWaitMax      time.Duration
  40  	ExcludedEndpoints []string
  41  }
  42  
  43  // NewRetryConfig creates a new retry config with default settings.
  44  func NewRetryConfig() RetryConfig {
  45  	return RetryConfig{
  46  		RetryMax:          10,
  47  		RetryWaitMin:      1 * time.Second,
  48  		RetryWaitMax:      30 * time.Second,
  49  		ExcludedEndpoints: []string{},
  50  	}
  51  }
  52  
  53  func configureRetryClient(conf RetryConfig, signFunc func(r *http.Request) error, log log.Interface) (*retryablehttp.Client, error) {
  54  	retryClient := retryablehttp.NewClient()
  55  
  56  	err := validateRetryConf(conf)
  57  	if err != nil {
  58  		return nil, err
  59  	}
  60  	retryClient.RetryMax = conf.RetryMax
  61  	retryClient.RetryWaitMin = conf.RetryWaitMin
  62  	retryClient.RetryWaitMax = conf.RetryWaitMax
  63  
  64  	retryClient.PrepareRetry = signFunc
  65  	retryClient.HTTPClient.CheckRedirect = func(r *http.Request, _ []*http.Request) error {
  66  		return signFunc(r)
  67  	}
  68  	retryClient.CheckRetry = overrideRetryPolicy(retryablehttp.DefaultRetryPolicy, conf.ExcludedEndpoints)
  69  	retryClient.Backoff = overrideBackoff(retryablehttp.DefaultBackoff, log)
  70  	retryClient.Logger = GetRetryableLogger(log)
  71  
  72  	return retryClient, err
  73  }
  74  
  75  func validateRetryConf(conf RetryConfig) error {
  76  	errs := []error{}
  77  
  78  	if conf.RetryMax < 0 {
  79  		errs = append(errs, errors.New("maximum number of retries cannot be negative"))
  80  	}
  81  	if conf.RetryWaitMin < 0 {
  82  		errs = append(errs, errors.New("minimum retry wait time cannot be negative"))
  83  	}
  84  	if conf.RetryWaitMax < 0 {
  85  		errs = append(errs, errors.New("maximum retry wait time cannot be negative"))
  86  	}
  87  	if conf.RetryWaitMax < conf.RetryWaitMin {
  88  		errs = append(errs, errors.New("maximum retry wait time cannot be shorter than minimum retry wait time"))
  89  	}
  90  	for _, pattern := range conf.ExcludedEndpoints {
  91  		if _, err := path.Match(pattern, ""); err != nil {
  92  			errs = append(errs, fmt.Errorf("malformed exclude endpoint pattern: %v: %s", err, pattern))
  93  		}
  94  	}
  95  	if len(errs) > 0 {
  96  		return errors.Join(errs...)
  97  	}
  98  	return nil
  99  }
 100  
 101  func overrideRetryPolicy(basePolicy retryablehttp.CheckRetry, excludedEndpoints []string) retryablehttp.CheckRetry {
 102  	return func(ctx context.Context, resp *http.Response, err error) (bool, error) {
 103  		// do not retry on context.Canceled or context.DeadlineExceeded
 104  		if ctx.Err() != nil {
 105  			return false, ctx.Err()
 106  		}
 107  
 108  		if resp == nil || resp.Request.Method != http.MethodGet ||
 109  			(resp.Request.URL != nil && isBlocked(resp.Request.URL.Path, excludedEndpoints)) {
 110  			var urlErr *url.Error
 111  			if resp == nil && errors.As(err, &urlErr) && strings.ToUpper(urlErr.Op) == http.MethodGet {
 112  				return basePolicy(ctx, resp, err)
 113  			}
 114  			return false, err
 115  		}
 116  
 117  		// Retry all PAPI GET requests resulting status code 429
 118  		// The backoff time is calculated in getXRateLimitBackoff
 119  		is429 := resp.StatusCode == http.StatusTooManyRequests
 120  		if is429 && (resp.Request.URL != nil && strings.HasPrefix(resp.Request.URL.Path, "/papi/")) {
 121  			return true, nil
 122  		}
 123  		if resp.StatusCode == http.StatusConflict {
 124  			return true, nil
 125  		}
 126  		return basePolicy(ctx, resp, err)
 127  	}
 128  }
 129  
 130  func overrideBackoff(baseBackoff retryablehttp.Backoff, logger log.Interface) retryablehttp.Backoff {
 131  	return func(minT, maxT time.Duration, attemptNum int, resp *http.Response) time.Duration {
 132  		if resp != nil {
 133  			if resp.StatusCode == http.StatusTooManyRequests {
 134  				if wait, ok := getXRateLimitBackoff(resp, logger); ok {
 135  					return wait
 136  				}
 137  			}
 138  		}
 139  		return baseBackoff(minT, maxT, attemptNum, resp)
 140  	}
 141  }
 142  
 143  // Note that Date's resolution is seconds (e.g. Mon, 01 Jul 2024 14:32:14 GMT),
 144  // while X-RateLimit-Next's resolution is milliseconds (2024-07-01T14:32:28.645Z).
 145  // This may cause the wait time to be inflated by at most one second, like for the
 146  // actual server response time around 2024-07-01T14:32:14.999Z. This is acceptable behavior
 147  // as retry does not occur earlier than expected.
 148  func getXRateLimitBackoff(resp *http.Response, logger log.Interface) (time.Duration, bool) {
 149  	nextHeader := resp.Header.Get("X-RateLimit-Next")
 150  	if nextHeader == "" {
 151  		return 0, false
 152  	}
 153  	next, err := time.Parse(time.RFC3339Nano, nextHeader)
 154  	if err != nil {
 155  		if logger != nil {
 156  			logger.Error("Could not parse X-RateLimit-Next header", "error", err)
 157  		}
 158  		return 0, false
 159  	}
 160  
 161  	dateHeader := resp.Header.Get("Date")
 162  	if dateHeader == "" {
 163  		if logger != nil {
 164  			logger.Warnf("No Date header for X-RateLimit-Next: %s", nextHeader)
 165  		}
 166  		return 0, false
 167  	}
 168  	date, err := time.Parse(time.RFC1123, dateHeader)
 169  	if err != nil {
 170  		if logger != nil {
 171  			logger.Error("Could not parse Date header", "error", err)
 172  		}
 173  		return 0, false
 174  	}
 175  
 176  	// Next in the past does not make sense
 177  	if next.Before(date) {
 178  		if logger != nil {
 179  			logger.Warnf("X-RateLimit-Next: %s before Date: %s", nextHeader, dateHeader)
 180  		}
 181  		return 0, false
 182  	}
 183  	return next.Sub(date), true
 184  }
 185  
 186  func isBlocked(url string, disabledPatterns []string) bool {
 187  	for _, pattern := range disabledPatterns {
 188  		match, err := path.Match(pattern, url)
 189  		if err != nil {
 190  			return false
 191  		}
 192  		if match {
 193  			return true
 194  		}
 195  	}
 196  	return false
 197  }
 198