Skip to content

Commit b21f25d

Browse files
authored
Add retry error budget (#158)
1 parent a7e5759 commit b21f25d

8 files changed

Lines changed: 478 additions & 9 deletions

File tree

httpclient/client.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package httpclient
22

33
import (
4+
"context"
45
"io"
56
"net/http"
67
"slices"
@@ -17,9 +18,10 @@ type Client struct {
1718
plugins []heimdall.Plugin
1819
timeout *time.Duration
1920

20-
retrier heimdall.Retriable
21-
retryCount int
22-
retryableCodes []int
21+
retrier heimdall.Retriable
22+
retryCount int
23+
retryableCodes []int
24+
retryErrorBudget *internal.ErrorBudget
2325
}
2426

2527
const (
@@ -167,7 +169,7 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
167169
if err != nil {
168170
errs = append(errs, err)
169171
c.reportError(request, err)
170-
if internal.IsCtxDone(request.Context()) {
172+
if c.skipRetry(request.Context()) {
171173
break
172174
}
173175
continue
@@ -176,20 +178,29 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
176178

177179
if _, ok := slices.BinarySearch(c.retryableCodes, response.StatusCode); ok ||
178180
response.StatusCode >= http.StatusInternalServerError {
179-
if internal.IsCtxDone(request.Context()) {
181+
if c.skipRetry(request.Context()) {
180182
break
181183
}
182-
183184
continue
184185
}
185186

186187
errs = nil // Clear errors if any iteration succeeds
188+
_ = c.retryErrorBudget.Success()
187189
break
188190
}
189191

190192
return response, internal.BuildMultiError(errs)
191193
}
192194

195+
func (c *Client) skipRetry(ctx context.Context) bool {
196+
if internal.IsCtxDone(ctx) {
197+
_ = c.retryErrorBudget.Success()
198+
return true
199+
}
200+
201+
return c.retryErrorBudget.Failure()
202+
}
203+
193204
func (c *Client) reportRequestStart(request *http.Request) {
194205
for _, plugin := range c.plugins {
195206
plugin.OnRequestStart(request)

httpclient/client_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,52 @@ func TestHTTPClientPostRetriesOnFailure(t *testing.T) {
307307
assert.Equal(t, noOfCalls, count)
308308
}
309309

310+
func TestHTTPClientRetriesWithBudgetGetOnFailure5xx(t *testing.T) {
311+
t.Parallel()
312+
313+
count := 0
314+
backoffInterval := 1 * time.Millisecond
315+
maximumJitterInterval := 1 * time.Millisecond
316+
317+
client := NewClient(
318+
WithHTTPTimeout(10*time.Millisecond),
319+
WithRetryCount(4),
320+
WithRetryErrorBudgetToken(100, 0.1),
321+
WithRetrier(heimdall.NewRetrier(heimdall.NewConstantBackoff(backoffInterval, maximumJitterInterval))),
322+
)
323+
324+
dummyHandler := func(w http.ResponseWriter, r *http.Request) {
325+
w.WriteHeader(http.StatusInternalServerError)
326+
_, _ = w.Write([]byte(`{ "response": "something went wrong" }`))
327+
count = count + 1
328+
}
329+
330+
server := httptest.NewServer(http.HandlerFunc(dummyHandler))
331+
defer server.Close()
332+
333+
for attempt := range 10 { // under budget
334+
currCount := count
335+
response, err := client.Get(server.URL, http.Header{})
336+
require.NoError(t, err)
337+
338+
assert.Equal(t, 5, count-currCount, "attempt #%d", attempt)
339+
assert.NotNil(t, response)
340+
assert.Equal(t, http.StatusInternalServerError, response.StatusCode)
341+
assert.Equal(t, "{ \"response\": \"something went wrong\" }", respBody(t, response))
342+
}
343+
344+
for attempt := range 10 { // over budget, no retry
345+
currCount := count
346+
response, err := client.Get(server.URL, http.Header{})
347+
require.NoError(t, err)
348+
349+
assert.Equal(t, 1, count-currCount, "attempt #%d", attempt)
350+
assert.NotNil(t, response)
351+
assert.Equal(t, http.StatusInternalServerError, response.StatusCode)
352+
assert.Equal(t, "{ \"response\": \"something went wrong\" }", respBody(t, response))
353+
}
354+
}
355+
310356
func BenchmarkHTTPClientPostRetriesOnFailure(b *testing.B) {
311357
noOfRetries := 3
312358
backoffInterval := 1 * time.Millisecond

httpclient/options.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"time"
66

77
"github.com/gojek/heimdall/v7"
8+
"github.com/gojek/heimdall/v7/internal"
89
)
910

1011
// Option represents the client options
@@ -52,3 +53,34 @@ func WithRetryableStatusCodes(statusCodes ...int) Option {
5253
c.retryableCodes = codes
5354
}
5455
}
56+
57+
// WithRetryErrorBudgetToken creates a weighted token retry error budget with the following token details.
58+
//
59+
// maxToken: The maximum/initial token value which is used to calculate token threshold(i.e. maxToken/2)
60+
// tokenRatio: The allowed ratio of failure in comparison to success.
61+
func WithRetryErrorBudgetToken(maxToken int32, tokenRatio float32) Option {
62+
return func(c *Client) {
63+
c.retryErrorBudget = internal.NewTokenErrorBudget(maxToken, tokenRatio)
64+
}
65+
}
66+
67+
// WithRetryErrorBudgetPercent creates a weighted token retry error budget with the following failure details.
68+
//
69+
// minFailureVolume: The minimum failure required.
70+
// failurePercent: The failure percentage (0-100).
71+
//
72+
// Note: To determine if budget is exceeded we use recent event which satisfies following
73+
//
74+
// failureEvent <= maxFailureEvent
75+
// successEvent = (maxFailureEvent-failureEvent) / allowedSuccessPerFailure
76+
// totalEvent = failureEvent + successEvent
77+
//
78+
// Where
79+
//
80+
// maxFailureEvent = minFailureVolume * 2
81+
// allowedSuccessPerFailure = (100 - failurePercent) / failurePercent
82+
func WithRetryErrorBudgetPercent(minFailureVolume int32, failurePercent float32) Option {
83+
return func(c *Client) {
84+
c.retryErrorBudget = internal.NewPercentErrorBudget(minFailureVolume, failurePercent)
85+
}
86+
}

hystrix/hystrix_client.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ type Client struct {
3232
errorPercentThreshold int
3333
fallbackFunc func(ctx context.Context, err error) error
3434

35-
retrier heimdall.Retriable
36-
retryCount int
37-
retryableCodes []int
35+
retrier heimdall.Retriable
36+
retryCount int
37+
retryableCodes []int
38+
retryErrorBudget *internal.ErrorBudget
3839

3940
statsD *plugins.StatsdCollectorConfig
4041
}
@@ -209,6 +210,11 @@ func (hhc *Client) Do(request *http.Request) (*http.Response, error) {
209210

210211
response, err = hhc.hystrixDo(request)
211212
if err == nil || internal.IsCtxDone(request.Context()) {
213+
_ = hhc.retryErrorBudget.Success()
214+
break
215+
}
216+
217+
if hhc.retryErrorBudget.Failure() {
212218
break
213219
}
214220
}

hystrix/hystrix_client_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,58 @@ func TestHystrixHTTPClientRetriesGetOnFailure5xx(t *testing.T) {
334334
assert.Equal(t, "{ \"response\": \"something went wrong\" }", respBody(t, response))
335335
}
336336

337+
func TestHystrixHTTPClientRetriesWithBudgetGetOnFailure5xx(t *testing.T) {
338+
t.Parallel()
339+
340+
count := 0
341+
backoffInterval := 1 * time.Millisecond
342+
maximumJitterInterval := 1 * time.Millisecond
343+
344+
client := NewClient(
345+
WithHTTPTimeout(10*time.Millisecond),
346+
WithCommandName("some_command_name_5xx_with_budget"),
347+
WithHystrixTimeout(10*time.Millisecond),
348+
WithMaxConcurrentRequests(100),
349+
WithErrorPercentThreshold(25),
350+
WithSleepWindow(100),
351+
WithRequestVolumeThreshold(100),
352+
WithRetryCount(4),
353+
WithRetryErrorBudgetToken(100, 0.1),
354+
WithRetrier(heimdall.NewRetrier(heimdall.NewConstantBackoff(backoffInterval, maximumJitterInterval))),
355+
)
356+
357+
dummyHandler := func(w http.ResponseWriter, r *http.Request) {
358+
w.WriteHeader(http.StatusInternalServerError)
359+
_, _ = w.Write([]byte(`{ "response": "something went wrong" }`))
360+
count = count + 1
361+
}
362+
363+
server := httptest.NewServer(http.HandlerFunc(dummyHandler))
364+
defer server.Close()
365+
366+
for attempt := range 10 { // under budget
367+
currCount := count
368+
response, err := client.Get(server.URL, http.Header{})
369+
require.NoError(t, err)
370+
371+
assert.Equal(t, 5, count-currCount, "attempt #%d", attempt)
372+
assert.NotNil(t, response)
373+
assert.Equal(t, http.StatusInternalServerError, response.StatusCode)
374+
assert.Equal(t, "{ \"response\": \"something went wrong\" }", respBody(t, response))
375+
}
376+
377+
for attempt := range 10 { // over budget, no retry
378+
currCount := count
379+
response, err := client.Get(server.URL, http.Header{})
380+
require.NoError(t, err)
381+
382+
assert.Equal(t, 1, count-currCount, "attempt #%d", attempt)
383+
assert.NotNil(t, response)
384+
assert.Equal(t, http.StatusInternalServerError, response.StatusCode)
385+
assert.Equal(t, "{ \"response\": \"something went wrong\" }", respBody(t, response))
386+
}
387+
}
388+
337389
func BenchmarkHystrixHTTPClientRetriesGetOnFailure(b *testing.B) {
338390
backoffInterval := 1 * time.Millisecond
339391
maximumJitterInterval := 1 * time.Millisecond

hystrix/options.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/afex/hystrix-go/plugins"
99
"github.com/gojek/heimdall/v7"
1010
"github.com/gojek/heimdall/v7/httpclient"
11+
"github.com/gojek/heimdall/v7/internal"
1112
)
1213

1314
// Option represents the hystrix client options
@@ -118,3 +119,34 @@ func WithRetryableStatusCodes(statusCodes ...int) Option {
118119
c.retryableCodes = codes
119120
}
120121
}
122+
123+
// WithRetryErrorBudgetToken creates a weighted token retry error budget with the following token details.
124+
//
125+
// maxToken: The maximum/initial token value which is used to calculate token threshold(i.e. maxToken/2)
126+
// tokenRatio: The allowed ratio of failure in comparison to success.
127+
func WithRetryErrorBudgetToken(maxToken int32, tokenRatio float32) Option {
128+
return func(c *Client) {
129+
c.retryErrorBudget = internal.NewTokenErrorBudget(maxToken, tokenRatio)
130+
}
131+
}
132+
133+
// WithRetryErrorBudgetPercent creates a weighted token retry error budget with the following failure details.
134+
//
135+
// minFailureVolume: The minimum failure required.
136+
// failurePercent: The failure percentage (0-100).
137+
//
138+
// Note: To determine if budget is exceeded we use recent event which satisfies following
139+
//
140+
// failureEvent <= maxFailureEvent
141+
// successEvent = (maxFailureEvent-failureEvent) / allowedSuccessPerFailure
142+
// totalEvent = failureEvent + successEvent
143+
//
144+
// Where
145+
//
146+
// maxFailureEvent = minFailureVolume * 2
147+
// allowedSuccessPerFailure = (100 - failurePercent) / failurePercent
148+
func WithRetryErrorBudgetPercent(minFailureVolume int32, failurePercent float32) Option {
149+
return func(c *Client) {
150+
c.retryErrorBudget = internal.NewPercentErrorBudget(minFailureVolume, failurePercent)
151+
}
152+
}

0 commit comments

Comments
 (0)