Skip to content

Commit 96b410a

Browse files
committed
Properly handle redirects in the request retrier. Fix expected behavior of client having round-robin uri selector.
1 parent 7e3a617 commit 96b410a

12 files changed

+203
-66
lines changed

conjure-go-client/httpclient/client.go

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -83,31 +83,34 @@ func (c *clientImpl) Delete(ctx context.Context, params ...RequestParam) (*http.
8383
}
8484

8585
func (c *clientImpl) Do(ctx context.Context, params ...RequestParam) (*http.Response, error) {
86-
uriCount := c.uriPool.NumURIs()
87-
attempts := 2 * uriCount
86+
attempts := 2 * c.uriPool.NumURIs()
8887
if c.maxAttempts != nil {
8988
if confMaxAttempts := c.maxAttempts.CurrentIntPtr(); confMaxAttempts != nil {
9089
attempts = *confMaxAttempts
9190
}
9291
}
9392

93+
var resp *http.Response
9494
var err error
9595
retrier := internal.NewRequestRetrier(c.backoffOptions.CurrentRetryParams().Start(ctx), attempts)
96-
var req *http.Request
97-
var resp *http.Response
98-
for retrier.Next(req, resp) {
99-
req, resp, err = c.doOnce(ctx, params...)
96+
for {
97+
shouldRetry, retryURL := retrier.Next(resp, err)
98+
if !shouldRetry {
99+
break
100+
}
101+
resp, err = c.doOnce(ctx, retryURL, params...)
100102
if err != nil {
101103
svc1log.FromContext(ctx).Debug("Retrying request", svc1log.Stacktrace(err))
102104
}
103105
}
104-
if err != nil {
105-
return nil, err
106-
}
107-
return resp, nil
106+
return resp, err
108107
}
109108

110-
func (c *clientImpl) doOnce(ctx context.Context, params ...RequestParam) (*http.Request, *http.Response, error) {
109+
func (c *clientImpl) doOnce(
110+
ctx context.Context,
111+
retryURL *url.URL,
112+
params ...RequestParam,
113+
) (*http.Response, error) {
111114
// 1. create the request
112115
b := &requestBuilder{
113116
headers: make(http.Header),
@@ -120,7 +123,7 @@ func (c *clientImpl) doOnce(ctx context.Context, params ...RequestParam) (*http.
120123
continue
121124
}
122125
if err := p.apply(b); err != nil {
123-
return nil, nil, err
126+
return nil, err
124127
}
125128
}
126129

@@ -129,15 +132,23 @@ func (c *clientImpl) doOnce(ctx context.Context, params ...RequestParam) (*http.
129132
}
130133

131134
if b.method == "" {
132-
return nil, nil, werror.ErrorWithContextParams(ctx, "httpclient: use WithRequestMethod() to specify HTTP method")
135+
return nil, werror.ErrorWithContextParams(ctx, "httpclient: use WithRequestMethod() to specify HTTP method")
133136
}
134-
url, err := c.uriSelector.Select(c.uriPool.URIs(), b.headers)
135-
if err != nil {
136-
return nil, nil, werror.WrapWithContextParams(ctx, err, "failed to select uri")
137+
var uri string
138+
if retryURL == nil {
139+
var err error
140+
uri, err = c.uriSelector.Select(c.uriPool.URIs(), b.headers)
141+
if err != nil {
142+
return nil, werror.WrapWithContextParams(ctx, err, "failed to select uri")
143+
}
144+
uri = joinURIAndPath(uri, b.path)
145+
} else {
146+
b.path = ""
147+
uri = retryURL.String()
137148
}
138-
req, err := http.NewRequestWithContext(ctx, b.method, url, nil)
149+
req, err := http.NewRequestWithContext(ctx, b.method, uri, nil)
139150
if err != nil {
140-
return nil, nil, werror.WrapWithContextParams(ctx, err, "failed to build request")
151+
return nil, werror.WrapWithContextParams(ctx, err, "failed to build request")
141152
}
142153

143154
req.Header = b.headers
@@ -176,7 +187,7 @@ func (c *clientImpl) doOnce(ctx context.Context, params ...RequestParam) (*http.
176187
internal.DrainBody(resp)
177188
}
178189

179-
return req, resp, unwrapURLError(ctx, respErr)
190+
return resp, unwrapURLError(ctx, respErr)
180191
}
181192

182193
// unwrapURLError converts a *url.Error to a werror. We need this because all

conjure-go-client/httpclient/client_builder.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ func newClient(ctx context.Context, b *clientBuilder, params ...ClientParam) (Cl
155155
}
156156
uriPool := internal.NewStatefulURIPool(b.URIs)
157157
if b.URISelector == nil {
158-
b.URISelector = internal.NewRandomURISelector(func() int64 { return time.Now().UnixNano() })
158+
b.URISelector = internal.NewRoundRobinURISelector(func() int64 { return time.Now().UnixNano() })
159159
}
160160
return &clientImpl{
161161
client: httpClient,

conjure-go-client/httpclient/internal/balanced_selector.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ type balancedSelector struct {
5252
uriInfos map[string]uriInfo
5353
}
5454

55-
// Select implements estransport.Selector interface
55+
// Select implements Selector interface
5656
func (s *balancedSelector) Select(uris []string, _ http.Header) (string, error) {
5757
s.Lock()
5858
defer s.Unlock()

conjure-go-client/httpclient/internal/random_selector_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import (
2121
"github.com/stretchr/testify/assert"
2222
)
2323

24-
func TestRandomSelectorGetRandomURIs(t *testing.T) {
24+
func TestRandomSelector_Select(t *testing.T) {
2525
uris := []string{"uri1", "uri2", "uri3", "uri4", "uri5"}
2626
scorer := NewRandomURISelector(func() int64 { return time.Now().UnixNano() })
2727
uri, err := scorer.Select(uris, nil)

conjure-go-client/httpclient/internal/request_retrier.go

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package internal
1616

1717
import (
1818
"net/http"
19+
"net/url"
1920
"strings"
2021

2122
"github.com/palantir/pkg/retry"
@@ -54,41 +55,64 @@ func (r *RequestRetrier) attemptsRemaining() bool {
5455
return r.attemptCount < r.maxAttempts
5556
}
5657

57-
// Next returns true if a subsequent request attempt should be attempted. If
58-
// uses the previous request/response (if provided) to determine if the request
59-
// should be attempted. If the returned value is true, the retrier will have
60-
// waited the desired backoff interval before returning.
61-
func (r *RequestRetrier) Next(prevReq *http.Request, prevResp *http.Response) bool {
58+
// Next returns true if a subsequent request attempt should be attempted. If uses the previous response/resp err (if
59+
// provided) to determine if the request should be attempted. If the returned value is true, the retrier will have
60+
// waited the desired backoff interval before returning when applicable. If the previous response was a redirect, the
61+
// retrier will also return the URL that should be used for the new next request.
62+
func (r *RequestRetrier) Next(resp *http.Response, err error) (bool, *url.URL) {
6263
defer func() { r.attemptCount++ }()
63-
// check for bad requests
64-
if prevResp != nil {
65-
prevCode := prevResp.StatusCode
66-
// succesfull response
67-
if prevCode == http.StatusOK {
68-
return false
69-
}
70-
if prevCode >= http.StatusBadRequest && prevCode < http.StatusInternalServerError {
71-
return false
72-
}
64+
if r.isSuccess(resp) {
65+
return false, nil
7366
}
7467

75-
// don't retry mesh uris
76-
if prevReq != nil {
77-
prevURI := getBaseURI(prevReq.URL)
78-
if r.isMeshURI(prevURI) {
79-
return false
80-
}
68+
if r.isNonRetryableClientError(resp, err) {
69+
return false, nil
70+
}
71+
72+
// handle redirects
73+
if tryOther, otherURI := isRetryOtherResponse(resp, err); tryOther && otherURI != nil {
74+
return true, otherURI
8175
}
8276

83-
// TODO (dtrejo): Handle redirects?
77+
// don't retry mesh uris
78+
if r.isMeshURI(resp) {
79+
return false, nil
80+
}
8481

8582
if !r.attemptsRemaining() {
8683
// Retries exhausted
84+
return false, nil
85+
}
86+
return r.retrier.Next(), nil
87+
}
88+
89+
func (*RequestRetrier) isSuccess(resp *http.Response) bool {
90+
if resp == nil {
91+
return false
92+
}
93+
// Check for a 2XX status
94+
return resp.StatusCode >= 200 && resp.StatusCode < 300
95+
}
96+
97+
func (*RequestRetrier) isNonRetryableClientError(resp *http.Response, err error) bool {
98+
errCode, _ := StatusCodeFromError(err)
99+
// Check for a 4XX status parsed from the error or in the response
100+
if isClientError(errCode) && errCode != StatusCodeThrottle {
87101
return false
88102
}
89-
return r.retrier.Next()
103+
if resp != nil && isClientError(resp.StatusCode) {
104+
// 429 is retryable
105+
if isThrottle, _ := isThrottleResponse(resp, errCode); !isThrottle {
106+
return false
107+
}
108+
return true
109+
}
110+
return false
90111
}
91112

92-
func (*RequestRetrier) isMeshURI(uri string) bool {
93-
return strings.HasPrefix(uri, meshSchemePrefix)
113+
func (*RequestRetrier) isMeshURI(resp *http.Response) bool {
114+
if resp == nil {
115+
return false
116+
}
117+
return strings.HasPrefix(getBaseURI(resp.Request.URL), meshSchemePrefix)
94118
}

conjure-go-client/httpclient/internal/request_retrier_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,27 @@ func TestRequestRetrier_HandleMeshURI(t *testing.T) {
3131
r := NewRequestRetrier(retry.Start(context.Background()), 1)
3232
req, err := http.NewRequest("GET", "mesh-http://example.com", nil)
3333
require.NoError(t, err)
34-
shouldRetry := r.Next(req, &http.Response{})
34+
shouldRetry, _ := r.Next(&http.Response{Request: req}, nil)
3535
require.False(t, shouldRetry)
3636
}
3737

3838
func TestRequestRetrier_AttemptCount(t *testing.T) {
3939
maxAttempts := 3
4040
r := NewRequestRetrier(retry.Start(context.Background()), maxAttempts)
4141
// first request is not a retry, so it doesn't increment the overall count
42-
shouldRetry := r.Next(nil, nil)
42+
shouldRetry, _ := r.Next(nil, nil)
4343
require.True(t, shouldRetry)
4444

4545
for i := 0; i < maxAttempts-1; i++ {
4646
req, err := http.NewRequest("GET", "http://example.com", nil)
4747
require.NoError(t, err)
48-
shouldRetry = r.Next(req, &http.Response{})
48+
shouldRetry, _ = r.Next(&http.Response{Request: req}, err)
4949
require.True(t, shouldRetry)
5050
}
5151

5252
req, err := http.NewRequest("GET", "http://example.com", nil)
5353
require.NoError(t, err)
54-
shouldRetry = r.Next(req, &http.Response{})
54+
shouldRetry, _ = r.Next(&http.Response{Request: req}, err)
5555
require.False(t, shouldRetry)
5656
}
5757

@@ -62,28 +62,28 @@ func TestRequestRetrier_UnlimitedAttempts(t *testing.T) {
6262
r := NewRequestRetrier(retry.Start(ctx, retry.WithInitialBackoff(50*time.Millisecond), retry.WithRandomizationFactor(0)), 0)
6363

6464
startTime := time.Now()
65-
shouldRetry := r.Next(nil, nil)
65+
shouldRetry, _ := r.Next(nil, nil)
6666
require.True(t, shouldRetry)
6767
require.Lessf(t, time.Since(startTime), 49*time.Millisecond, "first GetNextURI should not have any delay")
6868

6969
req, err := http.NewRequest("GET", "http://example.com", nil)
7070
require.NoError(t, err)
71-
resp := &http.Response{}
71+
resp := &http.Response{Request: req}
7272

7373
startTime = time.Now()
74-
shouldRetry = r.Next(req, resp)
74+
shouldRetry, _ = r.Next(resp, err)
7575
require.True(t, shouldRetry)
7676
assert.Greater(t, time.Since(startTime), 50*time.Millisecond, "delay should be at least 1 backoff")
7777
assert.Less(t, time.Since(startTime), 100*time.Millisecond, "delay should be less than 2 backoffs")
7878

7979
startTime = time.Now()
80-
shouldRetry = r.Next(req, resp)
80+
shouldRetry, _ = r.Next(resp, err)
8181
require.True(t, shouldRetry)
8282
assert.Greater(t, time.Since(startTime), 100*time.Millisecond, "delay should be at least 2 backoffs")
8383
assert.Less(t, time.Since(startTime), 200*time.Millisecond, "delay should be less than 3 backoffs")
8484

8585
// Success should stop retries
86-
shouldRetry = r.Next(req, &http.Response{StatusCode: http.StatusOK})
86+
shouldRetry, _ = r.Next(&http.Response{Request: req, StatusCode: http.StatusOK}, nil)
8787
require.False(t, shouldRetry)
8888
}
8989

@@ -94,7 +94,7 @@ func TestRequestRetrier_ContextCanceled(t *testing.T) {
9494
r := NewRequestRetrier(retry.Start(ctx), 0)
9595

9696
// No retries if context is candled
97-
shouldRetry := r.Next(nil, nil)
97+
shouldRetry, _ := r.Next(nil, nil)
9898
require.False(t, shouldRetry)
9999
}
100100

conjure-go-client/httpclient/internal/retry.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,15 @@ const (
5959
StatusCodeUnavailable = http.StatusServiceUnavailable
6060
)
6161

62-
func isRetryOtherResponse(resp *http.Response, err error, errCode int) (bool, *url.URL) {
62+
func isRetryOtherResponse(resp *http.Response, err error) (bool, *url.URL) {
63+
errCode, _ := StatusCodeFromError(err)
64+
// prioritize redirect from werror first
6365
if errCode == StatusCodeRetryOther || errCode == StatusCodeRetryTemporaryRedirect {
6466
locationStr, ok := LocationFromError(err)
65-
if ok {
66-
return true, parseLocationURL(locationStr)
67+
if !ok {
68+
return true, nil
6769
}
70+
return true, parseLocationURL(locationStr)
6871
}
6972

7073
if resp == nil {
@@ -74,8 +77,11 @@ func isRetryOtherResponse(resp *http.Response, err error, errCode int) (bool, *u
7477
resp.StatusCode != StatusCodeRetryTemporaryRedirect {
7578
return false, nil
7679
}
77-
locationStr := resp.Header.Get("Location")
78-
return true, parseLocationURL(locationStr)
80+
location, err := resp.Location()
81+
if err != nil {
82+
return true, nil
83+
}
84+
return true, location
7985
}
8086

8187
func parseLocationURL(locationStr string) *url.URL {

conjure-go-client/httpclient/internal/retry_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,14 @@ func TestRetryResponseParsers(t *testing.T) {
129129
},
130130
} {
131131
t.Run(test.Name, func(t *testing.T) {
132-
errCode, _ := StatusCodeFromError(test.RespErr)
133-
isRetryOther, retryOtherURL := isRetryOtherResponse(test.Response, test.RespErr, errCode)
132+
isRetryOther, retryOtherURL := isRetryOtherResponse(test.Response, test.RespErr)
134133
if assert.Equal(t, test.IsRetryOther, isRetryOther) && test.RetryOtherURL != "" {
135134
if assert.NotNil(t, retryOtherURL) {
136135
assert.Equal(t, test.RetryOtherURL, retryOtherURL.String())
137136
}
138137
}
139138

139+
errCode, _ := StatusCodeFromError(test.RespErr)
140140
isThrottle, throttleDur := isThrottleResponse(test.Response, errCode)
141141
if assert.Equal(t, test.IsThrottle, isThrottle) {
142142
assert.WithinDuration(t, time.Now().Add(test.ThrottleDuration), time.Now().Add(throttleDur), time.Second)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2022 Palantir Technologies. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package internal
16+
17+
import (
18+
"net/http"
19+
"sync"
20+
)
21+
22+
type roundRobinSelector struct {
23+
sync.Mutex
24+
nanoClock func() int64
25+
26+
offset int
27+
}
28+
29+
// NewRoundRobinURISelector returns a URI scorer that uses a round robin algorithm for selecting URIs when scoring
30+
// using a rand.Rand seeded by the nanoClock function. The middleware no-ops on each request.
31+
func NewRoundRobinURISelector(nanoClock func() int64) URISelector {
32+
return &roundRobinSelector{
33+
nanoClock: nanoClock,
34+
}
35+
}
36+
37+
// Select implements Selector interface
38+
func (s *roundRobinSelector) Select(uris []string, _ http.Header) (string, error) {
39+
s.Lock()
40+
defer s.Unlock()
41+
42+
s.offset = (s.offset + 1) % len(uris)
43+
44+
return uris[s.offset], nil
45+
}
46+
47+
func (s *roundRobinSelector) RoundTrip(req *http.Request, next http.RoundTripper) (*http.Response, error) {
48+
return next.RoundTrip(req)
49+
}

0 commit comments

Comments
 (0)