Skip to content

Commit

Permalink
Extend Rate and Rate Limiting with X-Ratelimit-Used and `X-Rateli…
Browse files Browse the repository at this point in the history
…mit-Resource` headers (#3453)
  • Loading branch information
andygrunwald authored Jan 27, 2025
1 parent 3a72a02 commit e8d69e7
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 28 deletions.
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

0 comments on commit e8d69e7

Please # to comment.