Skip to content

Commit f4618bb

Browse files
committed
feat: retries & timeouts
1 parent 9c15903 commit f4618bb

9 files changed

+222
-11
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Features include:
3333
- MessagePack (<https://msgpack.org/>)
3434
- Amazon Ion (<http://amzn.github.io/ion-docs/>)
3535
- Gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)), Deflate ([RFC 1951](https://datatracker.ietf.org/doc/html/rfc1951)), and Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding
36+
- Automatic retries with support for [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) and `X-Retry-In` headers when APIs are rate-limited.
3637
- Standardized [hypermedia](https://smartbear.com/learn/api-design/what-is-hypermedia/) parsing into queryable/followable response links:
3738
- HTTP Link relation headers ([RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2))
3839
- [HAL](http://stateless.co/hal_specification.html)

cli/cli.go

+8
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,8 @@ Not after (expires): %s (%s)
551551
AddGlobalFlag("rsh-client-key", "", "Path to a PEM encoded private key", "", false)
552552
AddGlobalFlag("rsh-ca-cert", "", "Path to a PEM encoded CA cert", "", false)
553553
AddGlobalFlag("rsh-ignore-status-code", "", "Do not set exit code from HTTP status code", false, false)
554+
AddGlobalFlag("rsh-retry", "", "Number of times to retry on certain failures", 2, false)
555+
AddGlobalFlag("rsh-timeout", "t", "Timeout for HTTP requests", time.Duration(0), false)
554556

555557
Root.RegisterFlagCompletionFunc("rsh-output-format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
556558
return []string{"auto", "json", "yaml"}, cobra.ShellCompDirectiveNoFileComp
@@ -770,6 +772,12 @@ func Run() (returnErr error) {
770772
}
771773
profile, _ := GlobalFlags.GetString("rsh-profile")
772774
viper.Set("rsh-profile", profile)
775+
if retries, _ := GlobalFlags.GetInt("rsh-retry"); retries > 0 {
776+
viper.Set("rsh-retry", retries)
777+
}
778+
if timeout, _ := GlobalFlags.GetDuration("rsh-timeout"); timeout > 0 {
779+
viper.Set("rsh-timeout", timeout)
780+
}
773781

774782
// Now that global flags are parsed we can enable verbose mode if requested.
775783
if viper.GetBool("rsh-verbose") {

cli/cli_test.go

+3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ func reset(color bool) {
2323
viper.Set("nocolor", true)
2424
}
2525

26+
// Most tests are easier to write without retries.
27+
viper.Set("rsh-retry", 0)
28+
2629
Init("test", "1.0.0'")
2730
Defaults()
2831
}

cli/flag.go

+8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"fmt"
55
"strings"
6+
"time"
67

78
"github.com/spf13/viper"
89
)
@@ -21,6 +22,13 @@ func AddGlobalFlag(name, short, description string, defaultValue interface{}, mu
2122
flags.BoolP(name, short, viper.GetBool(name), description)
2223
GlobalFlags.BoolP(name, short, viper.GetBool(name), description)
2324
}
25+
case time.Duration:
26+
if multi {
27+
panic(fmt.Errorf("unsupported float slice param"))
28+
} else {
29+
flags.DurationP(name, short, viper.GetDuration(name), description)
30+
GlobalFlags.DurationP(name, short, viper.GetDuration(name), description)
31+
}
2432
case int, int16, int32, int64, uint16, uint32, uint64:
2533
if multi {
2634
flags.IntSliceP(name, short, viper.Get(name).([]int), description)

cli/request.go

+105-10
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package cli
22

33
import (
4+
"bytes"
5+
"context"
46
"crypto/tls"
57
"crypto/x509"
8+
"errors"
69
"fmt"
710
"io"
811
"net/http"
@@ -100,8 +103,6 @@ func IgnoreStatus() requestOption {
100103
// before sending it out on the wire. If verbose mode is enabled, it will
101104
// print out both the request and response.
102105
func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, error) {
103-
start := time.Now()
104-
105106
name, config := findAPI(req.URL.String())
106107

107108
if config == nil {
@@ -258,11 +259,7 @@ func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, e
258259
}
259260
}
260261

261-
if log {
262-
LogDebugRequest(req)
263-
}
264-
265-
resp, err := client.Do(req)
262+
resp, err := doRequestWithRetry(log, client, req)
266263
if err != nil {
267264
return nil, err
268265
}
@@ -271,11 +268,109 @@ func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, e
271268
lastStatus = resp.StatusCode
272269
}
273270

274-
if log {
275-
LogDebugResponse(start, resp)
271+
return resp, nil
272+
}
273+
274+
// isRetryable returns true if a request should be retried.
275+
func isRetryable(code int) bool {
276+
if code == /* 408 */ http.StatusRequestTimeout ||
277+
code == /* 425 */ http.StatusTooEarly ||
278+
code == /* 429 */ http.StatusTooManyRequests ||
279+
code == /* 500 */ http.StatusInternalServerError ||
280+
code == /* 502 */ http.StatusBadGateway ||
281+
code == /* 503 */ http.StatusServiceUnavailable ||
282+
code == /* 504 */ http.StatusGatewayTimeout {
283+
return true
284+
}
285+
return false
286+
}
287+
288+
// doRequestWithRetry logs and makes a request, retrying as needed (if
289+
// configured) and returning the last response.
290+
func doRequestWithRetry(log bool, client *http.Client, req *http.Request) (*http.Response, error) {
291+
retries := viper.GetInt("rsh-retry")
292+
293+
if retries == 0 {
294+
return client.Do(req)
276295
}
277296

278-
return resp, nil
297+
var bodyContents []byte
298+
if req.Body != nil {
299+
bodyContents, _ = io.ReadAll(req.Body)
300+
}
301+
302+
var resp *http.Response
303+
var err error
304+
triesLeft := 1 + retries
305+
for triesLeft > 0 {
306+
triesLeft--
307+
308+
if len(bodyContents) > 0 {
309+
// Reset the body reader for each retry.
310+
req.Body = io.NopCloser(bytes.NewReader(bodyContents))
311+
}
312+
313+
if log {
314+
LogDebugRequest(req)
315+
}
316+
317+
if timeout := viper.GetDuration("rsh-timeout"); timeout > 0 {
318+
ctx, cancel := context.WithTimeout(req.Context(), timeout)
319+
defer cancel()
320+
req = req.WithContext(ctx)
321+
}
322+
323+
start := time.Now()
324+
resp, err = client.Do(req)
325+
if err != nil {
326+
if errors.Is(err, context.DeadlineExceeded) {
327+
if triesLeft > 0 {
328+
// Try again after letting the user know.
329+
LogWarning("Got request timeout after %s, retrying", viper.GetDuration("rsh-timeout").Truncate(time.Millisecond))
330+
continue
331+
} else {
332+
// Add a human-friendly error before the original (context deadline
333+
// exceeded).
334+
err = fmt.Errorf("Request timed out after %s: %w", viper.GetDuration("rsh-timeout"), err)
335+
}
336+
}
337+
return resp, err
338+
}
339+
340+
if log {
341+
LogDebugResponse(start, resp)
342+
}
343+
344+
if triesLeft > 0 && isRetryable(resp.StatusCode) {
345+
// Attempt to parse when to retry! Default is 1 second.
346+
retryAfter := 1 * time.Second
347+
348+
if v := resp.Header.Get("Retry-After"); v != "" {
349+
// Could be either an integer number of seconds, or an HTTP date.
350+
if d, err := strconv.ParseInt(v, 10, 64); err == nil {
351+
retryAfter = time.Duration(d) * time.Second
352+
}
353+
354+
if d, err := http.ParseTime(v); err == nil {
355+
retryAfter = time.Until(d)
356+
}
357+
}
358+
359+
if v := resp.Header.Get("X-Retry-In"); v != "" {
360+
if d, err := time.ParseDuration(v); err == nil {
361+
retryAfter = d
362+
}
363+
}
364+
365+
LogWarning("Got %s, retrying in %s", resp.Status, retryAfter.Truncate(time.Millisecond))
366+
time.Sleep(retryAfter)
367+
368+
continue
369+
}
370+
break
371+
}
372+
373+
return resp, err
279374
}
280375

281376
// Response describes a parsed HTTP response which can be marshalled to enable

cli/request_test.go

+83
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package cli
22

33
import (
4+
"bytes"
45
"errors"
56
"net/http"
67
"testing"
8+
"time"
79

10+
"github.com/spf13/viper"
811
"github.com/stretchr/testify/assert"
912
"gopkg.in/h2non/gock.v1"
1013
)
@@ -123,3 +126,83 @@ func TestIgnoreStatus(t *testing.T) {
123126

124127
assert.Equal(t, 0, GetLastStatus())
125128
}
129+
130+
func TestRequestRetryIn(t *testing.T) {
131+
defer gock.Off()
132+
133+
reset(false)
134+
viper.Set("rsh-retry", 1)
135+
136+
// Duration string value (with units)
137+
gock.New("http://example.com").
138+
Get("/").
139+
Times(1).
140+
Reply(http.StatusTooManyRequests).
141+
SetHeader("X-Retry-In", "1ms")
142+
143+
gock.New("http://example.com").
144+
Get("/").
145+
Times(1).
146+
Reply(http.StatusOK)
147+
148+
req, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil)
149+
resp, err := MakeRequest(req)
150+
151+
assert.NoError(t, err)
152+
assert.Equal(t, resp.StatusCode, http.StatusOK)
153+
}
154+
155+
func TestRequestRetryAfter(t *testing.T) {
156+
defer gock.Off()
157+
158+
reset(false)
159+
viper.Set("rsh-retry", 2)
160+
161+
// Seconds value
162+
gock.New("http://example.com").
163+
Put("/").
164+
Times(1).
165+
Reply(http.StatusTooManyRequests).
166+
SetHeader("Retry-After", "0")
167+
168+
// HTTP date value
169+
gock.New("http://example.com").
170+
Put("/").
171+
Times(1).
172+
Reply(http.StatusTooManyRequests).
173+
SetHeader("Retry-After", time.Now().Format(http.TimeFormat))
174+
175+
gock.New("http://example.com").
176+
Put("/").
177+
Times(1).
178+
Reply(http.StatusOK)
179+
180+
req, _ := http.NewRequest(http.MethodPut, "http://example.com/", bytes.NewReader([]byte("hello")))
181+
resp, err := MakeRequest(req)
182+
183+
assert.NoError(t, err)
184+
assert.Equal(t, resp.StatusCode, http.StatusOK)
185+
}
186+
187+
func TestRequestRetryTimeout(t *testing.T) {
188+
defer gock.Off()
189+
190+
reset(false)
191+
viper.Set("rsh-retry", 1)
192+
viper.Set("rsh-timeout", 1*time.Millisecond)
193+
194+
// Duration string value (with units)
195+
gock.New("http://example.com").
196+
Get("/").
197+
Times(2).
198+
Reply(http.StatusOK).
199+
Delay(2 * time.Millisecond)
200+
// Note: delay seems to have a bug where subsequent requests without the
201+
// delay are still delayed... For now just have it reply twice.
202+
203+
req, _ := http.NewRequest(http.MethodGet, "http://example.com/", nil)
204+
_, err := MakeRequest(req)
205+
206+
assert.Error(t, err)
207+
assert.ErrorContains(t, err, "timed out")
208+
}

docs/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Start with the [guide](/guide.md) to learn how to install and configure Restish
4141
- MessagePack (<https://msgpack.org/>)
4242
- Amazon Ion (<http://amzn.github.io/ion-docs/>)
4343
- Gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)), Deflate ([RFC 1951](https://datatracker.ietf.org/doc/html/rfc1951)), and Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding
44+
- Automatic retries with support for [`Retry-After`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After) and `X-Retry-In` headers when APIs are rate-limited.
4445
- Standardized [hypermedia](https://smartbear.com/learn/api-design/what-is-hypermedia/) parsing into queryable/followable response links:
4546
- HTTP Link relation headers ([RFC 5988](https://tools.ietf.org/html/rfc5988#section-6.2.2))
4647
- [HAL](http://stateless.co/hal_specification.html)

docs/_sidebar.md

+1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
- [Input](input.md "Restish Input")
77
- [CLI Shorthand](shorthand.md "CLI Shorthand")
88
- [Output](output.md "Restish Output")
9+
- [Retries & Timeouts](retries.md "Retries & Timeouts")
910
- [Hypermedia](hypermedia.md "Hypermedia Linking in Restish")
1011
- [Bulk Management](bulk.md "Bulk Resource Management")

docs/index.html

+12-1
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@
169169
.token.diffRemoved {
170170
color: #ff5f87;
171171
}
172+
173+
.token.warning {
174+
color: #d78700;
175+
}
172176
</style>
173177
</head>
174178
<body>
@@ -236,7 +240,7 @@
236240
comment: /^\s*#.*/m,
237241
redirect: /2>\/dev\/null/,
238242
response: {
239-
pattern: /^(HTTP\/[12]|\{|\[)(.|\n)+(\]|\}(\n|$))/gm,
243+
pattern: /^(HTTP\/[12].*$)|((\{|\[)(.|\n)+(\]|\}(\n|$)))/gm,
240244
greedy: false,
241245
inside: Prism.languages.readable
242246
},
@@ -259,6 +263,13 @@
259263
alias: "variable",
260264
pattern: /\$[A-Z0-9_]+/,
261265
},
266+
log: {
267+
pattern: /(INFO|WARN|ERROR): .*/,
268+
inside: {
269+
warning: /WARN:/,
270+
error: /ERROR:/,
271+
},
272+
},
262273
header: {
263274
pattern: /-H [A-Z][a-zA-Z0-9-]+:\S+/,
264275
inside: {

0 commit comments

Comments
 (0)