Skip to content

Commit cd25843

Browse files
feat: add ExponentialJitterBackoff backoff strategy
The new strategy is an extension of the default one that applies a jitter to avoid thundering herd.
1 parent 390c1d8 commit cd25843

File tree

2 files changed

+181
-1
lines changed

2 files changed

+181
-1
lines changed

client.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,36 @@ func LinearJitterBackoff(min, max time.Duration, attemptNum int, resp *http.Resp
638638
return time.Duration(jitterMin * int64(attemptNum))
639639
}
640640

641+
// ExponentialJitterBackoff is an extension of DefaultBackoff that applies
642+
// a jitter to avoid thundering herd.
643+
func ExponentialJitterBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
644+
baseBackoff := DefaultBackoff(min, max, attemptNum, resp)
645+
646+
if resp != nil {
647+
if retryAfterHeaders := resp.Header["Retry-After"]; len(retryAfterHeaders) > 0 && retryAfterHeaders[0] != "" {
648+
return baseBackoff
649+
}
650+
}
651+
652+
// Seed randomization; it's OK to do it every time
653+
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
654+
655+
jitter := rnd.Float64()*0.5 - 0.25 // Random value between -0.25 e +0.25
656+
jitteredSleep := time.Duration(float64(baseBackoff) * (1.0 + jitter))
657+
658+
return clampDuration(jitteredSleep, min, max)
659+
}
660+
661+
func clampDuration(d, min, max time.Duration) time.Duration {
662+
if d < min {
663+
return min
664+
}
665+
if d > max {
666+
return max
667+
}
668+
return d
669+
}
670+
641671
// PassthroughErrorHandler is an ErrorHandler that directly passes through the
642672
// values from the net/http library for the final request. The body is not
643673
// closed.

client_test.go

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ func testClientDo(t *testing.T, body interface{}) {
251251
}
252252

253253
if resp.StatusCode != 200 {
254-
t.Fatalf("exected 200, got: %d", resp.StatusCode)
254+
t.Fatalf("expected 200, got: %d", resp.StatusCode)
255255
}
256256

257257
if retryCount < 0 {
@@ -896,6 +896,156 @@ func TestClient_DefaultBackoff(t *testing.T) {
896896
}
897897
}
898898

899+
func TestClient_ExponentialJitterBackoff(t *testing.T) {
900+
const retriableStatusCode int = http.StatusServiceUnavailable
901+
902+
t.Run("with non-empty first value of Retry-After header in response", func(t *testing.T) {
903+
response := &http.Response{
904+
StatusCode: retriableStatusCode,
905+
Header: http.Header{
906+
"Content-Type": []string{"application/json"},
907+
"Retry-After": []string{"42"},
908+
},
909+
}
910+
backoff := ExponentialJitterBackoff(retryWaitMin, retryWaitMax, 3, response)
911+
expectedBackoff := 42 * time.Second
912+
913+
if backoff != expectedBackoff {
914+
t.Fatalf("expected default backoff from Retry-After header (%s), got %s", expectedBackoff, backoff)
915+
}
916+
})
917+
918+
invalidRetryAfterHeaderCases := []struct {
919+
name string
920+
makeResponse func() *http.Response
921+
}{
922+
{
923+
name: "with empty first value of Retry-After header in response",
924+
makeResponse: func() *http.Response {
925+
return &http.Response{
926+
StatusCode: retriableStatusCode,
927+
Header: http.Header{
928+
"Content-Type": []string{"application/json"},
929+
"Retry-After": []string{""},
930+
},
931+
}
932+
},
933+
},
934+
{
935+
name: "without Retry-After header in response",
936+
makeResponse: func() *http.Response {
937+
return &http.Response{
938+
StatusCode: retriableStatusCode,
939+
Header: http.Header{"Content-Type": []string{"application/json"}},
940+
}
941+
},
942+
},
943+
{
944+
name: "with nil response",
945+
makeResponse: func() *http.Response {
946+
return nil
947+
},
948+
},
949+
}
950+
951+
for _, irahc := range invalidRetryAfterHeaderCases {
952+
t.Run(irahc.name, func(t *testing.T) {
953+
attemptNumCases := []struct {
954+
name string
955+
attemptNum int
956+
expectedBackoffWithoutJitter time.Duration
957+
}{
958+
{
959+
name: "with first attempt",
960+
attemptNum: 0,
961+
expectedBackoffWithoutJitter: retryWaitMin,
962+
},
963+
{
964+
name: "with low attempt number",
965+
attemptNum: 3,
966+
expectedBackoffWithoutJitter: 16 * time.Second,
967+
},
968+
{
969+
name: "with high attempt number",
970+
attemptNum: 10,
971+
expectedBackoffWithoutJitter: retryWaitMax,
972+
},
973+
}
974+
975+
for _, anc := range attemptNumCases {
976+
t.Run(anc.name, func(t *testing.T) {
977+
backoff := ExponentialJitterBackoff(defaultRetryWaitMin, defaultRetryWaitMax, anc.attemptNum, irahc.makeResponse())
978+
expectedJitterDelta := float64(anc.expectedBackoffWithoutJitter) * 0.25
979+
expectedMinTime := anc.expectedBackoffWithoutJitter - time.Duration(expectedJitterDelta)
980+
expectedMaxTime := anc.expectedBackoffWithoutJitter + time.Duration(expectedJitterDelta)
981+
expectedBackoffLowerLimit := max(expectedMinTime, retryWaitMin)
982+
expectedBackoffUpperLimit := min(expectedMaxTime, retryWaitMax)
983+
984+
t.Run("returns exponential backoff with jitter, clamped within min and max limits", func(t *testing.T) {
985+
if backoff < expectedBackoffLowerLimit || backoff > expectedBackoffUpperLimit {
986+
t.Fatalf("expected backoff to be within range [%s, %s], got %s", expectedBackoffLowerLimit, expectedBackoffUpperLimit, backoff)
987+
}
988+
})
989+
})
990+
}
991+
})
992+
}
993+
}
994+
995+
func Test_clampDuration(t *testing.T) {
996+
const (
997+
minDuration time.Duration = 500 * time.Millisecond
998+
maxDuration time.Duration = 10 * time.Minute
999+
)
1000+
1001+
testCases := []struct {
1002+
name string
1003+
errorMessage string
1004+
duration time.Duration
1005+
expectedClampedDuration time.Duration
1006+
}{
1007+
{
1008+
name: "with duration below min value",
1009+
errorMessage: "should return the min value",
1010+
duration: 60 * time.Microsecond,
1011+
expectedClampedDuration: minDuration,
1012+
},
1013+
{
1014+
name: "with duration equal to min value",
1015+
errorMessage: "should return the min value",
1016+
duration: minDuration,
1017+
expectedClampedDuration: minDuration,
1018+
},
1019+
{
1020+
name: "with duration strictly within min and max range",
1021+
errorMessage: "should return the given value",
1022+
duration: 45 * time.Second,
1023+
expectedClampedDuration: 45 * time.Second,
1024+
},
1025+
{
1026+
name: "with duration equal to max value",
1027+
errorMessage: "should return the max value",
1028+
duration: maxDuration,
1029+
expectedClampedDuration: maxDuration,
1030+
},
1031+
{
1032+
name: "with duration above max value",
1033+
errorMessage: "should return the max value",
1034+
duration: 2 * time.Hour,
1035+
expectedClampedDuration: maxDuration,
1036+
},
1037+
}
1038+
1039+
for _, tc := range testCases {
1040+
t.Run(tc.name, func(t *testing.T) {
1041+
duration := clampDuration(tc.duration, minDuration, maxDuration)
1042+
if duration != tc.expectedClampedDuration {
1043+
t.Fatalf("expected duration %s, got %s", expectedBackoff, backoff)
1044+
}
1045+
})
1046+
}
1047+
}
1048+
8991049
func TestClient_DefaultRetryPolicy_TLS(t *testing.T) {
9001050
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
9011051
w.WriteHeader(200)

0 commit comments

Comments
 (0)