Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type Client struct {
disableAutoReadResponse bool
maxResponseSize int64 // 0 means no limit
commonErrorType reflect.Type
retryOption *retryOption
retryOption *RetryOption
jsonMarshal func(v any) ([]byte, error)
jsonUnmarshal func(data []byte, v any) error
xmlMarshal func(v any) ([]byte, error)
Expand Down Expand Up @@ -1403,7 +1403,7 @@ func (c *Client) GetClient() *http.Client {
return c.httpClient
}

func (c *Client) getRetryOption() *retryOption {
func (c *Client) getRetryOption() *RetryOption {
if c.retryOption == nil {
c.retryOption = newDefaultRetryOption()
}
Expand Down
37 changes: 35 additions & 2 deletions request.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ type Request struct {
// request. A pointed-to value of 0 means no limit for this request.
maxResponseSize *int64
unReplayableBody io.ReadCloser
retryOption *retryOption
retryOption *RetryOption
bodyReadCloser io.ReadCloser
dumpOptions *DumpOptions
marshalBody any
Expand Down Expand Up @@ -1236,13 +1236,46 @@ func (r *Request) DisableForceMultipart() *Request {
return r
}

func (r *Request) getRetryOption() *retryOption {
func (r *Request) getRetryOption() *RetryOption {
if r.retryOption == nil {
r.retryOption = newDefaultRetryOption()
}
return r.retryOption
}

// GetRetryOption returns the retry configuration of this request.
// It returns nil if retry has not been configured (neither via
// Client.SetCommonRetry* nor Request.SetRetry*).
//
// The returned value is the live option used by this request: mutations
// affect subsequent retries on the same request. Treat it as read-only
// unless you intentionally want to change retry behavior from middleware.
//
// This is useful in middleware to inspect MaxRetries together with
// Request.RetryAttempt, e.g. to report errors only after the configured
// retry budget is exhausted:
//
// client.OnAfterResponse(func(c *req.Client, resp *req.Response) error {
// ro := resp.Request.GetRetryOption()
// if ro == nil {
// return nil
// }
// // Cover HTTP error statuses and transport failures (resp.Err with
// // no Response). Client OnAfterResponse still runs after failed Do.
// failed := resp.IsErrorState() || resp.Err != nil
// // RetryAttempt >= MaxRetries only detects budget exhaustion. Retries
// // may also stop earlier when a RetryCondition returns false; in that
// // case RetryAttempt can be less than MaxRetries on a terminal failure.
// if failed && ro.MaxRetries >= 0 &&
// resp.Request.RetryAttempt >= ro.MaxRetries {
// // report once after final failure
// }
// return nil
// })
func (r *Request) GetRetryOption() *RetryOption {
return r.retryOption
}

// SetRetryCount enables retry and set the maximum retry count.
// It will retry infinitely if count is negative.
func (r *Request) SetRetryCount(count int) *Request {
Expand Down
19 changes: 14 additions & 5 deletions retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,33 @@ func backoffInterval(min, max time.Duration) GetRetryIntervalFunc {
}
}

func newDefaultRetryOption() *retryOption {
return &retryOption{
func newDefaultRetryOption() *RetryOption {
return &RetryOption{
GetRetryInterval: defaultGetRetryInterval,
}
}

type retryOption struct {
// RetryOption controls the retry behavior of a request.
// It is typically configured via Client.SetCommonRetry* or Request.SetRetry*
// methods and can be read from middleware with Request.GetRetryOption.
//
// MaxRetries is the maximum number of retries (not including the initial
// attempt). A negative value means retry infinitely. Zero means no retries.
// GetRetryOption may still return a non-nil option if only non-count setters
// (interval, condition, or hook) were used while leaving MaxRetries at zero.
type RetryOption struct {
MaxRetries int
GetRetryInterval GetRetryIntervalFunc
RetryConditions []RetryConditionFunc
RetryHooks []RetryHookFunc
}

func (ro *retryOption) Clone() *retryOption {
// Clone returns a deep copy of RetryOption.
func (ro *RetryOption) Clone() *RetryOption {
if ro == nil {
return nil
}
o := &retryOption{
o := &RetryOption{
MaxRetries: ro.MaxRetries,
GetRetryInterval: ro.GetRetryInterval,
}
Expand Down
145 changes: 145 additions & 0 deletions retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"math"
"net/http"
"net/http/httptest"
"testing"
"time"

Expand Down Expand Up @@ -256,3 +257,147 @@ func TestRetryTurnedOffWhenRetryCountEqZero(t *testing.T) {
tests.AssertIsNil(t, resp.Response)
tests.AssertEqual(t, 0, resp.Request.RetryAttempt)
}

func TestGetRetryOptionNilWhenNotConfigured(t *testing.T) {
r := tc().R()
tests.AssertIsNil(t, r.GetRetryOption())
}

func TestGetRetryOptionFromRequest(t *testing.T) {
r := tc().R().SetRetryCount(5)
ro := r.GetRetryOption()
tests.AssertNotNil(t, ro)
tests.AssertEqual(t, 5, ro.MaxRetries)
}

func TestGetRetryOptionFromClient(t *testing.T) {
c := tc().SetCommonRetryCount(4)
r := c.R()
ro := r.GetRetryOption()
tests.AssertNotNil(t, ro)
tests.AssertEqual(t, 4, ro.MaxRetries)
}

func TestGetRetryOptionRequestOverridesClient(t *testing.T) {
c := tc().SetCommonRetryCount(4)
r := c.R().SetRetryCount(1)
ro := r.GetRetryOption()
tests.AssertNotNil(t, ro)
tests.AssertEqual(t, 1, ro.MaxRetries)
// Client-level option remains unchanged
tests.AssertEqual(t, 4, c.getRetryOption().MaxRetries)
}

// TestGetRetryOptionInMiddleware covers the #475 use case: middleware reads
// MaxRetries so exception reporting only runs after all retries are exhausted.
func TestGetRetryOptionInMiddleware(t *testing.T) {
reportCount := 0
middlewareCalls := 0
var seenMaxRetries int

c := tc().
SetCommonRetryCount(2).
SetCommonRetryFixedInterval(1 * time.Millisecond).
SetCommonRetryCondition(func(resp *Response, err error) bool {
return err != nil || resp.StatusCode == http.StatusTooManyRequests
}).
OnAfterResponse(func(client *Client, resp *Response) error {
middlewareCalls++
ro := resp.Request.GetRetryOption()
tests.AssertNotNil(t, ro)
seenMaxRetries = ro.MaxRetries

// Report only when retry budget is exhausted and the attempt failed
// (HTTP error status or transport error).
failed := resp.IsErrorState() || resp.Err != nil
if failed &&
resp.Request.RetryAttempt >= ro.MaxRetries &&
ro.MaxRetries >= 0 {
reportCount++
}
return nil
})

resp, err := c.R().Get("/too-many")

tests.AssertNoError(t, err)
tests.AssertEqual(t, 2, seenMaxRetries)
tests.AssertEqual(t, 2, resp.Request.RetryAttempt)
// Initial attempt + 2 retries => 3 middleware invocations
tests.AssertEqual(t, 3, middlewareCalls)
// Only the final exhausted attempt should report once
tests.AssertEqual(t, 1, reportCount)
}

func TestGetRetryOptionInMiddlewareTransportError(t *testing.T) {
// Transport failures leave resp.Response nil so IsErrorState is false;
// reporting must also consider resp.Err.
reportCount := 0
middlewareCalls := 0
c := C().
SetTimeout(500 * time.Millisecond).
SetCommonRetryCount(1).
SetCommonRetryFixedInterval(1 * time.Millisecond).
OnAfterResponse(func(client *Client, resp *Response) error {
middlewareCalls++
ro := resp.Request.GetRetryOption()
tests.AssertNotNil(t, ro)
failed := resp.IsErrorState() || resp.Err != nil
if failed &&
resp.Request.RetryAttempt >= ro.MaxRetries &&
ro.MaxRetries >= 0 {
reportCount++
}
return nil
})

resp, err := c.R().Get("https://non-exists-host.com.cn")
tests.AssertNotNil(t, err)
tests.AssertEqual(t, 1, resp.Request.RetryAttempt)
tests.AssertEqual(t, 2, middlewareCalls) // initial + 1 retry
tests.AssertEqual(t, 1, reportCount)
}

func TestGetRetryOptionInMiddlewareNoReportOnEventualSuccess(t *testing.T) {
// Server fails twice then succeeds; middleware must not report after success.
attempt := 0
reportCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempt++
if attempt <= 2 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}))
defer ts.Close()

c := C().
SetCommonRetryCount(3).
SetCommonRetryFixedInterval(1 * time.Millisecond).
SetCommonRetryCondition(func(resp *Response, err error) bool {
return err != nil || resp.StatusCode == http.StatusServiceUnavailable
})

resp, err := c.R().
OnAfterResponse(func(client *Client, resp *Response) error {
ro := resp.Request.GetRetryOption()
if ro == nil {
return nil
}
failed := resp.IsErrorState() || resp.Err != nil
if failed &&
resp.Request.RetryAttempt >= ro.MaxRetries &&
ro.MaxRetries >= 0 {
reportCount++
}
return nil
}).
Get(ts.URL)

tests.AssertNoError(t, err)
tests.AssertEqual(t, http.StatusOK, resp.StatusCode)
tests.AssertEqual(t, 0, reportCount)
tests.AssertEqual(t, 2, resp.Request.RetryAttempt)
}
Loading