diff --git a/client.go b/client.go index ece7dada..44fd6b6c 100644 --- a/client.go +++ b/client.go @@ -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) @@ -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() } diff --git a/request.go b/request.go index a9ff4455..7c9bbf83 100644 --- a/request.go +++ b/request.go @@ -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 @@ -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 { diff --git a/retry.go b/retry.go index fa67c843..d02029e7 100644 --- a/retry.go +++ b/retry.go @@ -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, } diff --git a/retry_test.go b/retry_test.go index 1b843b29..e24c1ff3 100644 --- a/retry_test.go +++ b/retry_test.go @@ -6,6 +6,7 @@ import ( "io" "math" "net/http" + "net/http/httptest" "testing" "time" @@ -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) +}