Skip to content

Commit e285b9b

Browse files
committed
repository initial source and CI
1 parent 13099f3 commit e285b9b

File tree

10 files changed

+681
-1
lines changed

10 files changed

+681
-1
lines changed

.github/workflows/lint.yaml

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Lint
2+
on:
3+
push:
4+
tags:
5+
- v*
6+
branches:
7+
- main
8+
pull_request:
9+
branches:
10+
- main
11+
12+
jobs:
13+
lint:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/setup-go@c4a742cab115ed795e34d4513e2cf7d472deb55f # v3
17+
with:
18+
go-version: 1.19
19+
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3
20+
- name: Lint
21+
uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 # v3.4.0
22+
with:
23+
version: v1.50.1
24+
args: --timeout=3m

.github/workflows/test.yaml

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Test
2+
on:
3+
push:
4+
tags:
5+
- v*
6+
branches:
7+
- main
8+
pull_request:
9+
branches:
10+
- main
11+
12+
jobs:
13+
build_and_test:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/setup-go@c4a742cab115ed795e34d4513e2cf7d472deb55f # v3
17+
with:
18+
go-version: 1.19
19+
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3
20+
- name: Verify dependencies
21+
run: go mod verify
22+
- name: Build
23+
run: go build -v ./...
24+
- name: Vet
25+
run: go vet -v ./...
26+
- name: Test
27+
run: go test -v -count=1 -shuffle=on -timeout=30m -race ./...

README.md

+44-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,45 @@
11
# go-github-ratelimit
2-
A GoLang HTTP RoundTripper that handles GitHub API secondary rate limits
2+
3+
[![Go Report Card](https://goreportcard.com/badge/github.com/gofri/go-github-ratelimit)](https://goreportcard.com/report/github.com/gofri/go-github-ratelimit)
4+
5+
Package go-github-ratelimit provides a http.RoundTripper implementation that handles [secondary rate limit](https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#secondary-rate-limits) for the GitHub API.
6+
The RoundTripper waits for the secondary rate limit to finish in a blocking mode and then issues/retries requests.
7+
8+
go-github-ratelimit can be used with any HTTP client communicating with GitHub API.
9+
It is meant to complement [go-github](https://github.com/google/go-github), but there is no association between this repository and the go-github repository or Google.
10+
11+
## Installation
12+
```go get github.com/gofri/go-github-ratelimit```
13+
14+
## Usage Example (with go-github and [oauth2](golang.org/x/oauth2))
15+
```go
16+
import "github.com/google/go-github/github"
17+
import "golang.org/x/oauth2"
18+
import "github.com/gofri/go-github-ratelimit/github_ratelimit"
19+
20+
func main() {
21+
ctx := context.Background()
22+
ts := oauth2.StaticTokenSource(
23+
&oauth2.Token{AccessToken: "Your Personal Access Token"},
24+
)
25+
tc := oauth2.NewClient(ctx, ts)
26+
rateLimiter := github_ratelimit.NewRateLimitWaiterClient(tc.Transport)
27+
client := github.NewClient(rateLimiter)
28+
29+
// now use the client as you please
30+
}
31+
```
32+
33+
## Options
34+
The RoundTripper accepts a set of optional options:
35+
- Single Sleep Limit: limit the sleep time for a single rate limit.
36+
- Total Sleep Limit: limit the accumulated sleep time for all rate limits.
37+
38+
The RoundTripper accepts a set of optional callbacks:
39+
- On Limit Detected: callback for when a rate limit that requires sleeping is detected.
40+
- On Single Limit Exceeded: callback for when a rate limit that exceeds the single sleep limit is detected.
41+
- On Total Limit Exceeded: callback for when a rate limit that exceeds the total sleep limit is detected.
42+
43+
## License
44+
This package is distributed under the MIT license found in the LICENSE file.
45+
Contribution and feedback is welcome.

github_ratelimit/callback.go

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package github_ratelimit
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"time"
7+
)
8+
9+
// CallbackContext is passed to all callbacks.
10+
// Fields might be nillable, depending on the specific callback and field.
11+
type CallbackContext struct {
12+
UserContext *context.Context
13+
RoundTripper *SecondaryRateLimitWaiter
14+
SleepUntil *time.Time
15+
TotalSleepTime *time.Duration
16+
Request *http.Request
17+
Response *http.Response
18+
}
19+
20+
// OnLimitDetected is a callback to be called when a new rate limit is detected (before the sleep)
21+
// The totalSleepTime includes the sleep time for the upcoming sleep
22+
// Note: called while holding the lock.
23+
type OnLimitDetected func(*CallbackContext)
24+
25+
// OnSingleLimitPassed is a callback to be called when a rate limit is exceeding the limit for a single sleep.
26+
// The sleepUntil represents the end of sleep time if the limit was not exceeded.
27+
// The totalSleepTime does not include the sleep (that is not going to happen).
28+
// Note: called while holding the lock.
29+
type OnSingleLimitExceeded func(*CallbackContext)
30+
31+
// OnTotalLimitExceeded is a callback to be called when a rate limit is exceeding the limit for the total sleep.
32+
// The sleepUntil represents the end of sleep time if the limit was not exceeded.
33+
// The totalSleepTime does not include the sleep (that is not going to happen).
34+
// Note: called while holding the lock.
35+
type OnTotalLimitExceeded func(*CallbackContext)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package github_ratelimit_test
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"net/http"
7+
"strings"
8+
"sync"
9+
"time"
10+
)
11+
12+
type SecondaryRateLimitInjecter struct {
13+
base http.RoundTripper
14+
options *SecondaryRateLimitInjecterOptions
15+
blockUntil time.Time
16+
lock sync.Mutex
17+
AbuseAttempts int
18+
}
19+
20+
const (
21+
RateLimitInjecterEvery = "SECONDARY_RATE_LIMIT_INJECTER_EVERY"
22+
RateLimitInjecterDuration = "SECONDARY_RATE_LIMIT_INJECTER_DURATION"
23+
)
24+
25+
type SecondaryRateLimitInjecterOptions struct {
26+
Every time.Duration
27+
Sleep time.Duration
28+
}
29+
30+
func NewRateLimitInjecter(base http.RoundTripper, options *SecondaryRateLimitInjecterOptions) (http.RoundTripper, error) {
31+
if options.IsNoop() {
32+
return base, nil
33+
}
34+
if err := options.Validate(); err != nil {
35+
return nil, err
36+
}
37+
38+
injecter := &SecondaryRateLimitInjecter{
39+
base: base,
40+
options: options,
41+
}
42+
return injecter, nil
43+
}
44+
45+
func (t *SecondaryRateLimitInjecter) RoundTrip(request *http.Request) (*http.Response, error) {
46+
resp, err := t.base.RoundTrip(request)
47+
if err != nil {
48+
return resp, err
49+
}
50+
51+
t.lock.Lock()
52+
defer t.lock.Unlock()
53+
54+
// initialize on first use
55+
now := time.Now()
56+
if t.blockUntil.IsZero() {
57+
t.blockUntil = now
58+
}
59+
60+
// on-going rate limit
61+
if t.blockUntil.After(now) {
62+
t.AbuseAttempts++
63+
return t.toRetryResponse(resp), nil
64+
}
65+
66+
nextStart := t.NextSleepStart()
67+
68+
// start a rate limit period
69+
if !now.Before(nextStart) {
70+
t.blockUntil = nextStart.Add(t.options.Sleep)
71+
return t.toRetryResponse(resp), nil
72+
}
73+
74+
return resp, nil
75+
}
76+
77+
func (r *SecondaryRateLimitInjecterOptions) IsNoop() bool {
78+
return r.Every == 0 || r.Sleep == 0
79+
}
80+
81+
func (r *SecondaryRateLimitInjecterOptions) Validate() error {
82+
if r.Every < 0 {
83+
return fmt.Errorf("injecter expects a positive trigger interval")
84+
}
85+
if r.Sleep < 0 {
86+
return fmt.Errorf("injecter expects a positive sleep interval")
87+
}
88+
return nil
89+
}
90+
func (r *SecondaryRateLimitInjecter) CurrentSleepEnd() time.Time {
91+
return r.blockUntil
92+
}
93+
94+
func (r *SecondaryRateLimitInjecter) NextSleepStart() time.Time {
95+
return r.blockUntil.Add(r.options.Every)
96+
}
97+
98+
func (t *SecondaryRateLimitInjecter) toRetryResponse(resp *http.Response) *http.Response {
99+
resp.StatusCode = http.StatusForbidden
100+
timeUntil := time.Until(t.blockUntil)
101+
if timeUntil.Nanoseconds()%int64(time.Second) > 0 {
102+
timeUntil += time.Second
103+
}
104+
resp.Header.Set("Retry-After", fmt.Sprintf("%v", int(timeUntil.Seconds())))
105+
doc_url := "https://docs.github.com/en/rest/guides/best-practices-for-integrators?apiVersion=2022-11-28#secondary-rate-limits"
106+
resp.Body = io.NopCloser(strings.NewReader(`{"documentation_url":"` + doc_url + `"}`))
107+
return resp
108+
}

github_ratelimit/options.go

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package github_ratelimit
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
type Option func(*SecondaryRateLimitWaiter)
9+
10+
// WithLimitDetectedCallback adds a callback to be called when a new active rate limit is detected.
11+
func WithLimitDetectedCallback(callback OnLimitDetected) Option {
12+
return func(t *SecondaryRateLimitWaiter) {
13+
t.onLimitDetected = callback
14+
}
15+
}
16+
17+
// WithSingleSleepLimit adds a limit to the duration allowed to wait for a single sleep (rate limit).
18+
// The callback parameter is nillable.
19+
func WithSingleSleepLimit(limit time.Duration, callback OnSingleLimitExceeded) Option {
20+
return func(t *SecondaryRateLimitWaiter) {
21+
t.singleSleepLimit = &limit
22+
t.onSingleLimitExceeded = callback
23+
}
24+
}
25+
26+
// WithTotalSleepLimit adds a limit to the accumulated duration allowed to wait for all sleeps (one or more rate limits).
27+
// The callback parameter is nillable.
28+
func WithTotalSleepLimit(limit time.Duration, callback OnTotalLimitExceeded) Option {
29+
return func(t *SecondaryRateLimitWaiter) {
30+
t.totalSleepLimit = &limit
31+
t.onTotalLimitExceeded = callback
32+
}
33+
}
34+
35+
// WithUserContext sets the user context to be passed to callbacks.
36+
func WithUserContext(ctx context.Context) Option {
37+
return func(t *SecondaryRateLimitWaiter) {
38+
t.userContext = &ctx
39+
}
40+
}
41+
42+
func applyOptions(w *SecondaryRateLimitWaiter, opts ...Option) {
43+
for _, o := range opts {
44+
if o == nil {
45+
continue
46+
}
47+
o(w)
48+
}
49+
}

0 commit comments

Comments
 (0)