Skip to content

Commit 524b7f2

Browse files
authored
fix: ignore GitHub API rate limit failures in update and health commands (#23032)
1 parent b93729b commit 524b7f2

4 files changed

Lines changed: 151 additions & 0 deletions

File tree

pkg/cli/health_command.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/github/gh-aw/pkg/console"
1313
"github.com/github/gh-aw/pkg/constants"
14+
"github.com/github/gh-aw/pkg/gitutil"
1415
"github.com/github/gh-aw/pkg/logger"
1516
"github.com/github/gh-aw/pkg/workflow"
1617
"github.com/spf13/cobra"
@@ -135,6 +136,16 @@ func RunHealth(config HealthConfig) error {
135136
// Fetch workflow runs from GitHub
136137
runs, err := fetchWorkflowRuns(workflowAPIName, startDate, config.RepoOverride, config.Verbose)
137138
if err != nil {
139+
if gitutil.IsRateLimitError(err.Error()) {
140+
// Rate limiting is a transient infrastructure condition, not a code error.
141+
// Warn and exit cleanly so CI jobs are not marked as failed.
142+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Skipping health check: GitHub API rate limit exceeded"))
143+
if config.JSONOutput && config.WorkflowName != "" {
144+
// Emit an empty-run JSON structure so callers can still parse the output.
145+
return displayDetailedHealth(nil, config)
146+
}
147+
return nil
148+
}
138149
return fmt.Errorf("failed to fetch workflow runs: %w", err)
139150
}
140151

pkg/cli/update_workflows.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111

1212
"github.com/github/gh-aw/pkg/console"
13+
"github.com/github/gh-aw/pkg/gitutil"
1314
"github.com/github/gh-aw/pkg/parser"
1415
"github.com/github/gh-aw/pkg/workflow"
1516
)
@@ -64,12 +65,30 @@ func UpdateWorkflows(workflowNames []string, allowMajor, force, verbose bool, en
6465
showUpdateSummary(successfulUpdates, failedUpdates)
6566

6667
if len(successfulUpdates) == 0 {
68+
// If all failures were due to GitHub API rate limiting, treat as non-fatal.
69+
// Rate limiting is a transient infrastructure condition, not a code error.
70+
if len(failedUpdates) > 0 && allFailuresAreRateLimited(failedUpdates) {
71+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("All workflow updates skipped due to GitHub API rate limiting"))
72+
return nil
73+
}
6774
return errors.New("no workflows were successfully updated")
6875
}
6976

7077
return nil
7178
}
7279

80+
// allFailuresAreRateLimited returns true if every failed workflow update was caused
81+
// by a GitHub API rate limit error. Used to distinguish transient rate-limiting
82+
// (non-fatal) from genuine update failures (fatal).
83+
func allFailuresAreRateLimited(failures []updateFailure) bool {
84+
for _, f := range failures {
85+
if !gitutil.IsRateLimitError(f.Error) {
86+
return false
87+
}
88+
}
89+
return true
90+
}
91+
7392
// findWorkflowsWithSource finds all workflows that have a source field
7493
func findWorkflowsWithSource(workflowsDir string, filterNames []string, verbose bool) ([]*workflowWithSource, error) {
7594
updateLog.Printf("Finding workflows with source field in %s", workflowsDir)

pkg/gitutil/gitutil.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ import (
1010

1111
var log = logger.New("gitutil:gitutil")
1212

13+
// IsRateLimitError checks if an error message indicates a GitHub API rate limit error.
14+
// This is used to detect transient failures caused by hitting the GitHub API rate limit
15+
// (HTTP 403 "API rate limit exceeded" or HTTP 429 responses).
16+
func IsRateLimitError(errMsg string) bool {
17+
lowerMsg := strings.ToLower(errMsg)
18+
return strings.Contains(lowerMsg, "api rate limit exceeded") ||
19+
strings.Contains(lowerMsg, "rate limit exceeded") ||
20+
strings.Contains(lowerMsg, "secondary rate limit")
21+
}
22+
1323
// IsAuthError checks if an error message indicates an authentication issue.
1424
// This is used to detect when GitHub API calls fail due to missing or invalid credentials.
1525
func IsAuthError(errMsg string) bool {

pkg/gitutil/gitutil_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//go:build !integration
2+
3+
package gitutil
4+
5+
import (
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestIsRateLimitError(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
errMsg string
15+
expected bool
16+
}{
17+
{
18+
name: "GitHub API rate limit exceeded (HTTP 403)",
19+
errMsg: "gh: API rate limit exceeded for installation. If you reach out to GitHub Support for help, please include the request ID (HTTP 403)",
20+
expected: true,
21+
},
22+
{
23+
name: "rate limit exceeded lowercase",
24+
errMsg: "rate limit exceeded",
25+
expected: true,
26+
},
27+
{
28+
name: "HTTP 403 with API rate limit message",
29+
errMsg: "HTTP 403: API rate limit exceeded for installation.",
30+
expected: true,
31+
},
32+
{
33+
name: "secondary rate limit in GitHub error message",
34+
errMsg: "gh: You have exceeded a secondary rate limit",
35+
expected: true,
36+
},
37+
{
38+
name: "authentication error is not a rate limit error",
39+
errMsg: "authentication required. Run 'gh auth login' first",
40+
expected: false,
41+
},
42+
{
43+
name: "not found error is not a rate limit error",
44+
errMsg: "HTTP 404: Not Found",
45+
expected: false,
46+
},
47+
{
48+
name: "empty string",
49+
errMsg: "",
50+
expected: false,
51+
},
52+
{
53+
name: "unrelated error message",
54+
errMsg: "failed to parse workflow runs: unexpected end of JSON input",
55+
expected: false,
56+
},
57+
{
58+
name: "mixed case",
59+
errMsg: "API Rate Limit Exceeded for installation",
60+
expected: true,
61+
},
62+
}
63+
64+
for _, tt := range tests {
65+
t.Run(tt.name, func(t *testing.T) {
66+
result := IsRateLimitError(tt.errMsg)
67+
assert.Equal(t, tt.expected, result, "IsRateLimitError(%q) should return %v", tt.errMsg, tt.expected)
68+
})
69+
}
70+
}
71+
72+
func TestIsAuthError(t *testing.T) {
73+
tests := []struct {
74+
name string
75+
errMsg string
76+
expected bool
77+
}{
78+
{
79+
name: "GH_TOKEN mention",
80+
errMsg: "GH_TOKEN is not set",
81+
expected: true,
82+
},
83+
{
84+
name: "authentication error",
85+
errMsg: "authentication required",
86+
expected: true,
87+
},
88+
{
89+
name: "not logged in",
90+
errMsg: "not logged into any GitHub hosts",
91+
expected: true,
92+
},
93+
{
94+
name: "rate limit error is not an auth error",
95+
errMsg: "API rate limit exceeded for installation",
96+
expected: false,
97+
},
98+
{
99+
name: "empty string",
100+
errMsg: "",
101+
expected: false,
102+
},
103+
}
104+
105+
for _, tt := range tests {
106+
t.Run(tt.name, func(t *testing.T) {
107+
result := IsAuthError(tt.errMsg)
108+
assert.Equal(t, tt.expected, result, "IsAuthError(%q) should return %v", tt.errMsg, tt.expected)
109+
})
110+
}
111+
}

0 commit comments

Comments
 (0)