Skip to content

Commit 4d562bb

Browse files
authored
Merge pull request #2317 from mdaniel/api-rate-limit
Honor the rate limit headers in "hub api"
2 parents d450572 + d6d4b67 commit 4d562bb

File tree

3 files changed

+131
-9
lines changed

3 files changed

+131
-9
lines changed

commands/api.go

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"regexp"
1010
"strconv"
1111
"strings"
12+
"time"
1213

1314
"github.com/github/hub/github"
1415
"github.com/github/hub/ui"
@@ -73,7 +74,10 @@ var cmdApi = &Command{
7374
resource as indicated in the "Link" response header. For GraphQL queries,
7475
this utilizes 'pageInfo' that must be present in the query; see EXAMPLES.
7576
76-
Note that multiple JSON documents will be output as a result.
77+
Note that multiple JSON documents will be output as a result. If the API
78+
rate limit has been reached, the final document that is output will be the
79+
HTTP 403 notice, and the process will exit with a non-zero status. One way
80+
this can be avoided is by enabling '--obey-ratelimit'.
7781
7882
--color[=<WHEN>]
7983
Enable colored output even if stdout is not a terminal. <WHEN> can be one
@@ -86,6 +90,11 @@ var cmdApi = &Command{
8690
requests as well. Just make sure to not use '--cache' for any GraphQL
8791
mutations.
8892
93+
--obey-ratelimit
94+
After exceeding the API rate limit, pause the process until the reset time
95+
of the current rate limit window and retry the request. Note that this may
96+
cause the process to hang for a long time (maximum of 1 hour).
97+
8998
<ENDPOINT>
9099
The GitHub API endpoint to send the HTTP request to (default: "/").
91100
@@ -136,7 +145,7 @@ func init() {
136145
CmdRunner.Use(cmdApi)
137146
}
138147

139-
func apiCommand(cmd *Command, args *Args) {
148+
func apiCommand(_ *Command, args *Args) {
140149
path := ""
141150
if !args.IsParamsEmpty() {
142151
path = args.GetParam(0)
@@ -235,15 +244,20 @@ func apiCommand(cmd *Command, args *Args) {
235244
parseJSON := args.Flag.Bool("--flat")
236245
includeHeaders := args.Flag.Bool("--include")
237246
paginate := args.Flag.Bool("--paginate")
247+
rateLimitWait := args.Flag.Bool("--obey-ratelimit")
238248

239249
args.NoForward()
240250

241-
requestLoop := true
242-
for requestLoop {
251+
for {
243252
response, err := gh.GenericAPIRequest(method, path, body, headers, cacheTTL)
244253
utils.Check(err)
245-
success := response.StatusCode < 300
246254

255+
if rateLimitWait && response.StatusCode == 403 && response.RateLimitRemaining() == 0 {
256+
pauseUntil(response.RateLimitReset())
257+
continue
258+
}
259+
260+
success := response.StatusCode < 300
247261
jsonType := true
248262
if !success {
249263
jsonType, _ = regexp.MatchString(`[/+]json(?:;|$)`, response.Header.Get("Content-Type"))
@@ -273,7 +287,6 @@ func apiCommand(cmd *Command, args *Args) {
273287
os.Exit(22)
274288
}
275289

276-
requestLoop = false
277290
if paginate {
278291
if isGraphQL && hasNextPage && endCursor != "" {
279292
if v, ok := params["variables"]; ok {
@@ -283,15 +296,31 @@ func apiCommand(cmd *Command, args *Args) {
283296
variables := map[string]interface{}{"endCursor": endCursor}
284297
params["variables"] = variables
285298
}
286-
requestLoop = true
299+
goto next
287300
} else if nextLink := response.Link("next"); nextLink != "" {
288301
path = nextLink
289-
requestLoop = true
302+
goto next
290303
}
291304
}
292-
if requestLoop && !parseJSON {
305+
306+
break
307+
next:
308+
if !parseJSON {
293309
fmt.Fprintf(out, "\n")
294310
}
311+
312+
if rateLimitWait && response.RateLimitRemaining() == 0 {
313+
pauseUntil(response.RateLimitReset())
314+
}
315+
}
316+
}
317+
318+
func pauseUntil(timestamp int) {
319+
rollover := time.Unix(int64(timestamp)+1, 0)
320+
duration := time.Until(rollover)
321+
if duration > 0 {
322+
ui.Errorf("API rate limit exceeded; pausing until %v ...\n", rollover)
323+
time.Sleep(duration)
295324
}
296325
}
297326

features/api.feature

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,73 @@ Feature: hub api
421421
Given I am "octocat" on github.com with OAuth token "TOKEN2"
422422
When I run `hub api -t count --cache 5`
423423
Then it should pass with ".count 2"
424+
425+
Scenario: Honor rate limit with pagination
426+
Given the GitHub API server:
427+
"""
428+
get('/hello') {
429+
page = (params[:page] || 1).to_i
430+
if page < 2
431+
response.headers['X-Ratelimit-Remaining'] = '0'
432+
response.headers['X-Ratelimit-Reset'] = Time.now.utc.to_i.to_s
433+
response.headers['Link'] = %(</hello?page=#{page+1}>; rel="next")
434+
end
435+
json [{}]
436+
}
437+
"""
438+
When I successfully run `hub api --obey-ratelimit --paginate hello`
439+
Then the stderr should contain "API rate limit exceeded; pausing until "
440+
441+
Scenario: Succumb to rate limit with pagination
442+
Given the GitHub API server:
443+
"""
444+
get('/hello') {
445+
page = (params[:page] || 1).to_i
446+
response.headers['X-Ratelimit-Remaining'] = '0'
447+
response.headers['X-Ratelimit-Reset'] = Time.now.utc.to_i.to_s
448+
if page == 2
449+
status 403
450+
json :message => "API rate limit exceeded"
451+
else
452+
response.headers['Link'] = %(</hello?page=#{page+1}>; rel="next")
453+
json [{page:page}]
454+
end
455+
}
456+
"""
457+
When I run `hub api --paginate -t hello`
458+
Then the exit status should be 22
459+
And the stderr should not contain "API rate limit exceeded"
460+
And the stdout should contain exactly:
461+
"""
462+
.[0].page 1
463+
.message API rate limit exceeded\n
464+
"""
465+
466+
Scenario: Honor rate limit for 403s
467+
Given the GitHub API server:
468+
"""
469+
count = 0
470+
get('/hello') {
471+
count += 1
472+
if count == 1
473+
response.headers['X-Ratelimit-Remaining'] = '0'
474+
response.headers['X-Ratelimit-Reset'] = Time.now.utc.to_i.to_s
475+
halt 403
476+
end
477+
json [{}]
478+
}
479+
"""
480+
When I successfully run `hub api --obey-ratelimit hello`
481+
Then the stderr should contain "API rate limit exceeded; pausing until "
482+
483+
Scenario: 403 unrelated to rate limit
484+
Given the GitHub API server:
485+
"""
486+
get('/hello') {
487+
response.headers['X-Ratelimit-Remaining'] = '1'
488+
status 403
489+
}
490+
"""
491+
When I run `hub api --obey-ratelimit hello`
492+
Then the exit status should be 22
493+
Then the stderr should not contain "API rate limit exceeded"

github/http.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ const checksType = "application/vnd.github.antiope-preview+json;charset=utf-8"
3232
const draftsType = "application/vnd.github.shadow-cat-preview+json;charset=utf-8"
3333
const cacheVersion = 2
3434

35+
const (
36+
rateLimitRemainingHeader = "X-Ratelimit-Remaining"
37+
rateLimitResetHeader = "X-Ratelimit-Reset"
38+
)
39+
3540
var inspectHeaders = []string{
3641
"Authorization",
3742
"X-GitHub-OTP",
@@ -517,3 +522,21 @@ func (res *simpleResponse) Link(name string) string {
517522
}
518523
return ""
519524
}
525+
526+
func (res *simpleResponse) RateLimitRemaining() int {
527+
if v := res.Header.Get(rateLimitRemainingHeader); len(v) > 0 {
528+
if num, err := strconv.Atoi(v); err == nil {
529+
return num
530+
}
531+
}
532+
return -1
533+
}
534+
535+
func (res *simpleResponse) RateLimitReset() int {
536+
if v := res.Header.Get(rateLimitResetHeader); len(v) > 0 {
537+
if ts, err := strconv.Atoi(v); err == nil {
538+
return ts
539+
}
540+
}
541+
return -1
542+
}

0 commit comments

Comments
 (0)