Skip to content

Extend Rate and Rate Limiting with X-Ratelimit-Used and X-Ratelimit-Resource headers #3453

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

Merged
merged 2 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion github/github-stringify_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ const (
headerAPIVersion = "X-Github-Api-Version"
headerRateLimit = "X-Ratelimit-Limit"
headerRateRemaining = "X-Ratelimit-Remaining"
headerRateUsed = "X-Ratelimit-Used"
headerRateReset = "X-Ratelimit-Reset"
headerRateResource = "X-Ratelimit-Resource"
headerOTP = "X-Github-Otp"
headerRetryAfter = "Retry-After"

Expand Down Expand Up @@ -763,11 +765,17 @@ func parseRate(r *http.Response) Rate {
if remaining := r.Header.Get(headerRateRemaining); remaining != "" {
rate.Remaining, _ = strconv.Atoi(remaining)
}
if used := r.Header.Get(headerRateUsed); used != "" {
rate.Used, _ = strconv.Atoi(used)
}
if reset := r.Header.Get(headerRateReset); reset != "" {
if v, _ := strconv.ParseInt(reset, 10, 64); v != 0 {
rate.Reset = Timestamp{time.Unix(v, 0)}
}
}
if resource := r.Header.Get(headerRateResource); resource != "" {
rate.Resource = resource
}
return rate
}

Expand Down
48 changes: 48 additions & 0 deletions github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,9 @@ func TestDo_rateLimit(t *testing.T) {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "59")
w.Header().Set(headerRateUsed, "1")
w.Header().Set(headerRateReset, "1372700873")
w.Header().Set(headerRateResource, "core")
})

req, _ := client.NewRequest("GET", ".", nil)
Expand All @@ -1197,10 +1199,16 @@ func TestDo_rateLimit(t *testing.T) {
if got, want := resp.Rate.Remaining, 59; got != want {
t.Errorf("Client rate remaining = %v, want %v", got, want)
}
if got, want := resp.Rate.Used, 1; got != want {
t.Errorf("Client rate used = %v, want %v", got, want)
}
reset := time.Date(2013, time.July, 1, 17, 47, 53, 0, time.UTC)
if !resp.Rate.Reset.UTC().Equal(reset) {
t.Errorf("Client rate reset = %v, want %v", resp.Rate.Reset.UTC(), reset)
}
if got, want := resp.Rate.Resource, "core"; got != want {
t.Errorf("Client rate resource = %v, want %v", got, want)
}
}

func TestDo_rateLimitCategory(t *testing.T) {
Expand Down Expand Up @@ -1288,7 +1296,9 @@ func TestDo_rateLimit_errorResponse(t *testing.T) {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "59")
w.Header().Set(headerRateUsed, "1")
w.Header().Set(headerRateReset, "1372700873")
w.Header().Set(headerRateResource, "core")
http.Error(w, "Bad Request", 400)
})

Expand All @@ -1307,10 +1317,16 @@ func TestDo_rateLimit_errorResponse(t *testing.T) {
if got, want := resp.Rate.Remaining, 59; got != want {
t.Errorf("Client rate remaining = %v, want %v", got, want)
}
if got, want := resp.Rate.Used, 1; got != want {
t.Errorf("Client rate used = %v, want %v", got, want)
}
reset := time.Date(2013, time.July, 1, 17, 47, 53, 0, time.UTC)
if !resp.Rate.Reset.UTC().Equal(reset) {
t.Errorf("Client rate reset = %v, want %v", resp.Rate.Reset, reset)
}
if got, want := resp.Rate.Resource, "core"; got != want {
t.Errorf("Client rate resource = %v, want %v", got, want)
}
}

// Ensure *RateLimitError is returned when API rate limit is exceeded.
Expand All @@ -1321,7 +1337,9 @@ func TestDo_rateLimit_rateLimitError(t *testing.T) {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "0")
w.Header().Set(headerRateUsed, "60")
w.Header().Set(headerRateReset, "1372700873")
w.Header().Set(headerRateResource, "core")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, `{
Expand All @@ -1347,10 +1365,16 @@ func TestDo_rateLimit_rateLimitError(t *testing.T) {
if got, want := rateLimitErr.Rate.Remaining, 0; got != want {
t.Errorf("rateLimitErr rate remaining = %v, want %v", got, want)
}
if got, want := rateLimitErr.Rate.Used, 60; got != want {
t.Errorf("rateLimitErr rate used = %v, want %v", got, want)
}
reset := time.Date(2013, time.July, 1, 17, 47, 53, 0, time.UTC)
if !rateLimitErr.Rate.Reset.UTC().Equal(reset) {
t.Errorf("rateLimitErr rate reset = %v, want %v", rateLimitErr.Rate.Reset.UTC(), reset)
}
if got, want := rateLimitErr.Rate.Resource, "core"; got != want {
t.Errorf("rateLimitErr rate resource = %v, want %v", got, want)
}
}

// Ensure a network call is not made when it's known that API rate limit is still exceeded.
Expand All @@ -1363,7 +1387,9 @@ func TestDo_rateLimit_noNetworkCall(t *testing.T) {
mux.HandleFunc("/first", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "0")
w.Header().Set(headerRateUsed, "60")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix()))
w.Header().Set(headerRateResource, "core")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, `{
Expand Down Expand Up @@ -1406,9 +1432,15 @@ func TestDo_rateLimit_noNetworkCall(t *testing.T) {
if got, want := rateLimitErr.Rate.Remaining, 0; got != want {
t.Errorf("rateLimitErr rate remaining = %v, want %v", got, want)
}
if got, want := rateLimitErr.Rate.Used, 60; got != want {
t.Errorf("rateLimitErr rate used = %v, want %v", got, want)
}
if !rateLimitErr.Rate.Reset.UTC().Equal(reset) {
t.Errorf("rateLimitErr rate reset = %v, want %v", rateLimitErr.Rate.Reset.UTC(), reset)
}
if got, want := rateLimitErr.Rate.Resource, "core"; got != want {
t.Errorf("rateLimitErr rate resource = %v, want %v", got, want)
}
}

// Ignore rate limit headers if the response was served from cache.
Expand All @@ -1423,7 +1455,9 @@ func TestDo_rateLimit_ignoredFromCache(t *testing.T) {
w.Header().Set("X-From-Cache", "1")
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "0")
w.Header().Set(headerRateUsed, "60")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix()))
w.Header().Set(headerRateResource, "core")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, `{
Expand Down Expand Up @@ -1470,7 +1504,9 @@ func TestDo_rateLimit_sleepUntilResponseResetLimit(t *testing.T) {
firstRequest = false
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "0")
w.Header().Set(headerRateUsed, "60")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix()))
w.Header().Set(headerRateResource, "core")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, `{
Expand All @@ -1481,7 +1517,9 @@ func TestDo_rateLimit_sleepUntilResponseResetLimit(t *testing.T) {
}
w.Header().Set(headerRateLimit, "5000")
w.Header().Set(headerRateRemaining, "5000")
w.Header().Set(headerRateUsed, "0")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix()))
w.Header().Set(headerRateResource, "core")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{}`)
Expand Down Expand Up @@ -1510,7 +1548,9 @@ func TestDo_rateLimit_sleepUntilResponseResetLimitRetryOnce(t *testing.T) {
requestCount++
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "0")
w.Header().Set(headerRateUsed, "60")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix()))
w.Header().Set(headerRateResource, "core")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, `{
Expand Down Expand Up @@ -1542,7 +1582,9 @@ func TestDo_rateLimit_sleepUntilClientResetLimit(t *testing.T) {
requestCount++
w.Header().Set(headerRateLimit, "5000")
w.Header().Set(headerRateRemaining, "5000")
w.Header().Set(headerRateUsed, "0")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix()))
w.Header().Set(headerRateResource, "core")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{}`)
Expand Down Expand Up @@ -1573,7 +1615,9 @@ func TestDo_rateLimit_abortSleepContextCancelled(t *testing.T) {
requestCount++
w.Header().Set(headerRateLimit, "60")
w.Header().Set(headerRateRemaining, "0")
w.Header().Set(headerRateUsed, "60")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Unix()))
w.Header().Set(headerRateResource, "core")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintln(w, `{
Expand Down Expand Up @@ -1606,7 +1650,9 @@ func TestDo_rateLimit_abortSleepContextCancelledClientLimit(t *testing.T) {
requestCount++
w.Header().Set(headerRateLimit, "5000")
w.Header().Set(headerRateRemaining, "5000")
w.Header().Set(headerRateUsed, "0")
w.Header().Set(headerRateReset, fmt.Sprint(reset.Add(time.Hour).Unix()))
w.Header().Set(headerRateResource, "core")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{}`)
Expand Down Expand Up @@ -1926,7 +1972,9 @@ func TestCheckResponse_RateLimit(t *testing.T) {
}
res.Header.Set(headerRateLimit, "60")
res.Header.Set(headerRateRemaining, "0")
res.Header.Set(headerRateUsed, "1")
res.Header.Set(headerRateReset, "243424")
res.Header.Set(headerRateResource, "core")

err := CheckResponse(res).(*RateLimitError)

Expand Down
14 changes: 11 additions & 3 deletions github/rate_limit.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,22 @@ type RateLimitService service

// Rate represents the rate limit for the current client.
type Rate struct {
// The number of requests per hour the client is currently limited to.
// The maximum number of requests that you can make per hour.
Limit int `json:"limit"`

// The number of remaining requests the client can make this hour.
// The number of requests remaining in the current rate limit window.
Remaining int `json:"remaining"`

// The time at which the current rate limit will reset.
// The number of requests you have made in the current rate limit window.
Used int `json:"used"`

// The time at which the current rate limit window resets, in UTC epoch seconds.
Reset Timestamp `json:"reset"`

// The rate limit resource that the request counted against.
// For more information about the different resources, see REST API endpoints for rate limits.
// GitHub API docs: https://docs.github.com/en/rest/rate-limit/rate-limit#get-rate-limit-status-for-the-authenticated-user
Resource string `json:"resource,omitempty"`
}

func (r Rate) String() string {
Expand Down
Loading
Loading