Skip to content
This repository was archived by the owner on Sep 11, 2026. It is now read-only.

Commit 2e000ff

Browse files
committed
extend retry client to understand a Retry-After header
1 parent 9add00e commit 2e000ff

30 files changed

Lines changed: 1601 additions & 280 deletions

retry/go.mod

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
module github.com/smithy-security/pkg/retry
22

3-
go 1.23
3+
go 1.23.2
44

55
require (
66
github.com/cenkalti/backoff/v5 v5.0.2
7-
github.com/stretchr/testify v1.9.0
7+
github.com/go-errors/errors v1.5.1
8+
github.com/smithy-security/pkg/utils v0.0.2
9+
github.com/stretchr/testify v1.10.0
810
)
911

1012
require (

retry/go.sum

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9
33
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
44
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
55
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6+
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
7+
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
68
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
79
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
810
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -16,8 +18,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
1618
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
1719
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
1820
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
19-
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
20-
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
21+
github.com/smithy-security/pkg/utils v0.0.2 h1:r1Gz5eki8xUJXShw4i5ZaizkiKgZlYNYtKE2PDwpoHQ=
22+
github.com/smithy-security/pkg/utils v0.0.2/go.mod h1:bzCtRv/q9BdCrALRkcWWW3y8DzugbZrEQPwgZ/iepig=
23+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
24+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
2125
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
2226
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
2327
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

retry/retry.go

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package retry
22

33
import (
4-
"errors"
5-
"fmt"
64
"log/slog"
75
"net/http"
86
"net/http/httputil"
7+
"strconv"
8+
"strings"
9+
"time"
910

1011
"github.com/cenkalti/backoff/v5"
12+
"github.com/go-errors/errors"
13+
"github.com/smithy-security/pkg/utils"
1114
)
1215

1316
const defaultMaxRetries uint = 5
@@ -29,12 +32,17 @@ var (
2932
)
3033

3134
type (
35+
// ResponseInfoLogger is allowed to inspect the retryable response and
36+
// print more info about it that might be useful to the caller for
37+
// debugging
38+
ResponseInfoLogger func(res *http.Response, logger Logger)
39+
3240
// Logger allows to inject a custom logger in the client.
3341
Logger interface {
34-
Error(msg string, keysAndValues ...interface{})
35-
Info(msg string, keysAndValues ...interface{})
36-
Debug(msg string, keysAndValues ...interface{})
37-
Warn(msg string, keysAndValues ...interface{})
42+
Error(msg string, keysAndValues ...any)
43+
Info(msg string, keysAndValues ...any)
44+
Debug(msg string, keysAndValues ...any)
45+
Warn(msg string, keysAndValues ...any)
3846
}
3947

4048
// NextRetryInSeconds allows customising the behaviour for the calculating the next retry.
@@ -59,6 +67,9 @@ type (
5967
// AcceptedStatusCodes allows to specify the non-retryable status codes.
6068
// defaultAcceptedStatusCodes are the default.
6169
AcceptedStatusCodes map[int]struct{}
70+
// ResponseInfoLoggerFunc when set will be used to check the response
71+
// returned by the API
72+
ResponseInfoLoggerFunc ResponseInfoLogger
6273
}
6374

6475
retry struct {
@@ -124,7 +135,7 @@ func applyConfig(cfg Config) (Config, error) {
124135
func NewClient(config Config) (*http.Client, error) {
125136
config, err := applyConfig(config)
126137
if err != nil {
127-
return nil, fmt.Errorf("failed to apply config: %w", err)
138+
return nil, errors.Errorf("failed to apply config: %w", err)
128139
}
129140

130141
config.BaseClient.Transport = &retry{
@@ -139,7 +150,7 @@ func NewClient(config Config) (*http.Client, error) {
139150
func NewRoundTripper(config Config) (http.RoundTripper, error) {
140151
config, err := applyConfig(config)
141152
if err != nil {
142-
return nil, fmt.Errorf("failed to apply config: %w", err)
153+
return nil, errors.Errorf("failed to apply config: %w", err)
143154
}
144155

145156
return &retry{
@@ -148,6 +159,41 @@ func NewRoundTripper(config Config) (http.RoundTripper, error) {
148159
}, nil
149160
}
150161

162+
const noRetryHeader = -1
163+
164+
// parseRetryHeader does a best effort parsing of the retry header
165+
func parseRetryHeader(logger Logger, resp *http.Response) int {
166+
vals, ok := resp.Header["Retry-After"]
167+
if !ok {
168+
return noRetryHeader
169+
}
170+
171+
logger.Debug("response contains retry after header", slog.String("vals", strings.Join(vals, ",")))
172+
if len(vals) > 1 {
173+
logger.Error("retry header has multiple values")
174+
return noRetryHeader
175+
}
176+
177+
retrySeconds, err := strconv.ParseInt(vals[0], 10, 32)
178+
if err == nil {
179+
return int(retrySeconds)
180+
}
181+
182+
logger.Debug(
183+
"could not parse `retry after` value into seconds, trying as a date",
184+
slog.String("err", err.Error()),
185+
)
186+
187+
retryAfterTime, err := time.Parse(http.TimeFormat, vals[0])
188+
if err == nil {
189+
logger.Debug("parsed successfully time from retry after header")
190+
return int(time.Until(retryAfterTime).Seconds()) + 1
191+
}
192+
193+
logger.Error("could not parse http time in retry header", slog.String("err", err.Error()))
194+
return noRetryHeader
195+
}
196+
151197
// RoundTrip implements a http transport RoundTripper with retry capabilities.
152198
func (re *retry) RoundTrip(req *http.Request) (*http.Response, error) {
153199
var (
@@ -168,21 +214,30 @@ func (re *retry) RoundTrip(req *http.Request) (*http.Response, error) {
168214
switch {
169215
case !isAcceptedStatus && currAttempt >= re.config.MaxRetries:
170216
return resp, backoff.Permanent(
171-
fmt.Errorf(
217+
errors.Errorf(
172218
"maximum number of retries exceeded: %d",
173219
currAttempt,
174220
),
175221
)
176222
case !isAcceptedStatus && isRetryableStatus:
177-
nextRetryInSeconds := re.config.NextRetryInSecondsFunc(currAttempt)
223+
nextRetryInSeconds := parseRetryHeader(logger, resp)
224+
if nextRetryInSeconds == noRetryHeader {
225+
nextRetryInSeconds = re.config.NextRetryInSecondsFunc(currAttempt)
226+
}
178227

179228
logger.Debug(
180229
"retryable status code, retrying",
181230
slog.Int("retry_in_seconds", nextRetryInSeconds),
182231
slog.Int("curr_attempt", int(currAttempt)),
183232
slog.Int("status_code", resp.StatusCode),
184233
)
234+
235+
if !utils.IsNil(re.config.ResponseInfoLoggerFunc) {
236+
re.config.ResponseInfoLoggerFunc(resp, logger)
237+
}
238+
185239
currAttempt++
240+
186241
return resp, backoff.RetryAfter(nextRetryInSeconds)
187242
case !isAcceptedStatus && !isRetryableStatus:
188243
bb, err := httputil.DumpResponse(resp, true)
@@ -197,7 +252,8 @@ func (re *retry) RoundTrip(req *http.Request) (*http.Response, error) {
197252
slog.Int("status_code", resp.StatusCode),
198253
slog.String("raw_body", string(bb)),
199254
)
200-
return resp, backoff.Permanent(fmt.Errorf("invalid status code: %d", resp.StatusCode))
255+
256+
return resp, backoff.Permanent(errors.Errorf("invalid status code: %d", resp.StatusCode))
201257
}
202258

203259
return resp, nil
@@ -209,7 +265,7 @@ func (re *retry) RoundTrip(req *http.Request) (*http.Response, error) {
209265
retryableOp,
210266
)
211267
if err != nil {
212-
return result, fmt.Errorf("could not process backoff result: %w", err)
268+
return result, errors.Errorf("could not process backoff result: %w", err)
213269
}
214270

215271
return result, nil

retry/retry_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ func TestRoundTripper(t *testing.T) {
4646
Body: io.NopCloser(bytes.NewBufferString(body)),
4747
}
4848
}
49+
makeResponseWithHeader = func(statusCode int, headerName, headerVal, body string) *http.Response {
50+
resp := &http.Response{
51+
Header: http.Header{},
52+
StatusCode: statusCode,
53+
Body: io.NopCloser(bytes.NewBufferString(body)),
54+
}
55+
resp.Header.Add(headerName, headerVal)
56+
return resp
57+
}
4958
zeroDelayRetry = func(uint) int { return 0 }
5059
)
5160

@@ -172,6 +181,30 @@ func TestRoundTripper(t *testing.T) {
172181
expectedBody: "success",
173182
expectError: false,
174183
},
184+
{
185+
name: "response has retry after header",
186+
responses: []*http.Response{
187+
makeResponseWithHeader(http.StatusTooManyRequests, "Retry-After", "1", "rate limited 1"),
188+
makeResponse(http.StatusOK, "success"),
189+
},
190+
errors: []error{nil, nil, nil},
191+
maxRetries: 3,
192+
expectedStatus: http.StatusOK,
193+
expectedBody: "success",
194+
expectError: false,
195+
},
196+
{
197+
name: "response has wrong retry after header",
198+
responses: []*http.Response{
199+
makeResponseWithHeader(http.StatusTooManyRequests, "Retry-After", "bla", "rate limited 1"),
200+
makeResponse(http.StatusOK, "success"),
201+
},
202+
errors: []error{nil, nil, nil},
203+
maxRetries: 3,
204+
expectedStatus: http.StatusOK,
205+
expectedBody: "success",
206+
expectError: false,
207+
},
175208
} {
176209
t.Run(tc.name, func(t *testing.T) {
177210
retryFunc := tc.retryFunc

retry/vendor/github.com/go-errors/errors/.travis.yml

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

retry/vendor/github.com/go-errors/errors/LICENSE.MIT

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

retry/vendor/github.com/go-errors/errors/README.md

Lines changed: 84 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)