Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow blocking until primary rate limit is reset #3117

Merged
merged 11 commits into from May 1, 2024
6 changes: 6 additions & 0 deletions README.md
Expand Up @@ -180,6 +180,12 @@ if _, ok := err.(*github.AbuseRateLimitError); ok {
}
```

Alternatively, you can block until the rate limit is reset by using the `context.WithValue` method:

````go
repos, _, err := client.Repositories.List(context.WithValue(ctx, github.SleepUntilPrimaryRateLimitResetWhenRateLimited, true), "", nil)
```

You can use [go-github-ratelimit](https://github.com/gofri/go-github-ratelimit) to handle
secondary rate limit sleep-and-retry for you.

Expand Down
17 changes: 17 additions & 0 deletions github/github.go
Expand Up @@ -804,6 +804,7 @@ type requestContext uint8

const (
bypassRateLimitCheck requestContext = iota
SleepUntilPrimaryRateLimitResetWhenRateLimited
)

// BareDo sends an API request and lets you handle the api response. If an error
Expand Down Expand Up @@ -889,6 +890,13 @@ func (c *Client) BareDo(ctx context.Context, req *http.Request) (*Response, erro
err = aerr
}

rateLimitError, ok := err.(*RateLimitError)
if ok && req.Context().Value(SleepUntilPrimaryRateLimitResetWhenRateLimited) != nil {
sleepUntilResetWithBuffer(rateLimitError.Rate.Reset.Time)
// retry the request once when the rate limit has reset
return c.BareDo(context.WithValue(req.Context(), SleepUntilPrimaryRateLimitResetWhenRateLimited, nil), req)
}

// Update the secondary rate limit if we hit it.
rerr, ok := err.(*AbuseRateLimitError)
if ok && rerr.RetryAfter != nil {
Expand Down Expand Up @@ -942,6 +950,10 @@ func (c *Client) checkRateLimitBeforeDo(req *http.Request, rateLimitCategory Rat
rate := c.rateLimits[rateLimitCategory]
c.rateMu.Unlock()
if !rate.Reset.Time.IsZero() && rate.Remaining == 0 && time.Now().Before(rate.Reset.Time) {
if req.Context().Value(SleepUntilPrimaryRateLimitResetWhenRateLimited) != nil {
sleepUntilResetWithBuffer(rate.Reset.Time)
return nil
}
// Create a fake response.
resp := &http.Response{
Status: http.StatusText(http.StatusForbidden),
Expand Down Expand Up @@ -1514,6 +1526,11 @@ func formatRateReset(d time.Duration) string {
return fmt.Sprintf("[rate reset in %v]", timeString)
}

func sleepUntilResetWithBuffer(reset time.Time) {
buffer := time.Second
time.Sleep(time.Until(reset) + buffer)
}

// When using roundTripWithOptionalFollowRedirect, note that it
// is the responsibility of the caller to close the response body.
func (c *Client) roundTripWithOptionalFollowRedirect(ctx context.Context, u string, maxRedirects int, opts ...RequestOption) (*http.Response, error) {
Expand Down
103 changes: 103 additions & 0 deletions github/github_test.go
Expand Up @@ -1381,6 +1381,109 @@ func TestDo_rateLimit_ignoredFromCache(t *testing.T) {
}
}

// Ensure *RateLimitError is returned when API rate limit is exceeded.
func TestDo_rateLimit_sleepUntilResponseResetLimit(t *testing.T) {
client, mux, _, teardown := setup()
defer teardown()

reset := time.Now().UTC().Add(time.Second)

var firstRequest = true
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if firstRequest {
firstRequest = false
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "0")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix()))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, `{
"message": "API rate limit exceeded for xxx.xxx.xxx.xxx. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)",
"documentation_url": "https://docs.github.com/en/rest/overview/resources-in-the-rest-api#abuse-rate-limits"
}`)
return
}
w.Header().Set(headerRateLimit, "5000")
w.Header().Set(headerRateRemaining, "5000")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix()))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{}`)
})

req, _ := client.NewRequest("GET", ".", nil)
ctx := context.Background()
resp, err := client.Do(context.WithValue(ctx, SleepUntilPrimaryRateLimitResetWhenRateLimited, true), req, nil)
if err != nil {
t.Errorf("Do returned unexpected error: %v", err)
}
if got, want := resp.StatusCode, http.StatusOK; got != want {
t.Errorf("Response status code = %v, want %v", got, want)
}
}

func TestDo_rateLimit_sleepUntilResponseResetLimitRetryOnce(t *testing.T) {
client, mux, _, teardown := setup()
defer teardown()

reset := time.Now().UTC().Add(time.Second)

requestCount := 0
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "0")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix()))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, `{
"message": "API rate limit exceeded for xxx.xxx.xxx.xxx. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)",
"documentation_url": "https://docs.github.com/en/rest/overview/resources-in-the-rest-api#abuse-rate-limits"
}`)
})

req, _ := client.NewRequest("GET", ".", nil)
ctx := context.Background()
_, err := client.Do(context.WithValue(ctx, SleepUntilPrimaryRateLimitResetWhenRateLimited, true), req, nil)
if err == nil {
t.Error("Expected error to be returned.")
}
if got, want := requestCount, 2; got != want {
t.Errorf("Expected 2 requests, got %d", got)
}
}

// Ensure a network call is not made when it's known that API rate limit is still exceeded.
func TestDo_rateLimit_sleepUntilClientResetLimit(t *testing.T) {
client, mux, _, teardown := setup()
defer teardown()

reset := time.Now().UTC().Add(time.Second)
client.rateLimits[CoreCategory] = Rate{Limit: 5000, Remaining: 0, Reset: Timestamp{reset}}
requestCount := 0
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.Header().Set(headerRateLimit, "5000")
w.Header().Set(headerRateRemaining, "5000")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix()))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{}`)
})
req, _ := client.NewRequest("GET", ".", nil)
ctx := context.Background()
resp, err := client.Do(context.WithValue(ctx, SleepUntilPrimaryRateLimitResetWhenRateLimited, true), req, nil)
if err != nil {
t.Errorf("Do returned unexpected error: %v", err)
}
if got, want := resp.StatusCode, http.StatusOK; got != want {
t.Errorf("Response status code = %v, want %v", got, want)
}
if got, want := requestCount, 1; got != want {
t.Errorf("Expected 1 request, got %d", got)
}
}

// Ensure *AbuseRateLimitError is returned when the response indicates that
// the client has triggered an abuse detection mechanism.
func TestDo_rateLimit_abuseRateLimitError(t *testing.T) {
Expand Down