Skip to content

Commit 25f130c

Browse files
authored
retry on gateway call (#94)
1 parent b5e3767 commit 25f130c

File tree

2 files changed

+216
-30
lines changed

2 files changed

+216
-30
lines changed

cmd/secrets/common/gateway.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,63 @@ import (
55
"fmt"
66
"io"
77
"net/http"
8+
"time"
9+
10+
"github.com/avast/retry-go/v4"
811
)
912

1013
type GatewayClient interface {
1114
Post(body []byte) (respBody []byte, status int, err error)
1215
}
1316

1417
type HTTPClient struct {
15-
URL string
16-
Client *http.Client
18+
URL string
19+
Client *http.Client
20+
RetryAttempts uint
21+
RetryDelay time.Duration
1722
}
1823

1924
func (g *HTTPClient) Post(body []byte) ([]byte, int, error) {
25+
attempts := g.RetryAttempts
26+
if attempts == 0 {
27+
attempts = 5
28+
}
29+
delay := g.RetryDelay
30+
if delay == 0 {
31+
delay = 3 * time.Second
32+
}
33+
34+
var respBody []byte
35+
var status int
36+
37+
err := retry.Do(
38+
func() error {
39+
b, s, e := g.postOnce(body)
40+
respBody, status = b, s
41+
if e != nil {
42+
return e // retry on any error
43+
}
44+
if s != http.StatusOK {
45+
return fmt.Errorf("gateway returned non-200: %d", s) // retry on any non-200
46+
}
47+
return nil // success
48+
},
49+
retry.Attempts(attempts),
50+
retry.Delay(delay),
51+
retry.LastErrorOnly(true),
52+
retry.OnRetry(func(n uint, err error) {
53+
fmt.Printf("Waiting for block confirmation and retrying gateway POST (attempt %d/%d): %v", n+1, attempts, err)
54+
}),
55+
)
56+
57+
if err != nil {
58+
// Return the last seen body/status to aid debugging.
59+
return respBody, status, fmt.Errorf("gateway POST failed after %d attempts: %w", attempts, err)
60+
}
61+
return respBody, status, nil
62+
}
63+
64+
func (g *HTTPClient) postOnce(body []byte) ([]byte, int, error) {
2065
req, err := http.NewRequest("POST", g.URL, bytes.NewBuffer(body))
2166
if err != nil {
2267
return nil, 0, fmt.Errorf("create HTTP request: %w", err)

cmd/secrets/common/gateway_test.go

Lines changed: 169 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,65 +6,206 @@ import (
66
"io"
77
"net/http"
88
"testing"
9+
"time"
910

1011
"github.com/stretchr/testify/assert"
1112
)
1213

13-
type MockRoundTripper struct {
14+
// errReadCloser simulates a failure while reading the body.
15+
type errReadCloser struct{}
16+
17+
func (e *errReadCloser) Read(p []byte) (int, error) { return 0, errors.New("read error") }
18+
func (e *errReadCloser) Close() error { return nil }
19+
20+
// RTResponse holds one RoundTrip outcome.
21+
type RTResponse struct {
1422
Response *http.Response
1523
Err error
1624
}
1725

18-
func (mrt *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
19-
return mrt.Response, mrt.Err
26+
// SeqRoundTripper returns a sequence of outcomes across calls.
27+
// If calls exceed length, it repeats the last element.
28+
type SeqRoundTripper struct {
29+
Seq []RTResponse
30+
Calls int
2031
}
2132

22-
type errReadCloser struct{}
33+
func (s *SeqRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
34+
i := s.Calls
35+
if i >= len(s.Seq) {
36+
i = len(s.Seq) - 1
37+
}
38+
s.Calls++
39+
entry := s.Seq[i]
40+
return entry.Response, entry.Err
41+
}
2342

24-
func (e *errReadCloser) Read(p []byte) (int, error) { return 0, errors.New("read error") }
25-
func (e *errReadCloser) Close() error { return nil }
43+
// makeResp builds a tiny HTTP response with given status and body.
44+
func makeResp(code int, body string) *http.Response {
45+
return &http.Response{
46+
StatusCode: code,
47+
Body: io.NopCloser(bytes.NewBufferString(body)),
48+
Header: make(http.Header),
49+
}
50+
}
2651

2752
func TestPostToGateway(t *testing.T) {
2853
h, _, _ := newMockHandler(t)
2954

30-
t.Run("success", func(t *testing.T) {
31-
mockResponseBody := `{"jsonrpc":"2.0","id":"abc","result":{"ok":true}}`
32-
mockResponse := &http.Response{
33-
StatusCode: 200,
34-
Body: io.NopCloser(bytes.NewBufferString(mockResponseBody)),
35-
Header: make(http.Header),
36-
}
55+
t.Run("success (single attempt)", func(t *testing.T) {
56+
body := `{"jsonrpc":"2.0","id":"abc","result":{"ok":true}}`
3757

38-
// Wire mock transport into Handler's HTTP client
39-
mockedHttpClient := &http.Client{Transport: &MockRoundTripper{Response: mockResponse}}
40-
h.Gw = &HTTPClient{URL: "https://unit-test.gw", Client: mockedHttpClient}
58+
rt := &SeqRoundTripper{
59+
Seq: []RTResponse{
60+
{Response: makeResp(200, body)},
61+
},
62+
}
63+
mockedHTTP := &http.Client{Transport: rt}
64+
h.Gw = &HTTPClient{
65+
URL: "https://unit-test.gw",
66+
Client: mockedHTTP,
67+
RetryAttempts: 3,
68+
RetryDelay: 0, // fast tests
69+
}
4170

4271
respBytes, status, err := h.Gw.Post([]byte(`{"x":1}`))
4372
assert.NoError(t, err)
4473
assert.Equal(t, 200, status)
45-
assert.Equal(t, mockResponseBody, string(respBytes))
74+
assert.Equal(t, body, string(respBytes))
75+
assert.Equal(t, 1, rt.Calls)
4676
})
4777

48-
t.Run("http error", func(t *testing.T) {
49-
mockedHttpClient := &http.Client{Transport: &MockRoundTripper{Response: nil, Err: errors.New("network down")}}
50-
h.Gw = &HTTPClient{URL: "https://unit-test.gw", Client: mockedHttpClient}
78+
t.Run("http transport error -> retries then fail", func(t *testing.T) {
79+
rt := &SeqRoundTripper{
80+
Seq: []RTResponse{
81+
{Err: errors.New("network down")},
82+
{Err: errors.New("network still down")},
83+
},
84+
}
85+
mockedHTTP := &http.Client{Transport: rt}
86+
h.Gw = &HTTPClient{
87+
URL: "https://unit-test.gw",
88+
Client: mockedHTTP,
89+
RetryAttempts: 2,
90+
RetryDelay: 0,
91+
}
5192

5293
_, _, err := h.Gw.Post([]byte(`{}`))
5394
assert.Error(t, err)
54-
assert.Contains(t, err.Error(), "network down")
95+
assert.Contains(t, err.Error(), "network")
96+
assert.Equal(t, 2, rt.Calls)
5597
})
5698

57-
t.Run("read error", func(t *testing.T) {
58-
mockResponse := &http.Response{
59-
StatusCode: 200,
60-
Body: &errReadCloser{},
61-
Header: make(http.Header),
99+
t.Run("read error -> retries then fail", func(t *testing.T) {
100+
rt := &SeqRoundTripper{
101+
Seq: []RTResponse{
102+
{Response: &http.Response{StatusCode: 200, Body: &errReadCloser{}, Header: make(http.Header)}},
103+
{Response: &http.Response{StatusCode: 200, Body: &errReadCloser{}, Header: make(http.Header)}},
104+
},
105+
}
106+
mockedHTTP := &http.Client{Transport: rt}
107+
h.Gw = &HTTPClient{
108+
URL: "https://unit-test.gw",
109+
Client: mockedHTTP,
110+
RetryAttempts: 2,
111+
RetryDelay: 0,
62112
}
63-
mockedHttpClient := &http.Client{Transport: &MockRoundTripper{Response: mockResponse}}
64-
h.Gw = &HTTPClient{URL: "https://unit-test.gw", Client: mockedHttpClient}
65113

66114
_, _, err := h.Gw.Post([]byte(`{}`))
67115
assert.Error(t, err)
68116
assert.Contains(t, err.Error(), "read response body: read error")
117+
assert.Equal(t, 2, rt.Calls)
118+
})
119+
120+
t.Run("non-200 then 200 -> success after retry", func(t *testing.T) {
121+
successBody := `{"ok":true}`
122+
rt := &SeqRoundTripper{
123+
Seq: []RTResponse{
124+
{Response: makeResp(503, "temporary outage")},
125+
{Response: makeResp(200, successBody)},
126+
},
127+
}
128+
mockedHTTP := &http.Client{Transport: rt}
129+
h.Gw = &HTTPClient{
130+
URL: "https://unit-test.gw",
131+
Client: mockedHTTP,
132+
RetryAttempts: 3,
133+
RetryDelay: 0,
134+
}
135+
136+
respBytes, status, err := h.Gw.Post([]byte(`{}`))
137+
assert.NoError(t, err)
138+
assert.Equal(t, 200, status)
139+
assert.Equal(t, successBody, string(respBytes))
140+
assert.Equal(t, 2, rt.Calls)
141+
})
142+
143+
t.Run("always non-200 -> fail after attempts", func(t *testing.T) {
144+
rt := &SeqRoundTripper{
145+
Seq: []RTResponse{
146+
{Response: makeResp(500, "err1")},
147+
{Response: makeResp(429, "err2")},
148+
{Response: makeResp(400, "err3")}, // any non-200 should still retry/fail
149+
},
150+
}
151+
mockedHTTP := &http.Client{Transport: rt}
152+
h.Gw = &HTTPClient{
153+
URL: "https://unit-test.gw",
154+
Client: mockedHTTP,
155+
RetryAttempts: 3,
156+
RetryDelay: 0,
157+
}
158+
159+
_, status, err := h.Gw.Post([]byte(`{}`))
160+
assert.Error(t, err)
161+
// status is from the last attempt
162+
assert.Equal(t, 400, status)
163+
assert.Contains(t, err.Error(), "gateway POST failed")
164+
assert.Equal(t, 3, rt.Calls)
165+
})
166+
167+
t.Run("error then success -> ok", func(t *testing.T) {
168+
body := `{"ok":true}`
169+
rt := &SeqRoundTripper{
170+
Seq: []RTResponse{
171+
{Err: errors.New("dial tcp: i/o timeout")},
172+
{Response: makeResp(200, body)},
173+
},
174+
}
175+
mockedHTTP := &http.Client{Transport: rt}
176+
h.Gw = &HTTPClient{
177+
URL: "https://unit-test.gw",
178+
Client: mockedHTTP,
179+
RetryAttempts: 5,
180+
RetryDelay: 0,
181+
}
182+
183+
respBytes, status, err := h.Gw.Post([]byte(`{}`))
184+
assert.NoError(t, err)
185+
assert.Equal(t, 200, status)
186+
assert.Equal(t, body, string(respBytes))
187+
assert.Equal(t, 2, rt.Calls)
188+
})
189+
190+
// Optional: prove delays are honored if set
191+
t.Run("honors small delay", func(t *testing.T) {
192+
rt := &SeqRoundTripper{
193+
Seq: []RTResponse{
194+
{Response: makeResp(503, "nope")},
195+
{Response: makeResp(200, `{"ok":true}`)},
196+
},
197+
}
198+
mockedHTTP := &http.Client{Transport: rt}
199+
h.Gw = &HTTPClient{
200+
URL: "https://unit-test.gw",
201+
Client: mockedHTTP,
202+
RetryAttempts: 2,
203+
RetryDelay: 5 * time.Millisecond,
204+
}
205+
206+
_, status, err := h.Gw.Post([]byte(`{}`))
207+
assert.NoError(t, err)
208+
assert.Equal(t, 200, status)
209+
assert.Equal(t, 2, rt.Calls)
69210
})
70211
}

0 commit comments

Comments
 (0)