11package retry
22
33import (
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
1316const defaultMaxRetries uint = 5
@@ -29,12 +32,17 @@ var (
2932)
3033
3134type (
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.
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) {
124135func 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) {
139150func 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.
152198func (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
0 commit comments