-
Notifications
You must be signed in to change notification settings - Fork 58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[SDK-4224] Allow configuration and disabling of retry strategy #216
Changes from all commits
356a023
c0e5141
fe70f99
6196331
d0219a5
615ccf2
3e9cc2e
d317fdf
28a0d10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,13 +2,18 @@ package client | |
|
||
import ( | ||
"context" | ||
"crypto/x509" | ||
"encoding/base64" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"log" | ||
"math" | ||
"math/rand" | ||
"net/http" | ||
"net/http/httputil" | ||
"net/url" | ||
"regexp" | ||
"runtime" | ||
"strconv" | ||
"time" | ||
|
@@ -48,6 +53,28 @@ var DefaultAuth0ClientInfo = &Auth0ClientInfo{ | |
}, | ||
} | ||
|
||
// RetryOptions defines the retry rules that should be followed by the SDK when making requests. | ||
type RetryOptions struct { | ||
MaxRetries int | ||
Statuses []int | ||
} | ||
|
||
// IsEmpty checks whether the provided Auth0ClientInfo data is nil or has no data to allow | ||
// short-circuiting the "Auth0-Client" header configuration. | ||
func (r *RetryOptions) IsEmpty() bool { | ||
if r == nil { | ||
return true | ||
} | ||
return r.MaxRetries == 0 && len(r.Statuses) == 0 | ||
} | ||
|
||
// DefaultRetryOptions is the default retry configuration used by the SDK. | ||
// It will only retry on 429 errors and will retry a request twice. | ||
var DefaultRetryOptions = RetryOptions{ | ||
MaxRetries: 2, | ||
Statuses: []int{http.StatusTooManyRequests}, | ||
} | ||
|
||
// RoundTripFunc is an adapter to allow the use of ordinary functions as HTTP | ||
// round trips. | ||
type RoundTripFunc func(*http.Request) (*http.Response, error) | ||
|
@@ -58,32 +85,110 @@ func (rf RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |
return rf(req) | ||
} | ||
|
||
// RateLimitTransport wraps base transport with rate limiting functionality. | ||
// RetriesTransport wraps base transport with retry functionality. | ||
// | ||
// When a 429 status code is returned by the remote server, the | ||
// "X-RateLimit-Reset" header is used to determine how long the transport will | ||
// wait until re-issuing the failed request. | ||
func RateLimitTransport(base http.RoundTripper) http.RoundTripper { | ||
// This transport will retry in the following circumstances: | ||
// Total retries is less than the configured amount | ||
// AND | ||
// The configuration specifies to retry on the status OR the error. | ||
func RetriesTransport(base http.RoundTripper, r RetryOptions) http.RoundTripper { | ||
if base == nil { | ||
base = http.DefaultTransport | ||
} | ||
return rehttp.NewTransport(base, retry, delay) | ||
|
||
// Configure a retry transport that will retry if: | ||
// Total retries is less than the configured amount | ||
// AND | ||
// The configuration specifies to retry on the status OR the error | ||
tr := rehttp.NewTransport( | ||
base, | ||
rehttp.RetryAll( | ||
rehttp.RetryMaxRetries(r.MaxRetries), | ||
rehttp.RetryAny( | ||
rehttp.RetryStatuses(r.Statuses...), | ||
rehttp.RetryIsErr(retryErrors), | ||
), | ||
), | ||
backoffDelay(), | ||
) | ||
|
||
return tr | ||
} | ||
|
||
func retry(attempt rehttp.Attempt) bool { | ||
if attempt.Response == nil { | ||
// Matches the error returned by net/http when the configured number of redirects | ||
// is exhausted. | ||
var redirectsErrorRe = regexp.MustCompile(`stopped after \d+ redirects\z`) | ||
|
||
// Matches the error returned by net/http when the TLS certificate is not trusted. | ||
// When version 1.20 is the minimum supported this can be replaced by tls.CertificateVerificationError. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that if you're making a new major version it is acceptable to have it support only Go 1.20+. Specially since Go 1.21 is expected for August 2023, a mere 3 months away, which will drop support for 1.19. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I think that's a fair shout, I'll maybe circle back to dropping 1.19 after checking one some of the users of this package have their compile target. |
||
var certVerificationErrorRe = regexp.MustCompile(`certificate is not trusted`) | ||
|
||
// retryErrors checks whether the error returned is a potentially recoverable | ||
// error and returns true if it is. | ||
// By default the errors retried are too many redirects and unknown cert. | ||
func retryErrors(err error) bool { | ||
if err == nil { | ||
return false | ||
} | ||
|
||
// Too many redirects. | ||
if redirectsErrorRe.MatchString(err.Error()) { | ||
return false | ||
} | ||
|
||
// These two checks handle a bad certificate error across our supported versions. | ||
if certVerificationErrorRe.MatchString(err.Error()) { | ||
return false | ||
} | ||
return attempt.Response.StatusCode == http.StatusTooManyRequests | ||
if ok := errors.As(err, &x509.UnknownAuthorityError{}); ok { | ||
return false | ||
} | ||
|
||
// Retry other errors as they are most likely recoverable. | ||
return true | ||
} | ||
|
||
func delay(attempt rehttp.Attempt) time.Duration { | ||
resetAt := attempt.Response.Header.Get("X-RateLimit-Reset") | ||
resetAtUnix, err := strconv.ParseInt(resetAt, 10, 64) | ||
if err != nil { | ||
resetAtUnix = time.Now().Add(5 * time.Second).Unix() | ||
// backoffDelay implements a DelayFn that is an exponential backoff with jitter | ||
// and a minimum value. | ||
func backoffDelay() rehttp.DelayFn { | ||
// Disable gosec lint for as we don't need secure randomness here and the error | ||
// handling of an error adds needless complexity. | ||
//nolint:gosec | ||
PRNG := rand.New(rand.NewSource(time.Now().UnixNano())) | ||
minDelay := float64(250 * time.Millisecond) | ||
maxDelay := float64(10 * time.Second) | ||
baseDelay := float64(250 * time.Millisecond) | ||
|
||
return func(attempt rehttp.Attempt) time.Duration { | ||
wait := baseDelay * math.Pow(2, float64(attempt.Index)) | ||
min := wait + 1 | ||
max := wait + baseDelay | ||
wait = PRNG.Float64()*(max-min) + min | ||
|
||
wait = math.Min(wait, maxDelay) | ||
wait = math.Max(wait, minDelay) | ||
|
||
// If we're calculating the delay for anything other than a 429 status code then return now | ||
if attempt.Response == nil || attempt.Response.StatusCode != http.StatusTooManyRequests { | ||
return time.Duration(wait) | ||
} | ||
|
||
// Check against the rate limit reset value, if that is longer than use that. | ||
resetAtS := attempt.Response.Header.Get("X-RateLimit-Reset") | ||
resetAt, err := strconv.ParseInt(resetAtS, 10, 64) | ||
|
||
if err != nil { | ||
return time.Duration(wait) | ||
} | ||
|
||
// However don't use that rate limit value if it will take us beyond the max wait time. | ||
maxDelayTime := time.Now().Add(time.Duration(maxDelay)).Unix() | ||
if resetAt > maxDelayTime { | ||
return time.Duration(wait) | ||
} | ||
|
||
return time.Duration(resetAt-time.Now().Unix()) * time.Second | ||
} | ||
return time.Duration(resetAtUnix-time.Now().Unix()) * time.Second | ||
} | ||
|
||
// UserAgentTransport wraps base transport with a customized "User-Agent" header. | ||
|
@@ -156,10 +261,12 @@ func WithDebug(debug bool) Option { | |
} | ||
} | ||
|
||
// WithRateLimit configures the client to enable rate limiting. | ||
func WithRateLimit() Option { | ||
// WithRetries configures the retry transports on the http client used. | ||
func WithRetries(r RetryOptions) Option { | ||
return func(c *http.Client) { | ||
c.Transport = RateLimitTransport(c.Transport) | ||
if !r.IsEmpty() { | ||
c.Transport = RetriesTransport(c.Transport, r) | ||
} | ||
} | ||
} | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand it might be a bit late for this, but with this design it would be possible to use both
WithRetries()
andWithNoRetries()
–even though the last used method would prevail. What about simply usingmanagement.WithRetries(RetryOptions{MaxRetries: 0})
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(instead of
WithNoRetries()
)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider as well using factory methods if necessary, to reduce the verbosity and make the intent clearer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, what is the SDK already doing with other similar configuration options?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I was a little undecided on this and initially had an
Enabled
property on theRetryOptions
struct.We currently have
WithNoAuth0ClientInfo
to disable the Auth0Client telemetry so that was part of the reason in going forWithNoRetries
for this APIThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that this being a new major version, it's an opportunity to go with the approach that makes the most sense for all methods involved (including
WithNoAuth0ClientInfo
), specially since that will set the pattern for any future API additions once the major is out. It's alright if you want to useWithNoRetries()
anyway though.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think given that we'd most likely have keep
WithNoAuth0ClientInfo
(i.e. adding anEnabled
flag to that struct isn't as straight forward), then I'd lean towards maintaining the existingWithX
/WithNoX
pattern as it does fit with Go