internal/github/ratelimit.go
1
package github
3
import (
4
"errors"
5
"fmt"
6
"net/http"
7
"strconv"
8
"time"
9
)
11
// RateLimit is what GitHub said about the budget a response spent from. Every
12
// response carries it, including the successful ones, so a caller can see a
13
// limit approaching instead of only discovering it at zero.
14
type RateLimit struct {
15
Limit int
16
Remaining int
17
Used int
18
Resource string
20
// Reset is when the primary budget refills.
21
Reset time.Time
23
// RetryAfter is what a secondary limit asks for instead. GitHub sends it
24
// on abuse detection, where Reset is absent and the only honest answer to
25
// "when may I retry" is the number of seconds it just gave us.
26
RetryAfter time.Duration
27
}
29
func rateLimitFrom(header http.Header) (RateLimit, bool) {
30
limit := RateLimit{
31
Limit: headerInt(header, "X-RateLimit-Limit"),
32
Remaining: headerInt(header, "X-RateLimit-Remaining"),
33
Used: headerInt(header, "X-RateLimit-Used"),
34
Resource: header.Get("X-RateLimit-Resource"),
35
}
36
if seconds := headerInt(header, "X-RateLimit-Reset"); seconds > 0 {
37
limit.Reset = time.Unix(int64(seconds), 0).UTC()
38
}
39
if seconds := headerInt(header, "Retry-After"); seconds > 0 {
40
limit.RetryAfter = time.Duration(seconds) * time.Second
41
}
42
return limit, limit.Limit > 0 || !limit.Reset.IsZero() || limit.RetryAfter > 0
43
}
45
func headerInt(header http.Header, name string) int {
46
value, err := strconv.Atoi(header.Get(name))
47
if err != nil {
48
return 0
49
}
50
return value
51
}
53
// Exhausted reports whether the budget is spent. A secondary limit reports no
54
// remaining count at all, so Retry-After alone is enough to mean "stop".
55
func (r RateLimit) Exhausted() bool {
56
return (r.Limit > 0 && r.Remaining <= 0) || r.RetryAfter > 0
57
}
59
// RecoversAt is the earliest a caller should try again. It never returns a
60
// time in the past for an exhausted budget, because a caller that treats
61
// "already reset" as "go ahead" is the caller that spends the next window in
62
// one burst.
63
func (r RateLimit) RecoversAt(now time.Time) time.Time {
64
if r.RetryAfter > 0 {
65
return now.Add(r.RetryAfter)
66
}
67
if r.Reset.After(now) {
68
return r.Reset
69
}
70
return now
71
}
73
func (r RateLimit) String() string {
74
if r.Limit == 0 && r.RetryAfter == 0 {
75
return ""
76
}
77
described := fmt.Sprintf("%d of %d remaining", r.Remaining, r.Limit)
78
if r.Resource != "" {
79
described += " for " + r.Resource
80
}
81
if r.RetryAfter > 0 {
82
return described + fmt.Sprintf("; retry after %s", r.RetryAfter)
83
}
84
if !r.Reset.IsZero() {
85
return described + fmt.Sprintf("; resets at %s", r.Reset.Format(time.RFC3339))
86
}
87
return described
88
}
90
// RetryAfter reports when a request refused for rate limiting may be repeated.
91
// A caller on a timer needs this to stop asking: retrying a spent budget on
92
// the usual interval is what turns one exhausted window into several.
93
func RetryAfter(err error) (time.Time, bool) {
94
var responseError *apiError
95
if !errors.As(err, &responseError) || !responseError.limit.Exhausted() {
96
return time.Time{}, false
97
}
98
return responseError.limit.RecoversAt(time.Now().UTC()), true
99
}