Skip to content
This repository was archived by the owner on Dec 10, 2024. It is now read-only.

Commit 629ba2b

Browse files
authored
Merge pull request #800 from xanzy/svh/f-rate-limit
Implement a rate limiting stategy
2 parents ef77af7 + d8bb0b4 commit 629ba2b

File tree

6 files changed

+194
-121
lines changed

6 files changed

+194
-121
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
language: go
22

33
go:
4-
- 1.12.x
54
- 1.13.x
5+
- 1.14.x
66
- master
77

88
stages:

gitlab.go

Lines changed: 173 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"fmt"
2626
"io"
2727
"io/ioutil"
28+
"math/rand"
2829
"net/http"
2930
"net/url"
3031
"sort"
@@ -33,13 +34,19 @@ import (
3334
"time"
3435

3536
"github.com/google/go-querystring/query"
37+
"github.com/hashicorp/go-cleanhttp"
38+
retryablehttp "github.com/hashicorp/go-retryablehttp"
3639
"golang.org/x/oauth2"
40+
"golang.org/x/time/rate"
3741
)
3842

3943
const (
4044
defaultBaseURL = "https://gitlab.com/"
4145
apiVersionPath = "api/v4/"
4246
userAgent = "go-gitlab"
47+
48+
headerRateLimit = "RateLimit-Limit"
49+
headerRateReset = "RateLimit-Reset"
4350
)
4451

4552
// authType represents an authentication type within GitLab.
@@ -321,13 +328,16 @@ const (
321328
// A Client manages communication with the GitLab API.
322329
type Client struct {
323330
// HTTP client used to communicate with the API.
324-
client *http.Client
331+
client *retryablehttp.Client
325332

326333
// Base URL for API requests. Defaults to the public GitLab API, but can be
327334
// set to a domain endpoint to use with a self hosted GitLab server. baseURL
328335
// should always be specified with a trailing slash.
329336
baseURL *url.URL
330337

338+
// Limiter is used to limit API calls and prevent 429 responses.
339+
limiter *rate.Limiter
340+
331341
// Token type used to make authenticated API calls.
332342
authType authType
333343

@@ -479,15 +489,25 @@ func NewOAuthClient(httpClient *http.Client, token string) *Client {
479489

480490
func newClient(httpClient *http.Client) *Client {
481491
if httpClient == nil {
482-
httpClient = http.DefaultClient
492+
httpClient = cleanhttp.DefaultPooledClient()
483493
}
484494

485-
c := &Client{client: httpClient, UserAgent: userAgent}
486-
if err := c.SetBaseURL(defaultBaseURL); err != nil {
487-
// Should never happen since defaultBaseURL is our constant.
488-
panic(err)
495+
c := &Client{UserAgent: userAgent}
496+
497+
// Configure the HTTP client.
498+
c.client = &retryablehttp.Client{
499+
Backoff: c.retryHTTPBackoff,
500+
CheckRetry: c.retryHTTPCheck,
501+
ErrorHandler: retryablehttp.PassthroughErrorHandler,
502+
HTTPClient: httpClient,
503+
RetryWaitMin: 100 * time.Millisecond,
504+
RetryWaitMax: 400 * time.Millisecond,
505+
RetryMax: 30,
489506
}
490507

508+
// Set the default base URL.
509+
_ = c.SetBaseURL(defaultBaseURL)
510+
491511
// Create the internal timeStats service.
492512
timeStats := &timeStatsService{client: c}
493513

@@ -566,6 +586,64 @@ func newClient(httpClient *http.Client) *Client {
566586
return c
567587
}
568588

589+
// retryHTTPCheck provides a callback for Client.CheckRetry which
590+
// will retry both rate limit (429) and server (>= 500) errors.
591+
func (c *Client) retryHTTPCheck(ctx context.Context, resp *http.Response, err error) (bool, error) {
592+
if ctx.Err() != nil {
593+
return false, ctx.Err()
594+
}
595+
if err != nil {
596+
return false, err
597+
}
598+
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
599+
return true, nil
600+
}
601+
return false, nil
602+
}
603+
604+
// retryHTTPBackoff provides a generic callback for Client.Backoff which
605+
// will pass through all calls based on the status code of the response.
606+
func (c *Client) retryHTTPBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
607+
// Use the rate limit backoff function when we are rate limited.
608+
if resp != nil && resp.StatusCode == 429 {
609+
return rateLimitBackoff(min, max, attemptNum, resp)
610+
}
611+
612+
// Set custom duration's when we experience a service interruption.
613+
min = 700 * time.Millisecond
614+
max = 900 * time.Millisecond
615+
616+
return retryablehttp.LinearJitterBackoff(min, max, attemptNum, resp)
617+
}
618+
619+
// rateLimitBackoff provides a callback for Client.Backoff which will use the
620+
// RateLimit-Reset header to determine the time to wait. We add some jitter
621+
// to prevent a thundering herd.
622+
//
623+
// min and max are mainly used for bounding the jitter that will be added to
624+
// the reset time retrieved from the headers. But if the final wait time is
625+
// less then min, min will be used instead.
626+
func rateLimitBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
627+
// rnd is used to generate pseudo-random numbers.
628+
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
629+
630+
// First create some jitter bounded by the min and max durations.
631+
jitter := time.Duration(rnd.Float64() * float64(max-min))
632+
633+
if resp != nil {
634+
if v := resp.Header.Get(headerRateReset); v != "" {
635+
if reset, _ := strconv.ParseInt(v, 10, 64); reset > 0 {
636+
// Only update min if the given time to wait is longer.
637+
if wait := time.Until(time.Unix(reset, 0)); wait > min {
638+
min = wait
639+
}
640+
}
641+
}
642+
}
643+
644+
return min + jitter
645+
}
646+
569647
// BaseURL return a copy of the baseURL.
570648
func (c *Client) BaseURL() *url.URL {
571649
u := *c.baseURL
@@ -592,6 +670,49 @@ func (c *Client) SetBaseURL(urlStr string) error {
592670
// Update the base URL of the client.
593671
c.baseURL = baseURL
594672

673+
// Reconfigure the rate limiter.
674+
return c.configureLimiter()
675+
}
676+
677+
// configureLimiter configures the rate limiter.
678+
func (c *Client) configureLimiter() error {
679+
// Set default values for when rate limiting is disabled.
680+
limit := rate.Inf
681+
burst := 0
682+
683+
defer func() {
684+
// Create a new limiter using the calculated values.
685+
c.limiter = rate.NewLimiter(limit, burst)
686+
}()
687+
688+
// Create a new request.
689+
req, err := http.NewRequest("GET", c.baseURL.String(), nil)
690+
if err != nil {
691+
return err
692+
}
693+
694+
// Make a single request to retrieve the rate limit headers.
695+
resp, err := c.client.HTTPClient.Do(req)
696+
if err != nil {
697+
return err
698+
}
699+
resp.Body.Close()
700+
701+
if v := resp.Header.Get(headerRateLimit); v != "" {
702+
if rateLimit, _ := strconv.ParseFloat(v, 64); rateLimit > 0 {
703+
// The rate limit is based on requests per minute, so for our limiter to
704+
// work correctly we devide the limit by 60 to get the limit per second.
705+
rateLimit /= 60
706+
// Configure the limit and burst using a split of 2/3 for the limit and
707+
// 1/3 for the burst. This enables clients to burst 1/3 of the allowed
708+
// calls before the limiter kicks in. The remaining calls will then be
709+
// spread out evenly using intervals of time.Second / limit which should
710+
// prevent hitting the rate limit.
711+
limit = rate.Limit(rateLimit * 0.66)
712+
burst = int(rateLimit * 0.33)
713+
}
714+
}
715+
595716
return nil
596717
}
597718

@@ -600,7 +721,7 @@ func (c *Client) SetBaseURL(urlStr string) error {
600721
// Relative URL paths should always be specified without a preceding slash. If
601722
// specified, the value pointed to by body is JSON encoded and included as the
602723
// request body.
603-
func (c *Client) NewRequest(method, path string, opt interface{}, options []OptionFunc) (*http.Request, error) {
724+
func (c *Client) NewRequest(method, path string, opt interface{}, options []OptionFunc) (*retryablehttp.Request, error) {
604725
u := *c.baseURL
605726
unescaped, err := url.PathUnescape(path)
606727
if err != nil {
@@ -618,54 +739,53 @@ func (c *Client) NewRequest(method, path string, opt interface{}, options []Opti
618739
}
619740
u.RawQuery = q.Encode()
620741
}
742+
// Create a request specific headers map.
743+
reqHeaders := make(http.Header)
744+
reqHeaders.Set("Accept", "application/json")
621745

622-
req := &http.Request{
623-
Method: method,
624-
URL: &u,
625-
Proto: "HTTP/1.1",
626-
ProtoMajor: 1,
627-
ProtoMinor: 1,
628-
Header: make(http.Header),
629-
Host: u.Host,
746+
switch c.authType {
747+
case basicAuth, oAuthToken:
748+
reqHeaders.Set("Authorization", "Bearer "+c.token)
749+
case privateToken:
750+
reqHeaders.Set("PRIVATE-TOKEN", c.token)
630751
}
631752

632-
for _, fn := range options {
633-
if fn == nil {
634-
continue
635-
}
636-
637-
if err := fn(req); err != nil {
638-
return nil, err
639-
}
753+
if c.UserAgent != "" {
754+
reqHeaders.Set("User-Agent", c.UserAgent)
640755
}
641756

757+
var body interface{}
642758
if method == "POST" || method == "PUT" {
643-
bodyBytes, err := json.Marshal(opt)
644-
if err != nil {
645-
return nil, err
646-
}
647-
bodyReader := bytes.NewReader(bodyBytes)
648-
649759
u.RawQuery = ""
650-
req.Body = ioutil.NopCloser(bodyReader)
651-
req.GetBody = func() (io.ReadCloser, error) {
652-
return ioutil.NopCloser(bodyReader), nil
760+
reqHeaders.Set("Content-Type", "application/json")
761+
762+
if opt != nil {
763+
bodyBytes, err := json.Marshal(opt)
764+
if err != nil {
765+
return nil, err
766+
}
767+
body = bytes.NewReader(bodyBytes)
653768
}
654-
req.ContentLength = int64(bodyReader.Len())
655-
req.Header.Set("Content-Type", "application/json")
656769
}
657770

658-
req.Header.Set("Accept", "application/json")
771+
req, err := retryablehttp.NewRequest(method, u.String(), body)
772+
if err != nil {
773+
return nil, err
774+
}
775+
776+
for _, fn := range options {
777+
if fn == nil {
778+
continue
779+
}
659780

660-
switch c.authType {
661-
case basicAuth, oAuthToken:
662-
req.Header.Set("Authorization", "Bearer "+c.token)
663-
case privateToken:
664-
req.Header.Set("PRIVATE-TOKEN", c.token)
781+
if err := fn(req); err != nil {
782+
return nil, err
783+
}
665784
}
666785

667-
if c.UserAgent != "" {
668-
req.Header.Set("User-Agent", c.UserAgent)
786+
// Set the request specific headers.
787+
for k, v := range reqHeaders {
788+
req.Header[k] = v
669789
}
670790

671791
return req, nil
@@ -733,7 +853,12 @@ func (r *Response) populatePageValues() {
733853
// error if an API error has occurred. If v implements the io.Writer
734854
// interface, the raw response body will be written to v, without attempting to
735855
// first decode it.
736-
func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) {
856+
func (c *Client) Do(req *retryablehttp.Request, v interface{}) (*Response, error) {
857+
// Wait will block until the limiter can obtain a new token.
858+
if err := c.limiter.Wait(req.Context()); err != nil {
859+
return nil, err
860+
}
861+
737862
resp, err := c.client.Do(req)
738863
if err != nil {
739864
return nil, err
@@ -752,8 +877,8 @@ func (c *Client) Do(req *http.Request, v interface{}) (*Response, error) {
752877

753878
err = CheckResponse(resp)
754879
if err != nil {
755-
// even though there was an error, we still return the response
756-
// in case the caller wants to inspect it further
880+
// Even though there was an error, we still return the response
881+
// in case the caller wants to inspect it further.
757882
return response, err
758883
}
759884

@@ -872,11 +997,11 @@ func parseError(raw interface{}) string {
872997
// another user, provided your private token is from an administrator account.
873998
//
874999
// GitLab docs: https://docs.gitlab.com/ce/api/README.html#sudo
875-
type OptionFunc func(*http.Request) error
1000+
type OptionFunc func(*retryablehttp.Request) error
8761001

8771002
// WithSudo takes either a username or user ID and sets the SUDO request header
8781003
func WithSudo(uid interface{}) OptionFunc {
879-
return func(req *http.Request) error {
1004+
return func(req *retryablehttp.Request) error {
8801005
user, err := parseID(uid)
8811006
if err != nil {
8821007
return err
@@ -888,7 +1013,7 @@ func WithSudo(uid interface{}) OptionFunc {
8881013

8891014
// WithContext runs the request with the provided context
8901015
func WithContext(ctx context.Context) OptionFunc {
891-
return func(req *http.Request) error {
1016+
return func(req *retryablehttp.Request) error {
8921017
*req = *req.WithContext(ctx)
8931018
return nil
8941019
}

gitlab_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313
"os"
1414
"strings"
1515
"testing"
16+
17+
retryablehttp "github.com/hashicorp/go-retryablehttp"
1618
)
1719

1820
// setup sets up a test HTTP server along with a gitlab.Client that is
@@ -73,7 +75,7 @@ func mustWriteHTTPResponse(t *testing.T, w io.Writer, fixturePath string) {
7375
}
7476
}
7577

76-
func errorOption(*http.Request) error {
78+
func errorOption(*retryablehttp.Request) error {
7779
return errors.New("OptionFunc returns an error")
7880
}
7981

@@ -93,8 +95,8 @@ func TestSetBaseURL(t *testing.T) {
9395
expectedBaseURL := "http://gitlab.local/foo/" + apiVersionPath
9496
c := NewClient(nil, "")
9597
err := c.SetBaseURL("http://gitlab.local/foo")
96-
if err != nil {
97-
t.Fatalf("Failed to SetBaseURL: %v", err)
98+
if err == nil {
99+
t.Errorf("Expected a 'no such host' error, got: %v", err)
98100
}
99101
if c.BaseURL().String() != expectedBaseURL {
100102
t.Errorf("BaseURL is %s, want %s", c.BaseURL().String(), expectedBaseURL)
@@ -108,7 +110,7 @@ func TestCheckResponse(t *testing.T) {
108110
}
109111

110112
resp := &http.Response{
111-
Request: req,
113+
Request: req.Request,
112114
StatusCode: http.StatusBadRequest,
113115
Body: ioutil.NopCloser(strings.NewReader(`
114116
{

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ module github.com/xanzy/go-gitlab
22

33
require (
44
github.com/google/go-querystring v1.0.0
5+
github.com/hashicorp/go-cleanhttp v0.5.1
6+
github.com/hashicorp/go-retryablehttp v0.6.4
57
github.com/stretchr/testify v1.4.0
68
golang.org/x/net v0.0.0-20181108082009-03003ca0c849 // indirect
79
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288
810
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect
11+
golang.org/x/time v0.0.0-20191024005414-555d28b269f0
912
google.golang.org/appengine v1.3.0 // indirect
1013
)
1114

0 commit comments

Comments
 (0)