Skip to content

Commit 2056202

Browse files
Tweak retry behaviour (#131)
In a retryable error situation should not retry if the client method is not considered retryable. Signed-off-by: Raymond Cypher <[email protected]>
2 parents e2855eb + 9f2e4b5 commit 2056202

File tree

3 files changed

+135
-42
lines changed

3 files changed

+135
-42
lines changed

internal/client/client.go

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,11 @@ const (
4545

4646
type clientMethod int
4747

48+
//go:generate go run golang.org/x/tools/cmd/stringer -type=clientMethod
49+
4850
const (
49-
openSession clientMethod = iota
51+
unknown clientMethod = iota
52+
openSession
5053
closeSession
5154
fetchResults
5255
getResultSetMetadata
@@ -56,7 +59,9 @@ const (
5659
cancelOperation
5760
)
5861

59-
var nonRetryableClientMethods map[clientMethod]any = map[clientMethod]any{executeStatement: struct{}{}}
62+
var nonRetryableClientMethods map[clientMethod]any = map[clientMethod]any{
63+
executeStatement: struct{}{},
64+
unknown: struct{}{}}
6065

6166
// OpenSession is a wrapper around the thrift operation OpenSession
6267
// If RecordResults is true, the results will be marshalled to JSON format and written to OpenSession<index>.json
@@ -314,6 +319,10 @@ func SprintGuid(bts []byte) string {
314319
var retryableStatusCodes = map[int]any{http.StatusTooManyRequests: struct{}{}, http.StatusServiceUnavailable: struct{}{}}
315320

316321
func isRetryableServerResponse(resp *http.Response) bool {
322+
if resp == nil {
323+
return false
324+
}
325+
317326
_, ok := retryableStatusCodes[resp.StatusCode]
318327
return ok
319328
}
@@ -488,29 +497,24 @@ func RetryPolicy(ctx context.Context, resp *http.Response, err error) (bool, err
488497
return false, ctx.Err()
489498
}
490499

491-
if err != nil {
492-
if v, ok := err.(*url.Error); ok {
493-
s := v.Error()
494-
for _, re := range errorRes {
495-
if re.MatchString(s) {
496-
return false, v
497-
}
498-
}
500+
caller, _ := ctx.Value(ClientMethod).(clientMethod)
501+
_, nonRetryableClientMethod := nonRetryableClientMethods[caller]
499502

500-
if _, ok := v.Err.(x509.UnknownAuthorityError); ok {
501-
return false, v
502-
}
503+
if err != nil {
504+
if isRetryableError(err) && !nonRetryableClientMethod {
505+
return true, nil
503506
}
504507

505-
// The error is likely recoverable so retry.
506-
return true, nil
508+
return false, err
507509
}
508510

509-
var checkErr error
510-
if resp.StatusCode != http.StatusOK {
511-
checkErr = fmt.Errorf("unexpected HTTP status %s", resp.Status)
511+
// shouldn't retry on no response or success
512+
if resp == nil || resp.StatusCode == http.StatusOK {
513+
return false, nil
512514
}
513515

516+
checkErr := fmt.Errorf("unexpected HTTP status %s", resp.Status)
517+
514518
// 429 Too Many Requests or 503 service unavailable is recoverable. Sometimes the server puts
515519
// a Retry-After response header to indicate when the server is
516520
// available to start processing request from client.
@@ -523,20 +527,37 @@ func RetryPolicy(ctx context.Context, resp *http.Response, err error) (bool, err
523527
return true, dbsqlerrint.NewRetryableError(checkErr, retryAfter)
524528
}
525529

526-
if resp.StatusCode == 0 || (resp.StatusCode >= 500 && resp.StatusCode != http.StatusNotImplemented) {
527-
callerAny := ctx.Value(ClientMethod)
528-
if caller, ok := callerAny.(clientMethod); ok {
529-
if _, noRetry := nonRetryableClientMethods[caller]; !noRetry {
530-
return true, checkErr
531-
}
532-
}
530+
if !nonRetryableClientMethod && (resp.StatusCode == 0 || (resp.StatusCode >= 500 && resp.StatusCode != http.StatusNotImplemented)) {
531+
return true, checkErr
533532
}
534533

535534
// checkErr will be non-nil if the response code was not StatusOK.
536535
// Returning it here ensures that the error handler will be called.
537536
return false, checkErr
538537
}
539538

539+
func isRetryableError(err error) bool {
540+
if err == nil {
541+
return false
542+
}
543+
544+
if v, ok := err.(*url.Error); ok {
545+
s := v.Error()
546+
for _, re := range errorRes {
547+
if re.MatchString(s) {
548+
return false
549+
}
550+
}
551+
552+
if _, ok := v.Err.(x509.UnknownAuthorityError); ok {
553+
return false
554+
}
555+
}
556+
557+
// The error is likely recoverable so retry.
558+
return true
559+
}
560+
540561
func backoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
541562
// honour the Retry-After header
542563
if resp != nil && resp.Header != nil {

internal/client/client_test.go

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/http"
77
"net/url"
88
"testing"
9+
"time"
910

1011
"github.com/pkg/errors"
1112
"github.com/stretchr/testify/require"
@@ -47,36 +48,76 @@ func TestSprintByteId(t *testing.T) {
4748

4849
func TestRetryPolicy(t *testing.T) {
4950

50-
t.Run("test handling url error", func(t *testing.T) {
51-
urlErr := &url.Error{Err: errors.New("EOF")}
51+
t.Run("test handling error", func(t *testing.T) {
5252
resp := &http.Response{}
5353

54-
urlErr.Err = x509.UnknownAuthorityError{}
55-
b, err := RetryPolicy(context.Background(), resp, urlErr)
56-
require.False(t, b)
57-
require.Equal(t, urlErr, err)
54+
retryableErrors := []error{
55+
&url.Error{Err: errors.New("EOF")},
56+
&url.Error{Err: errors.New("other error")},
57+
errors.New("non-url error")}
5858

59+
nonRetryableErrors := []error{&url.Error{Err: x509.UnknownAuthorityError{}}}
5960
errMsgs := []string{"stopped after 12 redirects", "unsupported protocol scheme", "certificate is not trusted"}
6061
for _, msg := range errMsgs {
61-
urlErr.Err = errors.New(msg)
62-
b, err := RetryPolicy(context.Background(), resp, urlErr)
62+
nonRetryableErrors = append(nonRetryableErrors, &url.Error{Err: errors.New(msg)})
63+
}
64+
65+
// should never retry if context is cancelled/timed out, regardless of client method or error
66+
checkCtxCancelled := func(callingContext context.Context, err error) {
67+
cancelled, cancel := context.WithCancel(callingContext)
68+
cancel()
69+
timedOut, cancel2 := context.WithDeadline(callingContext, time.Now().Add(-10*time.Second))
70+
defer cancel2()
71+
72+
// should never retry if context is cancelled/timed out
73+
b, checkErr := RetryPolicy(cancelled, resp, err)
74+
require.False(t, b)
75+
require.Equal(t, cancelled.Err(), checkErr)
76+
b, checkErr = RetryPolicy(timedOut, resp, err)
6377
require.False(t, b)
64-
require.Equal(t, urlErr, err)
78+
require.Equal(t, timedOut.Err(), checkErr)
6579
}
6680

67-
urlErr.Err = errors.New("other error")
68-
b, err = RetryPolicy(context.Background(), resp, urlErr)
69-
require.True(t, b)
70-
require.Nil(t, err)
81+
for _, err := range retryableErrors {
82+
// Should retry only if client method is also retryable
83+
for cm := range _clientMethod_index {
84+
ctx := context.WithValue(context.Background(), ClientMethod, clientMethod(cm))
85+
86+
checkCtxCancelled(ctx, err)
87+
88+
_, nonRetryableClientMethod := nonRetryableClientMethods[clientMethod(cm)]
89+
b, checkErr := RetryPolicy(ctx, resp, err)
90+
if nonRetryableClientMethod {
91+
require.False(t, b)
92+
require.Equal(t, err, checkErr)
93+
} else {
94+
require.True(t, b)
95+
require.Nil(t, checkErr)
96+
}
97+
}
98+
}
7199

72-
b, err = RetryPolicy(context.Background(), resp, errors.New("non-url error"))
73-
require.True(t, b)
74-
require.Nil(t, err)
100+
for _, err := range nonRetryableErrors {
101+
// Should non retry regardless of client method
102+
for cm := range _clientMethod_index {
103+
ctx := context.WithValue(context.Background(), ClientMethod, clientMethod(cm))
104+
105+
checkCtxCancelled(ctx, err)
75106

107+
b, checkErr := RetryPolicy(ctx, resp, err)
108+
require.False(t, b)
109+
require.Equal(t, err, checkErr)
110+
}
111+
}
76112
})
77113

78114
t.Run("test handling status codes", func(t *testing.T) {
79-
resp := &http.Response{}
115+
var resp *http.Response
116+
retry, err := RetryPolicy(context.Background(), resp, nil)
117+
require.False(t, retry)
118+
require.Nil(t, err)
119+
120+
resp = &http.Response{}
80121
// 429 nd 503 are always retryable
81122
retryableCodes := []int{429, 503}
82123
// maybe retryable codes: 0 and >= 500, but not 501

internal/client/clientmethod_string.go

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)