Skip to content

Commit 9370903

Browse files
committed
feat: add rate limit handling and duplicate PR prevention
1 parent bd2fff0 commit 9370903

4 files changed

Lines changed: 125 additions & 5 deletions

File tree

cmd/root.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import (
1111
var Version = "dev"
1212

1313
var rootCmd = &cobra.Command{
14-
Use: "cq",
15-
Short: "Autonomous GitHub issue worker powered by Claude",
16-
Version: Version,
17-
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
18-
RunE: runWatch,
14+
Use: "cq",
15+
Short: "Autonomous GitHub issue worker powered by Claude",
16+
Version: Version,
17+
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
18+
RunE: runWatch,
1919
}
2020

2121
// Execute runs the root command.

internal/poller/poller.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88

99
"github.com/google/go-github/v69/github"
10+
"github.com/tinybluerobots/cq/internal/ratelimit"
1011
)
1112

1213
// ErrInvalidRepoFormat is returned when a repo string is not in "owner/name" format.
@@ -38,6 +39,10 @@ func (p *Poller) ListRepos(ctx context.Context) ([]string, error) {
3839
for {
3940
repos, resp, err := p.Client.Repositories.ListByOrg(ctx, p.Org, opts)
4041
if err != nil {
42+
if ratelimit.Wait(ctx, err) {
43+
continue
44+
}
45+
4146
return nil, fmt.Errorf("listing repos for org %s: %w", p.Org, err)
4247
}
4348

@@ -82,6 +87,10 @@ func (p *Poller) ListIssues(ctx context.Context, repo string) ([]*github.Issue,
8287
for {
8388
issues, resp, err := p.Client.Issues.ListByRepo(ctx, owner, name, opts)
8489
if err != nil {
90+
if ratelimit.Wait(ctx, err) {
91+
continue
92+
}
93+
8594
return nil, fmt.Errorf("listing issues for %s: %w", repo, err)
8695
}
8796

internal/ratelimit/ratelimit.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package ratelimit
2+
3+
import (
4+
"context"
5+
"errors"
6+
"log/slog"
7+
"time"
8+
9+
"github.com/google/go-github/v69/github"
10+
)
11+
12+
// Wait checks if err is a GitHub rate limit error and sleeps until the reset time.
13+
// Returns true if it waited (caller should retry), false otherwise.
14+
func Wait(ctx context.Context, err error) bool {
15+
var rlErr *github.RateLimitError
16+
if errors.As(err, &rlErr) {
17+
wait := time.Until(rlErr.Rate.Reset.Time) + time.Second
18+
if wait < 0 {
19+
wait = time.Minute
20+
}
21+
22+
slog.Warn("rate limited", "reset", rlErr.Rate.Reset.Time, "wait", wait)
23+
24+
select {
25+
case <-time.After(wait):
26+
return true
27+
case <-ctx.Done():
28+
return false
29+
}
30+
}
31+
32+
var abuseErr *github.AbuseRateLimitError
33+
if errors.As(err, &abuseErr) {
34+
wait := abuseErr.GetRetryAfter()
35+
if wait == 0 {
36+
wait = time.Minute
37+
}
38+
39+
slog.Warn("abuse rate limited", "wait", wait)
40+
41+
select {
42+
case <-time.After(wait):
43+
return true
44+
case <-ctx.Done():
45+
return false
46+
}
47+
}
48+
49+
return false
50+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package ratelimit
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
"time"
8+
9+
"github.com/google/go-github/v69/github"
10+
)
11+
12+
func TestWait_NotRateLimited(t *testing.T) {
13+
if Wait(context.Background(), errors.New("some error")) {
14+
t.Error("expected false for non-rate-limit error")
15+
}
16+
}
17+
18+
func TestWait_NilError(t *testing.T) {
19+
if Wait(context.Background(), nil) {
20+
t.Error("expected false for nil error")
21+
}
22+
}
23+
24+
func TestWait_RateLimitError(t *testing.T) {
25+
resetTime := time.Now().Add(100 * time.Millisecond)
26+
err := &github.RateLimitError{
27+
Rate: github.Rate{
28+
Remaining: 0,
29+
Reset: github.Timestamp{Time: resetTime},
30+
},
31+
}
32+
33+
start := time.Now()
34+
ok := Wait(context.Background(), err)
35+
elapsed := time.Since(start)
36+
37+
if !ok {
38+
t.Error("expected true for rate limit error")
39+
}
40+
41+
if elapsed < 100*time.Millisecond {
42+
t.Errorf("waited only %v, expected >= 100ms", elapsed)
43+
}
44+
}
45+
46+
func TestWait_CancelledContext(t *testing.T) {
47+
resetTime := time.Now().Add(10 * time.Second)
48+
err := &github.RateLimitError{
49+
Rate: github.Rate{
50+
Remaining: 0,
51+
Reset: github.Timestamp{Time: resetTime},
52+
},
53+
}
54+
55+
ctx, cancel := context.WithCancel(context.Background())
56+
cancel()
57+
58+
if Wait(ctx, err) {
59+
t.Error("expected false for cancelled context")
60+
}
61+
}

0 commit comments

Comments
 (0)