Skip to content

Commit 0b6fc64

Browse files
Support errors.Is for returned errors. (#114)
* replace valkyrie.MultiError with custom MultiError - supports errors.Is and errors.Cause - improved performance (no mutex required) * simplify & move mutli error, remove Cause method --------- Co-authored-by: Anmol Chopra <anmol.chopra@gojek.com>
1 parent 6945025 commit 0b6fc64

6 files changed

Lines changed: 147 additions & 11 deletions

File tree

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ go 1.24
44

55
require (
66
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5
7-
github.com/gojek/valkyrie v0.0.0-20180215180059-6aee720afcdf
87
github.com/pkg/errors v0.9.1
98
github.com/stretchr/testify v1.11.1
109
)

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c h1:
66
github.com/cactus/go-statsd-client/statsd v0.0.0-20200423205355-cb0885a1018c/go.mod h1:l/bIBLeOl9eX+wxJAzxS4TveKRtAqlyDpHjhkfO0MEI=
77
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
88
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9-
github.com/gojek/valkyrie v0.0.0-20180215180059-6aee720afcdf h1:5xRGbUdOmZKoDXkGx5evVLehuCMpuO1hl701bEQqXOM=
10-
github.com/gojek/valkyrie v0.0.0-20180215180059-6aee720afcdf/go.mod h1:QzhUKaYKJmcbTnCYCAVQrroCOY7vOOI8cSQ4NbuhYf0=
119
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
1210
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
1311
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=

httpclient/client.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77

88
"github.com/gojek/heimdall/v7"
99
"github.com/gojek/heimdall/v7/internal"
10-
"github.com/gojek/valkyrie"
1110
"github.com/pkg/errors"
1211
)
1312

@@ -138,7 +137,7 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
138137
reqGetBody = request.GetBody
139138
}
140139

141-
multiErr := &valkyrie.MultiError{}
140+
var errs []error
142141
var response *http.Response
143142

144143
for i := 0; i <= c.retryCount; i++ {
@@ -148,15 +147,17 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
148147
}
149148
if i > 0 {
150149
if err := internal.SleepInterruptible(request.Context(), c.retrier.NextInterval(i-1)); err != nil {
151-
multiErr.Push(err.Error())
150+
errs = append(errs, err)
152151
c.reportError(request, err)
153152
// no point of retrying after context has been cancelled
154153
break
155154
}
156155

157156
request, err = internal.CloneRequest(request, reqGetBody) // Clone the request to reset the body for retry
158157
if err != nil {
159-
return nil, err
158+
errs = append(errs, err)
159+
c.reportError(request, err)
160+
break
160161
}
161162
}
162163

@@ -165,7 +166,7 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
165166
response, err = c.client.Do(request)
166167

167168
if err != nil {
168-
multiErr.Push(err.Error())
169+
errs = append(errs, err)
169170
c.reportError(request, err)
170171
if internal.IsCtxDone(request.Context()) {
171172
break
@@ -182,11 +183,11 @@ func (c *Client) Do(request *http.Request) (*http.Response, error) {
182183
continue
183184
}
184185

185-
multiErr = &valkyrie.MultiError{} // Clear errors if any iteration succeeds
186+
errs = nil // Clear errors if any iteration succeeds
186187
break
187188
}
188189

189-
return response, multiErr.HasError()
190+
return response, internal.BuildMultiError(errs)
190191
}
191192

192193
func (c *Client) reportRequestStart(request *http.Request) {

httpclient/client_test.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package httpclient
33
import (
44
"bytes"
55
"context"
6+
"errors"
67
"io"
78
"net/http"
89
"net/http/httptest"
10+
"net/url"
911
"strings"
1012
"sync/atomic"
1113
"testing"
@@ -510,6 +512,36 @@ func TestCustomHTTPClientHeaderSuccess(t *testing.T) {
510512
assert.Equal(t, "{ \"response\": \"ok\" }", string(body))
511513
}
512514

515+
func TestHTTPClientContextTimeout(t *testing.T) {
516+
client := NewClient(WithHTTPTimeout(1000 * time.Millisecond))
517+
518+
dummyHandler := func(w http.ResponseWriter, r *http.Request) {
519+
assert.Equal(t, http.MethodGet, r.Method)
520+
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
521+
assert.Equal(t, "en", r.Header.Get("Accept-Language"))
522+
523+
time.Sleep(100 * time.Millisecond)
524+
525+
w.WriteHeader(http.StatusOK)
526+
w.Write([]byte(`{ "response": "ok" }`))
527+
}
528+
529+
server := httptest.NewServer(http.HandlerFunc(dummyHandler))
530+
defer server.Close()
531+
532+
ctxt, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
533+
defer cancel()
534+
535+
req, err := http.NewRequestWithContext(ctxt, http.MethodGet, server.URL, nil)
536+
require.NoError(t, err)
537+
req.Header.Set("Content-Type", "application/json")
538+
req.Header.Set("Accept-Language", "en")
539+
response, err := client.Do(req)
540+
require.ErrorIs(t, err, context.DeadlineExceeded)
541+
assert.Equal(t, &url.Error{Op: "Get", URL: server.URL, Err: context.DeadlineExceeded}, err)
542+
require.Nil(t, response)
543+
}
544+
513545
func respBody(t *testing.T, response *http.Response) string {
514546
if response.Body != nil {
515547
defer response.Body.Close()
@@ -555,7 +587,7 @@ func TestHTTPClientDoContextCancelledDuringRetry(t *testing.T) {
555587

556588
_, err = client.Do(req)
557589
require.Error(t, err)
558-
assert.Contains(t, err.Error(), context.Canceled.Error())
590+
assert.Equal(t, context.Canceled, err)
559591
assert.Less(t, count.Load(), int32(noOfRetries+1), "should not have completed all retries due to context cancellation")
560592
}
561593

@@ -624,3 +656,41 @@ func TestHTTPClientDoContextTimeoutDuringRetry(t *testing.T) {
624656
assert.Contains(t, err.Error(), context.DeadlineExceeded.Error())
625657
assert.Less(t, count.Load(), int32(noOfRetries+1), "should not have completed all retries due to context timeout")
626658
}
659+
660+
func TestHTTPClientMultiRetryOnTimeout(t *testing.T) {
661+
noOfRetries := 3
662+
backoffInterval := 4 * time.Millisecond
663+
maximumJitterInterval := 2 * time.Millisecond
664+
665+
client := NewClient(
666+
WithHTTPTimeout(5*time.Millisecond),
667+
WithRetryCount(noOfRetries),
668+
WithRetrier(heimdall.NewRetrier(heimdall.NewConstantBackoff(backoffInterval, maximumJitterInterval))),
669+
)
670+
671+
dummyHandler := func(w http.ResponseWriter, r *http.Request) {
672+
time.Sleep(10 * time.Millisecond)
673+
w.WriteHeader(http.StatusOK)
674+
}
675+
676+
server := httptest.NewServer(http.HandlerFunc(dummyHandler))
677+
defer server.Close()
678+
679+
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
680+
require.NoError(t, err)
681+
682+
_, err = client.Do(req)
683+
require.Error(t, err)
684+
assert.ErrorIs(t, err, context.DeadlineExceeded)
685+
mutliErr, ok := err.(interface{ Unwrap() []error })
686+
require.True(t, ok)
687+
errs := mutliErr.Unwrap()
688+
assert.Len(t, errs, noOfRetries+1)
689+
for _, e := range errs {
690+
var urlErr *url.Error
691+
require.True(t, errors.As(e, &urlErr))
692+
assert.Equal(t, "Get", urlErr.Op)
693+
assert.Equal(t, server.URL, urlErr.URL)
694+
assert.ErrorIs(t, urlErr.Err, context.DeadlineExceeded)
695+
}
696+
}

internal/multi_error.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package internal
2+
3+
import "strings"
4+
5+
func BuildMultiError(errors []error) error {
6+
if len(errors) == 0 {
7+
return nil
8+
}
9+
if len(errors) == 1 {
10+
return errors[0]
11+
}
12+
13+
return &multiError{errors: errors}
14+
}
15+
16+
type multiError struct {
17+
errors []error
18+
}
19+
20+
func (m multiError) Error() string {
21+
var sb strings.Builder
22+
for i, e := range m.errors {
23+
sb.WriteString(e.Error())
24+
if i < len(m.errors)-1 {
25+
sb.WriteString(", ")
26+
}
27+
}
28+
29+
return sb.String()
30+
}
31+
32+
func (m multiError) Unwrap() []error {
33+
return m.errors
34+
}

internal/multi_error_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package internal_test
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/gojek/heimdall/v7/internal"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
var err1 = errors.New("err1")
12+
var err2 = errors.New("err2")
13+
var err3 = errors.New("err3")
14+
var err4 = errors.New("err4")
15+
16+
func TestBuildMultiError(t *testing.T) {
17+
assert.Nil(t, internal.BuildMultiError(nil))
18+
19+
assert.Equal(t, err1, internal.BuildMultiError([]error{err1}))
20+
21+
errs := internal.BuildMultiError([]error{err1, err2})
22+
assert.Equal(t, "err1, err2", errs.Error())
23+
assert.ErrorIs(t, errs, err1)
24+
assert.ErrorIs(t, errs, err2)
25+
assert.NotErrorIs(t, errs, err3)
26+
assert.NotErrorIs(t, errs, err4)
27+
28+
errs = internal.BuildMultiError([]error{err1, err2, err3, err4})
29+
assert.Equal(t, "err1, err2, err3, err4", errs.Error())
30+
assert.ErrorIs(t, errs, err1)
31+
assert.ErrorIs(t, errs, err2)
32+
assert.ErrorIs(t, errs, err3)
33+
assert.ErrorIs(t, errs, err4)
34+
}

0 commit comments

Comments
 (0)