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