Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ This function:
- Creates a `GitHubAPIError` with the provided message, response, and error
- Stores the error in the context for middleware inspection
- Returns an appropriate MCP tool error response
- Adds a `github/error` entry to the result's `_meta`, with an observed error `kind` and HTTP `status` when available

The `github/error` metadata kind is one of:

- `http_error` — GitHub returned a non-2xx HTTP response
- `rate_limited` or `secondary_rate_limited` — GitHub reported a primary or secondary rate limit
- `response_read_error` — GitHub returned a 2xx response, but reading or decoding it failed
- `transport_error` — no HTTP response was received
- `canceled` — the request was canceled or its context expired

For example, a conflict response includes:

```json
"_meta": { "github/error": { "kind": "http_error", "status": 409 } }
```

This metadata describes the response observed by the server; it does not guarantee whether a write took effect. In particular, a transport error, a response read error, or a 5xx response may leave the outcome uncertain. Clients should inspect the affected resource before retrying a write whose outcome is unknown. The existing human-readable error text is unchanged.

### For GitHub GraphQL API Errors

Expand Down
60 changes: 51 additions & 9 deletions pkg/errors/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,34 +164,76 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github
if ctx != nil {
_, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
}
withErrorMeta := func(result *mcp.CallToolResult) *mcp.CallToolResult {
result.Meta = githubAPIErrorMeta(ctx, resp, err)
return result
}

if rateLimitErr, ok := stderrors.AsType[*github.RateLimitError](err); ok {
resetTime := rateLimitErr.Rate.Reset.Time
if !resetTime.IsZero() {
retryIn := time.Until(resetTime).Round(time.Second)
if retryIn > 0 {
return utils.NewToolResultError(fmt.Sprintf(
"%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn))
return withErrorMeta(utils.NewToolResultError(fmt.Sprintf(
"%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn)))
}
}
return utils.NewToolResultError(fmt.Sprintf(
"%s: GitHub API rate limit exceeded. Wait before retrying.", message))
return withErrorMeta(utils.NewToolResultError(fmt.Sprintf(
"%s: GitHub API rate limit exceeded. Wait before retrying.", message)))
}

if abuseErr, ok := stderrors.AsType[*github.AbuseRateLimitError](err); ok {
if abuseErr.RetryAfter != nil {
retryAfter := abuseErr.RetryAfter.Round(time.Second)
if retryAfter > 0 {
return utils.NewToolResultError(fmt.Sprintf(
return withErrorMeta(utils.NewToolResultError(fmt.Sprintf(
"%s: GitHub secondary rate limit exceeded. Retry after %v.",
message, retryAfter))
message, retryAfter)))
}
}
return utils.NewToolResultError(fmt.Sprintf(
"%s: GitHub secondary rate limit exceeded. Wait before retrying.", message))
return withErrorMeta(utils.NewToolResultError(fmt.Sprintf(
"%s: GitHub secondary rate limit exceeded. Wait before retrying.", message)))
}

return utils.NewToolResultErrorFromErr(message, formatGitHubValidationError(resp, err))
return withErrorMeta(utils.NewToolResultErrorFromErr(message, formatGitHubValidationError(resp, err)))
}

func githubAPIErrorMeta(ctx context.Context, resp *github.Response, err error) mcp.Meta {
status := 0
if resp != nil {
status = resp.StatusCode
}
if status == 0 {
if apiErr, ok := stderrors.AsType[*github.ErrorResponse](err); ok && apiErr.Response != nil {
status = apiErr.Response.StatusCode
} else if apiErr, ok := stderrors.AsType[*github.RateLimitError](err); ok && apiErr.Response != nil {
status = apiErr.Response.StatusCode
} else if apiErr, ok := stderrors.AsType[*github.AbuseRateLimitError](err); ok && apiErr.Response != nil {
status = apiErr.Response.StatusCode
}
}

_, isRateLimit := stderrors.AsType[*github.RateLimitError](err)
_, isSecondaryRateLimit := stderrors.AsType[*github.AbuseRateLimitError](err)
kind := "transport_error"
switch {
case stderrors.Is(err, context.Canceled), stderrors.Is(err, context.DeadlineExceeded), status == 0 && ctx != nil && ctx.Err() != nil:
kind = "canceled"
case isRateLimit:
kind = "rate_limited"
case isSecondaryRateLimit:
kind = "secondary_rate_limited"
case status >= http.StatusOK && status < http.StatusMultipleChoices:
kind = "response_read_error"
case status != 0:
kind = "http_error"
}

metadata := map[string]any{"kind": kind}
if status != 0 {
metadata["status"] = status
}
return mcp.Meta{"github/error": metadata}
}

// formatGitHubValidationError exposes the parsed fields of 422 responses without
Expand Down
91 changes: 88 additions & 3 deletions pkg/errors/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ package errors

import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"

"github.com/google/go-github/v92/github"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/http"
"testing"
"time"
)

func TestGitHubErrorContext(t *testing.T) {
Expand Down Expand Up @@ -688,6 +690,89 @@ func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) {
})
}

func TestNewGitHubAPIErrorResponse_Meta(t *testing.T) {
tests := []struct {
name string
ctx func() context.Context
resp *github.Response
err error
wantKind string
wantStatus int
}{
{
name: "HTTP error",
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusConflict}},
err: fmt.Errorf("conflict"),
wantKind: "http_error",
wantStatus: http.StatusConflict,
},
{
name: "primary rate limit",
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusForbidden}},
err: &github.RateLimitError{
Rate: github.Rate{},
Response: &http.Response{StatusCode: http.StatusForbidden},
},
wantKind: "rate_limited",
wantStatus: http.StatusForbidden,
},
{
name: "secondary rate limit",
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusForbidden}},
err: &github.AbuseRateLimitError{
Response: &http.Response{StatusCode: http.StatusForbidden},
},
wantKind: "secondary_rate_limited",
wantStatus: http.StatusForbidden,
},
{
name: "response read error",
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusOK}},
err: fmt.Errorf("unexpected EOF"),
wantKind: "response_read_error",
wantStatus: http.StatusOK,
},
{
name: "transport error",
err: fmt.Errorf("connection reset"),
wantKind: "transport_error",
},
{
name: "canceled request",
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
},
err: context.Canceled,
wantKind: "canceled",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
if tt.ctx != nil {
ctx = tt.ctx()
}
ctx = ContextWithGitHubErrors(ctx)

result := NewGitHubAPIErrorResponse(ctx, "API call failed", tt.resp, tt.err)

require.True(t, result.IsError)
wantMeta := map[string]any{"kind": tt.wantKind}
if tt.wantStatus != 0 {
wantMeta["status"] = tt.wantStatus
}
assert.Equal(t, wantMeta, result.Meta["github/error"])

encoded, err := json.Marshal(result)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"_meta":{"github/error":`)
})
}
}

func TestNewGitHubAPIErrorResponse_ValidationMessages(t *testing.T) {
t.Run("ruleset ErrorResponse includes sanitized structured validation messages", func(t *testing.T) {
ctx := ContextWithGitHubErrors(context.Background())
Expand Down