Skip to content

Commit 307bc95

Browse files
authored
Merge pull request #68 from netlify/rate_limit_aware_transport
Implement an http transport that retries after a rate limit.
2 parents 2c23c4b + a1bd7b2 commit 307bc95

File tree

7 files changed

+454
-498
lines changed

7 files changed

+454
-498
lines changed

go/Gopkg.lock

Lines changed: 31 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

go/Gopkg.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,11 @@
4848
[[constraint]]
4949
name = "github.com/sirupsen/logrus"
5050
version = "1.0.3"
51+
52+
[[constraint]]
53+
name = "github.com/Azure/go-autorest"
54+
version = "v9.6.0"
55+
56+
[[constraint]]
57+
name = "github.com/stretchr/testify"
58+
version = "v1.1.4"

go/porcelain/http/http.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package http
2+
3+
import (
4+
"net/http"
5+
"strconv"
6+
"time"
7+
8+
"github.com/Azure/go-autorest/autorest"
9+
"github.com/go-openapi/runtime"
10+
)
11+
12+
type RetryableTransport struct {
13+
tr runtime.ClientTransport
14+
attempts int
15+
}
16+
17+
type retryableRoundTripper struct {
18+
tr http.RoundTripper
19+
attempts int
20+
}
21+
22+
func NewRetryableTransport(tr runtime.ClientTransport, attempts int) *RetryableTransport {
23+
return &RetryableTransport{
24+
tr: tr,
25+
attempts: attempts,
26+
}
27+
}
28+
29+
func (t *RetryableTransport) Submit(op *runtime.ClientOperation) (interface{}, error) {
30+
client := op.Client
31+
32+
if client == nil {
33+
client = http.DefaultClient
34+
}
35+
36+
transport := client.Transport
37+
if transport == nil {
38+
transport = http.DefaultTransport
39+
}
40+
client.Transport = &retryableRoundTripper{
41+
tr: transport,
42+
attempts: t.attempts,
43+
}
44+
45+
op.Client = client
46+
47+
res, err := t.tr.Submit(op)
48+
49+
// restore original transport
50+
op.Client.Transport = transport
51+
52+
return res, err
53+
}
54+
55+
func (t *retryableRoundTripper) RoundTrip(req *http.Request) (resp *http.Response, err error) {
56+
rr := autorest.NewRetriableRequest(req)
57+
58+
for attempt := 0; attempt < t.attempts; attempt++ {
59+
err = rr.Prepare()
60+
if err != nil {
61+
return resp, err
62+
}
63+
64+
resp, err = t.tr.RoundTrip(rr.Request())
65+
66+
if err != nil || resp.StatusCode != http.StatusTooManyRequests {
67+
return resp, err
68+
}
69+
70+
if attempt+1 < t.attempts { // ignore delay check in the last request attempt
71+
if !delayWithRateLimit(resp, req.Cancel) {
72+
return resp, err
73+
}
74+
}
75+
}
76+
77+
return resp, err
78+
}
79+
80+
func delayWithRateLimit(resp *http.Response, cancel <-chan struct{}) bool {
81+
r := resp.Header.Get("X-RateLimit-Reset")
82+
if r == "" {
83+
return false
84+
}
85+
retryReset, err := strconv.ParseInt(r, 10, 0)
86+
if err != nil {
87+
return false
88+
}
89+
90+
t := time.Unix(retryReset, 0)
91+
select {
92+
case <-time.After(t.Sub(time.Now())):
93+
return true
94+
case <-cancel:
95+
return false
96+
}
97+
}

go/porcelain/http/http_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package http
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"testing"
10+
"time"
11+
12+
"github.com/go-openapi/runtime"
13+
httptransport "github.com/go-openapi/runtime/client"
14+
"github.com/go-openapi/strfmt"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestRetryableTransport(t *testing.T) {
19+
attempts := 0
20+
21+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
22+
attempts++
23+
24+
if attempts == 1 {
25+
reset := fmt.Sprintf("%d", time.Now().Add(1*time.Second).Unix())
26+
rw.Header().Set("X-RateLimit-Reset", reset)
27+
rw.WriteHeader(http.StatusTooManyRequests)
28+
_, _ = rw.Write([]byte("rate limited"))
29+
} else {
30+
rw.WriteHeader(http.StatusOK)
31+
_, _ = rw.Write([]byte("ok"))
32+
}
33+
}))
34+
defer server.Close()
35+
36+
rwrtr := runtime.ClientRequestWriterFunc(func(req runtime.ClientRequest, _ strfmt.Registry) error {
37+
return nil
38+
})
39+
40+
hu, _ := url.Parse(server.URL)
41+
rt := NewRetryableTransport(httptransport.New(hu.Host, "/", []string{"http"}), 2)
42+
43+
res, err := rt.Submit(&runtime.ClientOperation{
44+
ID: "getSites",
45+
Method: "GET",
46+
PathPattern: "/",
47+
Params: rwrtr,
48+
Reader: runtime.ClientResponseReaderFunc(func(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
49+
if response.Code() == 200 {
50+
var result string
51+
if err := consumer.Consume(response.Body(), &result); err != nil {
52+
return nil, err
53+
}
54+
return result, nil
55+
}
56+
return nil, errors.New("Generic error")
57+
}),
58+
})
59+
60+
require.NoError(t, err)
61+
require.Equal(t, 2, attempts)
62+
63+
require.IsType(t, "", res)
64+
actual := res.(string)
65+
require.EqualValues(t, "ok", actual)
66+
}
67+
68+
func TestRetryableTransportExceedsMaxAttempts(t *testing.T) {
69+
attempts := 0
70+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
71+
attempts++
72+
reset := fmt.Sprintf("%d", time.Now().Add(1*time.Second).Unix())
73+
rw.Header().Set("X-RateLimit-Reset", reset)
74+
rw.WriteHeader(http.StatusTooManyRequests)
75+
_, _ = rw.Write([]byte("rate limited"))
76+
}))
77+
defer server.Close()
78+
79+
rwrtr := runtime.ClientRequestWriterFunc(func(req runtime.ClientRequest, _ strfmt.Registry) error {
80+
return nil
81+
})
82+
83+
hu, _ := url.Parse(server.URL)
84+
rt := NewRetryableTransport(httptransport.New(hu.Host, "/", []string{"http"}), 2)
85+
86+
_, err := rt.Submit(&runtime.ClientOperation{
87+
ID: "getSites",
88+
Method: "GET",
89+
PathPattern: "/",
90+
Params: rwrtr,
91+
Reader: runtime.ClientResponseReaderFunc(func(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
92+
if response.Code() == 200 {
93+
var result string
94+
if err := consumer.Consume(response.Body(), &result); err != nil {
95+
return nil, err
96+
}
97+
return result, nil
98+
}
99+
return nil, errors.New("Generic error")
100+
}),
101+
})
102+
103+
require.Error(t, err)
104+
require.Equal(t, 2, attempts)
105+
}
106+
107+
func TestRetryableWithDifferentError(t *testing.T) {
108+
attempts := 0
109+
110+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
111+
attempts++
112+
113+
rw.WriteHeader(http.StatusNotFound)
114+
_, _ = rw.Write([]byte("not found"))
115+
}))
116+
defer server.Close()
117+
118+
rwrtr := runtime.ClientRequestWriterFunc(func(req runtime.ClientRequest, _ strfmt.Registry) error {
119+
return nil
120+
})
121+
122+
hu, _ := url.Parse(server.URL)
123+
rt := NewRetryableTransport(httptransport.New(hu.Host, "/", []string{"http"}), 2)
124+
125+
_, err := rt.Submit(&runtime.ClientOperation{
126+
ID: "getSites",
127+
Method: "GET",
128+
PathPattern: "/",
129+
Params: rwrtr,
130+
Reader: runtime.ClientResponseReaderFunc(func(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) {
131+
if response.Code() == 200 {
132+
var result string
133+
if err := consumer.Consume(response.Body(), &result); err != nil {
134+
return nil, err
135+
}
136+
return result, nil
137+
}
138+
return nil, errors.New("Generic error")
139+
}),
140+
})
141+
142+
require.Error(t, err)
143+
require.Equal(t, 1, attempts)
144+
}

go/porcelain/netlify_client.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,42 @@ package porcelain
22

33
import (
44
"github.com/go-openapi/runtime"
5+
httptransport "github.com/go-openapi/runtime/client"
56
"github.com/go-openapi/strfmt"
67
"github.com/netlify/open-api/go/plumbing"
8+
"github.com/netlify/open-api/go/porcelain/http"
79
)
810

911
const DefaultSyncFileLimit = 7000
1012
const DefaultConcurrentUploadLimit = 10
13+
const DefaultRetryAttempts = 3
1114

1215
// Default netlify HTTP client.
1316
var Default = NewHTTPClient(nil)
1417

1518
// NewHTTPClient creates a new netlify HTTP client.
1619
func NewHTTPClient(formats strfmt.Registry) *Netlify {
17-
n := plumbing.NewHTTPClient(formats)
18-
return &Netlify{
19-
Netlify: n,
20-
syncFileLimit: DefaultSyncFileLimit,
21-
uploadLimit: DefaultConcurrentUploadLimit,
22-
}
20+
return NewRetryableHTTPClient(formats, DefaultRetryAttempts)
2321
}
2422

25-
// New creates a new netlify client
23+
// NewRetryableHTTPClient creates a new netlify HTTP client with a number of attempts for rate limits.
24+
func NewRetryableHTTPClient(formats strfmt.Registry, attempts int) *Netlify {
25+
cfg := plumbing.DefaultTransportConfig()
26+
transport := httptransport.New(cfg.Host, cfg.BasePath, cfg.Schemes)
27+
28+
return NewRetryable(transport, formats, attempts)
29+
}
30+
31+
// New creates a new netlify client.
2632
func New(transport runtime.ClientTransport, formats strfmt.Registry) *Netlify {
27-
n := plumbing.New(transport, formats)
33+
return NewRetryable(transport, formats, DefaultRetryAttempts)
34+
}
35+
36+
// NewRetryable creates a new netlify client with a number of attempts for rate limits.
37+
func NewRetryable(transport runtime.ClientTransport, formats strfmt.Registry, attempts int) *Netlify {
38+
tr := http.NewRetryableTransport(transport, attempts)
39+
40+
n := plumbing.New(tr, formats)
2841
return &Netlify{
2942
Netlify: n,
3043
syncFileLimit: DefaultSyncFileLimit,

0 commit comments

Comments
 (0)