Skip to content

Commit 1654d32

Browse files
danmoseleyCopilot
andauthored
errors: improve rate limit error messages for AI agents (#2386)
* errors: improve rate limit error messages for AI agents When the GitHub API returns a rate limit error, replace the raw Go HTTP error string with a clean, actionable message so agents know exactly how long to wait before retrying. Before: search code: GET https://api.github.com/search/code: 403 API rate limit exceeded for user ID 12345. [rate reset in 2m59s] After: search code: GitHub API rate limit exceeded. Retry after 2m59s. create issue: GitHub secondary rate limit exceeded. Retry after 47s. create issue: GitHub secondary rate limit exceeded. Wait before retrying. Edge cases: expired/zero reset time, nil RetryAfter, and errors wrapped with errors.As all produce "Wait before retrying." rather than a negative or confusing duration. The original error is stored in context via addGitHubAPIErrorToContext before the rate-limit check, so middleware is unaffected. Fixes #2385. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * errors: fix flaky rate limit tests Compute expectedRetryIn before calling the function under test, and use larger reset time offsets (20-30 min), so a 1s boundary during time.Duration.Round cannot cause spurious mismatches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * errors: extract requireErrorText and assertContextHasError test helpers Reduces repetition in TestNewGitHubAPIErrorResponse_RateLimits subtests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix edge cases: sub-second rate limit durations and UTF-8 BOM - Primary rate limit: compute time.Until(resetTime) once and check the rounded result is >0 before showing 'Retry after X'. This avoids a TOCTOU race between the After(time.Now()) guard and the subsequent time.Until call, and prevents showing 'Retry after 0s.' when the reset time is imminent. - Secondary rate limit: round RetryAfter first, then check >0. Previously, a RetryAfter of e.g. 200ms would pass the >0 guard but format as 'Retry after 0s.' after rounding. - Add tests for both sub-second edge cases. - Remove UTF-8 BOM accidentally introduced in error_test.go by .NET WriteAllText with the default UTF8 encoding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2cff183 commit 1654d32

2 files changed

Lines changed: 260 additions & 1 deletion

File tree

pkg/errors/error.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package errors
22

33
import (
44
"context"
5+
stderrors "errors"
56
"fmt"
67
"net/http"
8+
"time"
79

810
"github.com/github/github-mcp-server/pkg/utils"
911
"github.com/google/go-github/v87/github"
@@ -159,6 +161,35 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github
159161
if ctx != nil {
160162
_, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
161163
}
164+
165+
var rateLimitErr *github.RateLimitError
166+
if stderrors.As(err, &rateLimitErr) {
167+
resetTime := rateLimitErr.Rate.Reset.Time
168+
if !resetTime.IsZero() {
169+
retryIn := time.Until(resetTime).Round(time.Second)
170+
if retryIn > 0 {
171+
return utils.NewToolResultError(fmt.Sprintf(
172+
"%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn))
173+
}
174+
}
175+
return utils.NewToolResultError(fmt.Sprintf(
176+
"%s: GitHub API rate limit exceeded. Wait before retrying.", message))
177+
}
178+
179+
var abuseErr *github.AbuseRateLimitError
180+
if stderrors.As(err, &abuseErr) {
181+
if abuseErr.RetryAfter != nil {
182+
retryAfter := abuseErr.RetryAfter.Round(time.Second)
183+
if retryAfter > 0 {
184+
return utils.NewToolResultError(fmt.Sprintf(
185+
"%s: GitHub secondary rate limit exceeded. Retry after %v.",
186+
message, retryAfter))
187+
}
188+
}
189+
return utils.NewToolResultError(fmt.Sprintf(
190+
"%s: GitHub secondary rate limit exceeded. Wait before retrying.", message))
191+
}
192+
162193
return utils.NewToolResultErrorFromErr(message, err)
163194
}
164195

pkg/errors/error_test.go

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import (
55
"fmt"
66
"net/http"
77
"testing"
8-
8+
"time"
99
"github.com/google/go-github/v87/github"
10+
"github.com/modelcontextprotocol/go-sdk/mcp"
1011
"github.com/stretchr/testify/assert"
1112
"github.com/stretchr/testify/require"
1213
)
@@ -460,3 +461,230 @@ func TestMiddlewareScenario(t *testing.T) {
460461
assert.Contains(t, gqlMessages, "mutation failed")
461462
})
462463
}
464+
465+
// requireErrorText asserts that result is a non-nil MCP tool error and returns its text content.
466+
func requireErrorText(t *testing.T, result *mcp.CallToolResult) string {
467+
t.Helper()
468+
require.NotNil(t, result)
469+
require.True(t, result.IsError)
470+
require.NotEmpty(t, result.Content)
471+
text, ok := result.Content[0].(*mcp.TextContent)
472+
require.True(t, ok, "expected *mcp.TextContent, got %T", result.Content[0])
473+
return text.Text
474+
}
475+
476+
// assertContextHasError asserts that exactly one error is stored in ctx and it matches expectedErr.
477+
//
478+
//nolint:revive // t must be first for test helpers; context-as-argument doesn't apply here
479+
func assertContextHasError(t *testing.T, ctx context.Context, expectedErr error) {
480+
t.Helper()
481+
apiErrors, err := GetGitHubAPIErrors(ctx)
482+
require.NoError(t, err)
483+
require.Len(t, apiErrors, 1)
484+
assert.Equal(t, expectedErr, apiErrors[0].Err)
485+
}
486+
487+
func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) {
488+
t.Run("RateLimitError produces clean message with retry time", func(t *testing.T) {
489+
// Given a context with GitHub error tracking enabled
490+
ctx := ContextWithGitHubErrors(context.Background())
491+
492+
resetTime := time.Now().Add(30 * time.Minute)
493+
rateLimitErr := &github.RateLimitError{
494+
Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
495+
Response: &http.Response{StatusCode: 403},
496+
Message: "API rate limit exceeded",
497+
}
498+
resp := &github.Response{Response: rateLimitErr.Response}
499+
500+
// Capture expected duration before the call so both use the same time.Until snapshot
501+
expectedRetryIn := time.Until(resetTime).Round(time.Second)
502+
503+
// When we create an API error response for a rate limit error
504+
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
505+
506+
// Then the message should be clean and actionable (no raw URLs or status codes)
507+
text := requireErrorText(t, result)
508+
assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn))
509+
assert.NotContains(t, text, "https://")
510+
assert.NotContains(t, text, "403")
511+
512+
// And the original error should still be stored in context for middleware
513+
assertContextHasError(t, ctx, rateLimitErr)
514+
})
515+
516+
t.Run("AbuseRateLimitError with RetryAfter produces clean message with wait time", func(t *testing.T) {
517+
// Given a context with GitHub error tracking enabled
518+
ctx := ContextWithGitHubErrors(context.Background())
519+
520+
retryAfter := 47 * time.Second
521+
abuseErr := &github.AbuseRateLimitError{
522+
Response: &http.Response{StatusCode: 403},
523+
Message: "You have exceeded a secondary rate limit.",
524+
RetryAfter: &retryAfter,
525+
}
526+
resp := &github.Response{Response: abuseErr.Response}
527+
528+
// When we create an API error response for a secondary rate limit error
529+
result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr)
530+
531+
// And the message should include the specific retry duration
532+
text := requireErrorText(t, result)
533+
assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 47s.")
534+
assert.NotContains(t, text, "https://")
535+
assert.NotContains(t, text, "403")
536+
537+
// And the original error should still be stored in context for middleware
538+
assertContextHasError(t, ctx, abuseErr)
539+
})
540+
541+
t.Run("AbuseRateLimitError without RetryAfter produces clean message without wait time", func(t *testing.T) {
542+
// Given a context with GitHub error tracking enabled
543+
ctx := ContextWithGitHubErrors(context.Background())
544+
545+
abuseErr := &github.AbuseRateLimitError{
546+
Response: &http.Response{StatusCode: 403},
547+
Message: "You have exceeded a secondary rate limit.",
548+
RetryAfter: nil,
549+
}
550+
resp := &github.Response{Response: abuseErr.Response}
551+
552+
// When we create an API error response for a secondary rate limit error without retry info
553+
result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr)
554+
555+
// And the message should be clean and actionable
556+
text := requireErrorText(t, result)
557+
assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.")
558+
assert.NotContains(t, text, "https://")
559+
assert.NotContains(t, text, "403")
560+
561+
// And the original error should still be stored in context for middleware
562+
assertContextHasError(t, ctx, abuseErr)
563+
})
564+
565+
t.Run("AbuseRateLimitError with sub-second RetryAfter falls back to wait message", func(t *testing.T) {
566+
ctx := ContextWithGitHubErrors(context.Background())
567+
568+
// 200ms rounds to 0s, so should fall back to the generic wait message
569+
retryAfter := 200 * time.Millisecond
570+
abuseErr := &github.AbuseRateLimitError{
571+
Response: &http.Response{StatusCode: 403},
572+
Message: "You have exceeded a secondary rate limit.",
573+
RetryAfter: &retryAfter,
574+
}
575+
resp := &github.Response{Response: abuseErr.Response}
576+
577+
result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr)
578+
579+
text := requireErrorText(t, result)
580+
assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.")
581+
})
582+
583+
t.Run("RateLimitError with reset time in the past falls back to wait message", func(t *testing.T) {
584+
ctx := ContextWithGitHubErrors(context.Background())
585+
586+
resetTime := time.Now().Add(-5 * time.Second) // already passed
587+
rateLimitErr := &github.RateLimitError{
588+
Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
589+
Response: &http.Response{StatusCode: 403},
590+
Message: "API rate limit exceeded",
591+
}
592+
resp := &github.Response{Response: rateLimitErr.Response}
593+
594+
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
595+
596+
text := requireErrorText(t, result)
597+
assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.")
598+
})
599+
600+
t.Run("RateLimitError with sub-second reset time falls back to wait message", func(t *testing.T) {
601+
ctx := ContextWithGitHubErrors(context.Background())
602+
603+
// 250ms in the future: still positive, but rounds to 0s, so should fall back
604+
resetTime := time.Now().Add(250 * time.Millisecond)
605+
rateLimitErr := &github.RateLimitError{
606+
Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
607+
Response: &http.Response{StatusCode: 403},
608+
Message: "API rate limit exceeded",
609+
}
610+
resp := &github.Response{Response: rateLimitErr.Response}
611+
612+
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
613+
614+
text := requireErrorText(t, result)
615+
assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.")
616+
})
617+
618+
t.Run("RateLimitError with zero reset time falls back to wait message", func(t *testing.T) {
619+
ctx := ContextWithGitHubErrors(context.Background())
620+
621+
rateLimitErr := &github.RateLimitError{
622+
Rate: github.Rate{}, // zero Reset time
623+
Response: &http.Response{StatusCode: 403},
624+
Message: "API rate limit exceeded",
625+
}
626+
resp := &github.Response{Response: rateLimitErr.Response}
627+
628+
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
629+
630+
text := requireErrorText(t, result)
631+
assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.")
632+
})
633+
634+
t.Run("wrapped RateLimitError is handled via errors.As", func(t *testing.T) {
635+
ctx := ContextWithGitHubErrors(context.Background())
636+
637+
resetTime := time.Now().Add(20 * time.Minute)
638+
rateLimitErr := &github.RateLimitError{
639+
Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
640+
Response: &http.Response{StatusCode: 403},
641+
Message: "API rate limit exceeded",
642+
}
643+
wrappedErr := fmt.Errorf("transport layer: %w", rateLimitErr)
644+
resp := &github.Response{Response: rateLimitErr.Response}
645+
646+
// Capture expected duration before the call so both use the same time.Until snapshot
647+
expectedRetryIn := time.Until(resetTime).Round(time.Second)
648+
649+
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, wrappedErr)
650+
651+
text := requireErrorText(t, result)
652+
assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn))
653+
assert.NotContains(t, text, "https://")
654+
})
655+
656+
t.Run("wrapped AbuseRateLimitError is handled via errors.As", func(t *testing.T) {
657+
ctx := ContextWithGitHubErrors(context.Background())
658+
659+
retryAfter := 30 * time.Second
660+
abuseErr := &github.AbuseRateLimitError{
661+
Response: &http.Response{StatusCode: 403},
662+
Message: "secondary rate limit",
663+
RetryAfter: &retryAfter,
664+
}
665+
wrappedErr := fmt.Errorf("transport layer: %w", abuseErr)
666+
resp := &github.Response{Response: abuseErr.Response}
667+
668+
result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, wrappedErr)
669+
670+
text := requireErrorText(t, result)
671+
assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 30s.")
672+
assert.NotContains(t, text, "https://")
673+
})
674+
675+
t.Run("non-rate-limit GitHub API error passes through the original error message", func(t *testing.T) {
676+
// Given a context with GitHub error tracking enabled
677+
ctx := ContextWithGitHubErrors(context.Background())
678+
679+
resp := &github.Response{Response: &http.Response{StatusCode: 422}}
680+
originalErr := fmt.Errorf("validation failed")
681+
682+
// When we create an API error response for a non-rate-limit error
683+
result := NewGitHubAPIErrorResponse(ctx, "API call failed", resp, originalErr)
684+
685+
// Then the message should contain the original error text unchanged
686+
text := requireErrorText(t, result)
687+
assert.Contains(t, text, "validation failed")
688+
})
689+
}
690+

0 commit comments

Comments
 (0)