Repository: go-resty/resty (go)

Issue:
Proposal: OnRetry hook
Currently the Retry interface of the Resty client supports a custom condition for when to retry and a custom callback to calculate the time between retries. What I am currently missing is an `OnRetry` hook to execute side effecting operations whenever a request is retried.

For example, I have a use case where I want to authenticate with an API after an access token has expired. One potential way to solve that with `resty` would be to authenticate with the API if the request returned a 403. While it is currently possible to achieve this with the `resty.Backoff`, it would be much cleaner to have an `OnRetry` hook.

Pull request:
Feat: Add Retry hooks
Add RetryHooks: side-effecting functions that will be executed whenever a retry is triggered.

RetryHooks give users the ability to add side-effecting operations whenever a retry is triggered, without having to abuse the RetryConditions or using the Backoff directly. For example, RetryHooks allow you to log whenever a retry is triggered.

Fixes #416 

Patch:
diff --git a/client.go b/client.go
index 4e54f69..cedb903 100644
--- a/client.go
+++ b/client.go
@@ -108,6 +108,7 @@ type Client struct {
 	RetryWaitTime         time.Duration
 	RetryMaxWaitTime      time.Duration
 	RetryConditions       []RetryConditionFunc
+	RetryHooks            []OnRetryFunc
 	RetryAfter            RetryAfterFunc
 	JSONMarshal           func(v interface{}) ([]byte, error)
 	JSONUnmarshal         func(data []byte, v interface{}) error
@@ -591,6 +592,13 @@ func (c *Client) AddRetryAfterErrorCondition() *Client {
 	return c
 }
 
+// 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 {
+	c.RetryHooks = append(c.RetryHooks, hook)
+	return c
+}
+
 // SetTLSClientConfig method sets TLSClientConfig for underling client Transport.
 //
 // For Example:
diff --git a/request.go b/request.go
index b1e8eb8..0e9cbf7 100644
--- a/request.go
+++ b/request.go
@@ -733,6 +733,7 @@ func (r *Request) Execute(method, url string) (*Response, error) {
 		WaitTime(r.client.RetryWaitTime),
 		MaxWaitTime(r.client.RetryMaxWaitTime),
 		RetryConditions(r.client.RetryConditions),
+		RetryHooks(r.client.RetryHooks),
 	)
 
 	r.client.onErrorHooks(r, resp, unwrapNoRetryErr(err))
diff --git a/retry.go b/retry.go
index a56f3b4..d281236 100644
--- a/retry.go
+++ b/retry.go
@@ -26,6 +26,9 @@ type (
 	// input: non-nil Response OR request execution error
 	RetryConditionFunc func(*Response, error) bool
 
+	// OnRetryFunc is for side-effecting functions triggered on retry
+	OnRetryFunc func(*Response, error)
+
 	// RetryAfterFunc returns time to wait before retry
 	// For example, it can parse HTTP Retry-After header
 	// https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
@@ -39,6 +42,7 @@ type (
 		waitTime        time.Duration
 		maxWaitTime     time.Duration
 		retryConditions []RetryConditionFunc
+		retryHooks      []OnRetryFunc
 	}
 )
 
@@ -70,6 +74,13 @@ func RetryConditions(conditions []RetryConditionFunc) Option {
 	}
 }
 
+// RetryHooks sets the hooks that will be executed after each retry
+func RetryHooks(hooks []OnRetryFunc) Option {
+	return func(o *Options) {
+		o.retryHooks = hooks
+	}
+}
+
 // Backoff retries with increasing timeout duration up until X amount of retries
 // (Default is 3 attempts, Override with option Retries(n))
 func Backoff(operation func() (*Response, error), options ...Option) error {
@@ -114,6 +125,10 @@ func Backoff(operation func() (*Response, error), options ...Option) error {
 			return err
 		}
 
+		for _, hook := range opts.retryHooks {
+			hook(resp, err)
+		}
+
 		waitTime, err2 := sleepDuration(resp, opts.waitTime, opts.maxWaitTime, attempt)
 		if err2 != nil {
 			if err == nil {


Test patch:
diff --git a/retry_test.go b/retry_test.go
index 19d274d..596791c 100644
--- a/retry_test.go
+++ b/retry_test.go
@@ -77,6 +77,23 @@ func TestConditionalBackoffConditionNonExecution(t *testing.T) {
 	assertNotEqual(t, counter, attempts)
 }
 
+// Check to make sure that RetryHooks are executed
+func TestOnRetryBackoff(t *testing.T) {
+	attempts := 3
+	counter := 0
+
+	hook := func(r *Response, err error) {
+		counter++
+	}
+
+	retryErr := Backoff(func() (*Response, error) {
+		return nil, nil
+	}, RetryHooks([]OnRetryFunc{hook}))
+
+	assertError(t, retryErr)
+	assertNotEqual(t, counter, attempts)
+}
+
 // Check to make sure the functions added to add conditionals work
 func TestConditionalGet(t *testing.T) {
 	ts := createGetServer(t)
@@ -651,6 +668,35 @@ func TestClientErrorRetry(t *testing.T) {
 	assertNil(t, resp.Error())
 }
 
+func TestClientRetryHook(t *testing.T) {
+	ts := createGetServer(t)
+	defer ts.Close()
+
+	attempt := 0
+
+	c := dc().
+		SetRetryCount(2).
+		SetTimeout(time.Second * 3).
+		AddRetryHook(
+			func(r *Response, _ error) {
+				attempt++
+			},
+		)
+
+	resp, err := c.R().Get(ts.URL + "/set-retrycount-test")
+	assertEqual(t, "", resp.Status())
+	assertEqual(t, "", resp.Proto())
+	assertEqual(t, 0, resp.StatusCode())
+	assertEqual(t, 0, len(resp.Cookies()))
+	assertNotNil(t, resp.Body())
+	assertEqual(t, 0, len(resp.Header()))
+
+	assertEqual(t, 3, attempt)
+
+	assertEqual(t, true, (strings.HasPrefix(err.Error(), "Get "+ts.URL+"/set-retrycount-test") ||
+		strings.HasPrefix(err.Error(), "Get \""+ts.URL+"/set-retrycount-test\"")))
+}
+
 func filler(*Response, error) bool {
 	return false
 }