Skip to content

Commit 4b607a9

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 4b607a9

File tree

2 files changed

+162
-0
lines changed

2 files changed

+162
-0
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: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,138 @@ 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 := testharness.FakeHTTPResponse(retriableStatusCode, nil)
904+
response.Header.Add("Retry-After", "42")
905+
backoff := ExponentialJitterBackoff(retryWaitMin, retryWaitMax, 3, response)
906+
907+
t.Run("returns default backoff", func(t *testing.T) {
908+
assert.Equal(t, 42*time.Second, backoff)
909+
})
910+
})
911+
912+
invalidRetryAfterHeaderCases := []struct {
913+
name string
914+
makeResponse func() *http.Response
915+
}{
916+
{
917+
name: "with empty first value of Retry-After header in response",
918+
makeResponse: func() *http.Response {
919+
response := testharness.FakeHTTPResponse(retriableStatusCode, nil)
920+
response.Header.Set("Retry-After", "")
921+
return response
922+
},
923+
},
924+
{
925+
name: "without Retry-After header in response",
926+
makeResponse: func() *http.Response {
927+
return testharness.FakeHTTPResponse(retriableStatusCode, nil)
928+
},
929+
},
930+
{
931+
name: "with nil response",
932+
makeResponse: func() *http.Response {
933+
return nil
934+
},
935+
},
936+
}
937+
938+
for _, irahc := range invalidRetryAfterHeaderCases {
939+
t.Run(irahc.name, func(t *testing.T) {
940+
attemptNumCases := []struct {
941+
name string
942+
attemptNum int
943+
expectedBackoffWithoutJitter time.Duration
944+
}{
945+
{
946+
name: "with first attempt",
947+
attemptNum: 0,
948+
expectedBackoffWithoutJitter: retryWaitMin,
949+
},
950+
{
951+
name: "with low attempt number",
952+
attemptNum: 3,
953+
expectedBackoffWithoutJitter: 16 * time.Second,
954+
},
955+
{
956+
name: "with high attempt number",
957+
attemptNum: 10,
958+
expectedBackoffWithoutJitter: retryWaitMax,
959+
},
960+
}
961+
962+
for _, anc := range attemptNumCases {
963+
t.Run(anc.name, func(t *testing.T) {
964+
backoff := ExponentialJitterBackoff(defaultRetryWaitMin, defaultRetryWaitMax, anc.attemptNum, irahc.makeResponse())
965+
expectedJitterDelta := float64(anc.expectedBackoffWithoutJitter) * 0.25
966+
expectedMinTime := anc.expectedBackoffWithoutJitter - time.Duration(expectedJitterDelta)
967+
expectedMaxTime := anc.expectedBackoffWithoutJitter + time.Duration(expectedJitterDelta)
968+
969+
t.Run("returns exponential backoff with jitter, clamped within min and max limits", func(t *testing.T) {
970+
assert.GreaterOrEqual(t, backoff, max(expectedMinTime, retryWaitMin))
971+
assert.LessOrEqual(t, backoff, min(expectedMaxTime, retryWaitMax))
972+
})
973+
})
974+
}
975+
})
976+
}
977+
}
978+
979+
func Test_clampDuration(t *testing.T) {
980+
const (
981+
minDuration time.Duration = 500 * time.Millisecond
982+
maxDuration time.Duration = 10 * time.Minute
983+
)
984+
985+
testCases := []struct {
986+
name string
987+
errorMessage string
988+
duration time.Duration
989+
expectedClampedDuration time.Duration
990+
}{
991+
{
992+
name: "with duration below min value",
993+
errorMessage: "should return the min value",
994+
duration: 60 * time.Microsecond,
995+
expectedClampedDuration: minDuration,
996+
},
997+
{
998+
name: "with duration equal to min value",
999+
errorMessage: "should return the min value",
1000+
duration: minDuration,
1001+
expectedClampedDuration: minDuration,
1002+
},
1003+
{
1004+
name: "with duration strictly within min and max range",
1005+
errorMessage: "should return the given value",
1006+
duration: 45 * time.Second,
1007+
expectedClampedDuration: 45 * time.Second,
1008+
},
1009+
{
1010+
name: "with duration equal to max value",
1011+
errorMessage: "should return the max value",
1012+
duration: maxDuration,
1013+
expectedClampedDuration: maxDuration,
1014+
},
1015+
{
1016+
name: "with duration above max value",
1017+
errorMessage: "should return the max value",
1018+
duration: 2 * time.Hour,
1019+
expectedClampedDuration: maxDuration,
1020+
},
1021+
}
1022+
1023+
for _, tc := range testCases {
1024+
t.Run(tc.name, func(t *testing.T) {
1025+
duration := clampDuration(tc.duration, minDuration, maxDuration)
1026+
assert.Equal(t, tc.expectedClampedDuration, duration, tc.errorMessage)
1027+
})
1028+
}
1029+
}
1030+
8991031
func TestClient_DefaultRetryPolicy_TLS(t *testing.T) {
9001032
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
9011033
w.WriteHeader(200)

0 commit comments

Comments
 (0)