Skip to content

Commit

Permalink
feat!: redesign request retry flow, retry-after header, default retry…
Browse files Browse the repository at this point in the history
… conditions #886
  • Loading branch information
jeevatkm committed Oct 27, 2024
1 parent 6bb3acb commit 78ccd46
Show file tree
Hide file tree
Showing 13 changed files with 777 additions and 762 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,8 @@ client.RemoveProxy()
Resty uses [backoff](http://www.awsarchitectureblog.com/2015/03/backoff.html)
to increase retry intervals after each attempt.

TODO update retry docs

Usage example:

```go
Expand Down
136 changes: 60 additions & 76 deletions client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2015-2024 Jeevanandam M ([email protected]), All rights reserved.
// Copyright (c) 2015-present Jeevanandam M ([email protected]), All rights reserved.
// resty source code and usage is governed by a MIT style
// license that can be found in the LICENSE file.
// SPDX-License-Identifier: MIT

package resty

Expand Down Expand Up @@ -63,9 +64,9 @@ var (
hdrContentLengthKey = http.CanonicalHeaderKey("Content-Length")
hdrContentEncodingKey = http.CanonicalHeaderKey("Content-Encoding")
hdrContentDisposition = http.CanonicalHeaderKey("Content-Disposition")
hdrLocationKey = http.CanonicalHeaderKey("Location")
hdrAuthorizationKey = http.CanonicalHeaderKey("Authorization")
hdrWwwAuthenticateKey = http.CanonicalHeaderKey("WWW-Authenticate")
hdrRetryAfterKey = http.CanonicalHeaderKey("Retry-After")

plainTextType = "text/plain; charset=utf-8"
jsonContentType = "application/json"
Expand Down Expand Up @@ -178,9 +179,9 @@ type Client struct {
retryWaitTime time.Duration
retryMaxWaitTime time.Duration
retryConditions []RetryConditionFunc
retryHooks []OnRetryFunc
retryAfter RetryAfterFunc
retryResetReaders bool
retryHooks []RetryHookFunc
retryStrategy RetryStrategyFunc
isRetryDefaultConditions bool
headerAuthorizationKey string
responseBodyLimit int64
resBodyUnlimitedReads bool
Expand All @@ -198,7 +199,6 @@ type Client struct {
proxyURL *url.URL
requestLog RequestLogCallback
responseLog ResponseLogCallback
rateLimiter RateLimiter
generateCurlOnDebug bool
loadBalancer LoadBalancer
beforeRequest []RequestMiddleware
Expand Down Expand Up @@ -635,7 +635,8 @@ func (c *Client) R() *Request {
RetryCount: c.retryCount,
RetryWaitTime: c.retryWaitTime,
RetryMaxWaitTime: c.retryMaxWaitTime,
RetryResetReaders: c.retryResetReaders,
RetryStrategy: c.retryStrategy,
IsRetryDefaultConditions: c.isRetryDefaultConditions,
CloseConnection: c.closeConnection,
DoNotParseResponse: c.notParseResponse,
DebugBodyLimit: c.debugBodyLimit,
Expand Down Expand Up @@ -1204,20 +1205,57 @@ func (c *Client) SetRetryMaxWaitTime(maxWaitTime time.Duration) *Client {
return c
}

// RetryAfter method returns the retry after callback function, that is
// used to calculate wait time between retries if it's registered; otherwise, it is nil.
func (c *Client) RetryAfter() RetryAfterFunc {
// RetryStrategy method returns the retry strategy function; otherwise, it is nil.
//
// See [Client.SetRetryStrategy]
func (c *Client) RetryStrategy() RetryStrategyFunc {
c.lock.RLock()
defer c.lock.RUnlock()
return c.retryStrategy
}

// SetRetryStrategy method used to set the custom Retry strategy into Resty client,
// it is used to get wait time before each retry. It can be overridden at request
// level, see [Request.SetRetryStrategy]
//
// Default (nil) implies exponential backoff with a jitter strategy
func (c *Client) SetRetryStrategy(rs RetryStrategyFunc) *Client {
c.lock.Lock()
defer c.lock.Unlock()
c.retryStrategy = rs
return c
}

// EnableRetryDefaultConditions method enables the Resty's default retry conditions
func (c *Client) EnableRetryDefaultConditions() *Client {
c.SetRetryDefaultConditions(true)
return c
}

// DisableRetryDefaultConditions method disables the Resty's default retry conditions
func (c *Client) DisableRetryDefaultConditions() *Client {
c.SetRetryDefaultConditions(false)
return c
}

// IsRetryDefaultConditions method returns true if Resty's default retry conditions
// are enabled otherwise false
//
// Default value is `true`
func (c *Client) IsRetryDefaultConditions() bool {
c.lock.RLock()
defer c.lock.RUnlock()
return c.retryAfter
return c.isRetryDefaultConditions
}

// SetRetryAfter sets a callback to calculate the wait time between retries.
// Default (nil) implies exponential backoff with jitter
func (c *Client) SetRetryAfter(callback RetryAfterFunc) *Client {
// SetRetryDefaultConditions method is used to enable/disable the Resty's default
// retry conditions
//
// It can be overridden at request level, see [Request.SetRetryDefaultConditions]
func (c *Client) SetRetryDefaultConditions(b bool) *Client {
c.lock.Lock()
defer c.lock.Unlock()
c.retryAfter = callback
c.isRetryDefaultConditions = b
return c
}

Expand All @@ -1241,47 +1279,22 @@ func (c *Client) AddRetryCondition(condition RetryConditionFunc) *Client {
return c
}

// AddRetryAfterErrorCondition adds the basic condition of retrying after encountering
// an error from the HTTP response
func (c *Client) AddRetryAfterErrorCondition() *Client {
c.AddRetryCondition(func(response *Response, err error) bool {
return response.IsError()
})
return c
}

// RetryHooks method returns all the retry hook functions.
func (c *Client) RetryHooks() []OnRetryFunc {
func (c *Client) RetryHooks() []RetryHookFunc {
c.lock.RLock()
defer c.lock.RUnlock()
return c.retryHooks
}

// AddRetryHook adds a side-effecting retry hook to an array of hooks
// that will be executed on each retry.
func (c *Client) AddRetryHook(hook OnRetryFunc) *Client {
func (c *Client) AddRetryHook(hook RetryHookFunc) *Client {
c.lock.Lock()
defer c.lock.Unlock()
c.retryHooks = append(c.retryHooks, hook)
return c
}

// RetryResetReaders method returns true if the retry reset readers are enabled; otherwise, it is nil.
func (c *Client) RetryResetReaders() bool {
c.lock.RLock()
defer c.lock.RUnlock()
return c.retryResetReaders
}

// SetRetryResetReaders method enables the Resty client to seek the start of all
// file readers are given as multipart files if the object implements [io.ReadSeeker].
func (c *Client) SetRetryResetReaders(b bool) *Client {
c.lock.Lock()
defer c.lock.Unlock()
c.retryResetReaders = b
return c
}

// SetTLSClientConfig method sets TLSClientConfig for underlying client Transport.
//
// For Example:
Expand Down Expand Up @@ -1539,22 +1552,6 @@ func (c *Client) SetOutputDirectory(dirPath string) *Client {
return c
}

// RateLimiter method returns the rate limiter interface
func (c *Client) RateLimiter() RateLimiter {
c.lock.RLock()
defer c.lock.RUnlock()
return c.rateLimiter
}

// SetRateLimiter sets an optional [RateLimiter]. If set, the rate limiter will control
// all requests were made by this client.
func (c *Client) SetRateLimiter(rl RateLimiter) *Client {
c.lock.Lock()
defer c.lock.Unlock()
c.rateLimiter = rl
return c
}

// Transport method returns [http.Transport] currently in use or error
// in case the currently used `transport` is not a [http.Transport].
//
Expand Down Expand Up @@ -1947,30 +1944,18 @@ func (c *Client) Close() error {
func (c *Client) executeBefore(req *Request) error {
var err error

if isStringEmpty(req.Method) {
req.Method = MethodGet
}

// user defined on before request methods
// to modify the *resty.Request object
for _, f := range c.beforeRequestMiddlewares() {
if err = f(c, req); err != nil {
return wrapNoRetryErr(err)
}
}

// If there is a rate limiter set for this client, the Execute call
// will return an error if the rate limit is exceeded.
if req.client.RateLimiter() != nil {
if !req.client.RateLimiter().Allow() {
return ErrRateLimitExceeded
return err
}
}

// resty middlewares
for _, f := range c.beforeRequest {
if err = f(c, req); err != nil {
return wrapNoRetryErr(err)
return err
}
}

Expand All @@ -1981,7 +1966,7 @@ func (c *Client) executeBefore(req *Request) error {
// call pre-request if defined
if c.preReqHook != nil {
if err = c.preReqHook(c, req.RawRequest); err != nil {
return wrapNoRetryErr(err)
return err
}
}

Expand All @@ -1996,10 +1981,9 @@ func (c *Client) execute(req *Request) (*Response, error) {
}

if err := requestDebugLogger(c, req); err != nil {
return nil, wrapNoRetryErr(err)
return nil, err
}

req.RawRequest.Body = wrapRequestBufferReleaser(req)
req.Time = time.Now()
resp, err := c.Client().Do(req.RawRequest)

Expand Down Expand Up @@ -2046,7 +2030,7 @@ func (c *Client) execute(req *Request) (*Response, error) {
}
}

return response, wrapNoRetryErr(err)
return response, err
}

// getting TLS client config if not exists then create one
Expand Down
57 changes: 30 additions & 27 deletions client_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2015-2024 Jeevanandam M ([email protected]), All rights reserved.
// Copyright (c) 2015-present Jeevanandam M ([email protected]), All rights reserved.
// resty source code and usage is governed by a MIT style
// license that can be found in the LICENSE file.
// SPDX-License-Identifier: MIT

package resty

Expand All @@ -9,13 +10,14 @@ import (
"compress/gzip"
"compress/lzw"
"context"
"crypto/rand"
cryprand "crypto/rand"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"math"
"math/rand"
"net"
"net/http"
"net/url"
Expand Down Expand Up @@ -202,19 +204,19 @@ func TestClientTimeout(t *testing.T) {
ts := createGetServer(t)
defer ts.Close()

c := dcnl().SetTimeout(time.Second * 3)
c := dcnl().SetTimeout(time.Millisecond * 200)
_, err := c.R().Get(ts.URL + "/set-timeout-test")

assertEqual(t, true, strings.Contains(strings.ToLower(err.Error()), "timeout"))
assertEqual(t, true, strings.Contains(err.Error(), "Client.Timeout"))
}

func TestClientTimeoutWithinThreshold(t *testing.T) {
ts := createGetServer(t)
defer ts.Close()

c := dcnl().SetTimeout(time.Second * 3)
resp, err := c.R().Get(ts.URL + "/set-timeout-test-with-sequence")
c := dcnl().SetTimeout(200 * time.Millisecond)

resp, err := c.R().Get(ts.URL + "/set-timeout-test-with-sequence")
assertError(t, err)

seq1, _ := strconv.ParseInt(resp.String(), 10, 32)
Expand Down Expand Up @@ -490,6 +492,12 @@ func TestClientSettingsCoverage(t *testing.T) {

c.DisableDebug()

assertEqual(t, true, c.IsRetryDefaultConditions())
c.DisableRetryDefaultConditions()
assertEqual(t, false, c.IsRetryDefaultConditions())
c.EnableRetryDefaultConditions()
assertEqual(t, true, c.IsRetryDefaultConditions())

// [Start] Custom Transport scenario
ct := dcnl()
ct.SetTransport(&CustomRoundTripper{})
Expand Down Expand Up @@ -1204,19 +1212,26 @@ func TestPostRedirectWithBody(t *testing.T) {
ts := createPostServer(t)
defer ts.Close()

targetURL, _ := url.Parse(ts.URL)
t.Log("ts.URL:", ts.URL)
t.Log("targetURL.Host:", targetURL.Host)
mu := sync.Mutex{}
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))

c := dcnl()
c := dcnl().SetBaseURL(ts.URL)

totalRequests := 4000
wg := sync.WaitGroup{}
for i := 0; i < 100; i++ {
wg.Add(1)
wg.Add(totalRequests)
for i := 0; i < totalRequests; i++ {
if i%50 == 0 {
time.Sleep(20 * time.Millisecond) // to prevent test server socket exhaustion
}
go func() {
defer wg.Done()
mu.Lock()
randNumber := rnd.Int()
mu.Unlock()
resp, err := c.R().
SetBody([]byte(strconv.Itoa(newRnd().Int()))).
Post(targetURL.String() + "/redirect-with-body")
SetBody([]byte(strconv.Itoa(randNumber))).
Post("/redirect-with-body")
assertError(t, err)
assertNotNil(t, resp)
}()
Expand Down Expand Up @@ -1252,14 +1267,6 @@ func TestUnixSocket(t *testing.T) {
assertEqual(t, "Hello resty client from a server running on endpoint /hello!", res.String())
}

var _ RateLimiter = (*testRateLimiter)(nil)

type testRateLimiter struct{}

func (t *testRateLimiter) Allow() bool {
return false
}

func TestClientClone(t *testing.T) {
parent := New()

Expand All @@ -1268,9 +1275,6 @@ func TestClientClone(t *testing.T) {
parent.SetBasicAuth("parent", "")
parent.SetProxy("http://localhost:8080")

// set an interface field
tr := &testRateLimiter{}
parent.SetRateLimiter(tr)
parent.SetCookie(&http.Cookie{
Name: "go-resty-1",
Value: "This is cookie 1 value",
Expand Down Expand Up @@ -1300,12 +1304,11 @@ func TestClientClone(t *testing.T) {

// assert interface/pointer type
assertEqual(t, parent.Client(), clone.Client())
assertEqual(t, parent.RateLimiter(), clone.RateLimiter())
}

func TestResponseBodyLimit(t *testing.T) {
ts := createTestServer(func(w http.ResponseWriter, r *http.Request) {
io.CopyN(w, rand.Reader, 100*800)
io.CopyN(w, cryprand.Reader, 100*800)
})
defer ts.Close()

Expand Down
Loading

0 comments on commit 78ccd46

Please sign in to comment.