chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
"github.com/google/go-github/v89/github"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
type GitHubAPIError struct {
|
||||
Message string `json:"message"`
|
||||
Response *github.Response `json:"-"`
|
||||
Err error `json:"-"`
|
||||
}
|
||||
|
||||
// NewGitHubAPIError creates a new GitHubAPIError with the provided message, response, and error.
|
||||
func newGitHubAPIError(message string, resp *github.Response, err error) *GitHubAPIError {
|
||||
return &GitHubAPIError{
|
||||
Message: message,
|
||||
Response: resp,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *GitHubAPIError) Error() string {
|
||||
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
|
||||
}
|
||||
|
||||
type GitHubGraphQLError struct {
|
||||
Message string `json:"message"`
|
||||
Err error `json:"-"`
|
||||
}
|
||||
|
||||
func newGitHubGraphQLError(message string, err error) *GitHubGraphQLError {
|
||||
return &GitHubGraphQLError{
|
||||
Message: message,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *GitHubGraphQLError) Error() string {
|
||||
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
|
||||
}
|
||||
|
||||
type GitHubRawAPIError struct {
|
||||
Message string `json:"message"`
|
||||
Response *http.Response `json:"-"`
|
||||
Err error `json:"-"`
|
||||
}
|
||||
|
||||
func newGitHubRawAPIError(message string, resp *http.Response, err error) *GitHubRawAPIError {
|
||||
return &GitHubRawAPIError{
|
||||
Message: message,
|
||||
Response: resp,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *GitHubRawAPIError) Error() string {
|
||||
return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
|
||||
}
|
||||
|
||||
type GitHubErrorKey struct{}
|
||||
type GitHubCtxErrors struct {
|
||||
api []*GitHubAPIError
|
||||
graphQL []*GitHubGraphQLError
|
||||
raw []*GitHubRawAPIError
|
||||
}
|
||||
|
||||
// ContextWithGitHubErrors updates or creates a context with a pointer to GitHub error information (to be used by middleware).
|
||||
func ContextWithGitHubErrors(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
||||
// If the context already has GitHubCtxErrors, we just empty the slices to start fresh
|
||||
val.api = []*GitHubAPIError{}
|
||||
val.graphQL = []*GitHubGraphQLError{}
|
||||
val.raw = []*GitHubRawAPIError{}
|
||||
} else {
|
||||
// If not, we create a new GitHubCtxErrors and set it in the context
|
||||
ctx = context.WithValue(ctx, GitHubErrorKey{}, &GitHubCtxErrors{})
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// GetGitHubAPIErrors retrieves the slice of GitHubAPIErrors from the context.
|
||||
func GetGitHubAPIErrors(ctx context.Context) ([]*GitHubAPIError, error) {
|
||||
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
||||
return val.api, nil // return the slice of API errors from the context
|
||||
}
|
||||
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
||||
}
|
||||
|
||||
// GetGitHubGraphQLErrors retrieves the slice of GitHubGraphQLErrors from the context.
|
||||
func GetGitHubGraphQLErrors(ctx context.Context) ([]*GitHubGraphQLError, error) {
|
||||
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
||||
return val.graphQL, nil // return the slice of GraphQL errors from the context
|
||||
}
|
||||
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
||||
}
|
||||
|
||||
// GetGitHubRawAPIErrors retrieves the slice of GitHubRawAPIErrors from the context.
|
||||
func GetGitHubRawAPIErrors(ctx context.Context) ([]*GitHubRawAPIError, error) {
|
||||
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
||||
return val.raw, nil // return the slice of raw API errors from the context
|
||||
}
|
||||
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
||||
}
|
||||
|
||||
func NewGitHubAPIErrorToCtx(ctx context.Context, message string, resp *github.Response, err error) (context.Context, error) {
|
||||
apiErr := newGitHubAPIError(message, resp, err)
|
||||
if ctx != nil {
|
||||
_, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
|
||||
}
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func NewGitHubGraphQLErrorToCtx(ctx context.Context, message string, err error) (context.Context, error) {
|
||||
graphQLErr := newGitHubGraphQLError(message, err)
|
||||
if ctx != nil {
|
||||
_, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling
|
||||
}
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func addGitHubAPIErrorToContext(ctx context.Context, err *GitHubAPIError) (context.Context, error) {
|
||||
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
||||
val.api = append(val.api, err) // append the error to the existing slice in the context
|
||||
return ctx, nil
|
||||
}
|
||||
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
||||
}
|
||||
|
||||
func addGitHubGraphQLErrorToContext(ctx context.Context, err *GitHubGraphQLError) (context.Context, error) {
|
||||
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
||||
val.graphQL = append(val.graphQL, err) // append the error to the existing slice in the context
|
||||
return ctx, nil
|
||||
}
|
||||
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
||||
}
|
||||
|
||||
func addRawAPIErrorToContext(ctx context.Context, err *GitHubRawAPIError) (context.Context, error) {
|
||||
if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
|
||||
val.raw = append(val.raw, err)
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
|
||||
}
|
||||
|
||||
// NewGitHubAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
|
||||
func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github.Response, err error) *mcp.CallToolResult {
|
||||
apiErr := newGitHubAPIError(message, resp, err)
|
||||
if ctx != nil {
|
||||
_, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
|
||||
}
|
||||
|
||||
var rateLimitErr *github.RateLimitError
|
||||
if stderrors.As(err, &rateLimitErr) {
|
||||
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 utils.NewToolResultError(fmt.Sprintf(
|
||||
"%s: GitHub API rate limit exceeded. Wait before retrying.", message))
|
||||
}
|
||||
|
||||
var abuseErr *github.AbuseRateLimitError
|
||||
if stderrors.As(err, &abuseErr) {
|
||||
if abuseErr.RetryAfter != nil {
|
||||
retryAfter := abuseErr.RetryAfter.Round(time.Second)
|
||||
if retryAfter > 0 {
|
||||
return utils.NewToolResultError(fmt.Sprintf(
|
||||
"%s: GitHub secondary rate limit exceeded. Retry after %v.",
|
||||
message, retryAfter))
|
||||
}
|
||||
}
|
||||
return utils.NewToolResultError(fmt.Sprintf(
|
||||
"%s: GitHub secondary rate limit exceeded. Wait before retrying.", message))
|
||||
}
|
||||
|
||||
return utils.NewToolResultErrorFromErr(message, err)
|
||||
}
|
||||
|
||||
// NewGitHubGraphQLErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
|
||||
func NewGitHubGraphQLErrorResponse(ctx context.Context, message string, err error) *mcp.CallToolResult {
|
||||
graphQLErr := newGitHubGraphQLError(message, err)
|
||||
if ctx != nil {
|
||||
_, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling
|
||||
}
|
||||
return utils.NewToolResultErrorFromErr(message, err)
|
||||
}
|
||||
|
||||
// NewGitHubRawAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
|
||||
func NewGitHubRawAPIErrorResponse(ctx context.Context, message string, resp *http.Response, err error) *mcp.CallToolResult {
|
||||
rawErr := newGitHubRawAPIError(message, resp, err)
|
||||
if ctx != nil {
|
||||
_, _ = addRawAPIErrorToContext(ctx, rawErr) // Explicitly ignore error for graceful handling
|
||||
}
|
||||
return utils.NewToolResultErrorFromErr(message, err)
|
||||
}
|
||||
|
||||
// NewGitHubAPIStatusErrorResponse handles cases where the API call succeeds (err == nil)
|
||||
// but returns an unexpected HTTP status code. It creates a synthetic error from the
|
||||
// status code and response body, then records it in context for observability tracking.
|
||||
func NewGitHubAPIStatusErrorResponse(ctx context.Context, message string, resp *github.Response, body []byte) *mcp.CallToolResult {
|
||||
err := fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||
return NewGitHubAPIErrorResponse(ctx, message, resp, err)
|
||||
}
|
||||
|
||||
// StructuredResolutionError is a machine-readable error returned by name-resolution
|
||||
// helpers (e.g. resolving a project field or single-select option by name). Agents
|
||||
// can parse the JSON body to self-correct without re-prompting.
|
||||
//
|
||||
// Kind values:
|
||||
// - "field_not_found" — no project field matches the supplied name
|
||||
// - "field_ambiguous" — more than one project field shares the supplied name
|
||||
// - "option_not_found" — no option on the resolved single-select field matches
|
||||
// - "option_ambiguous" — duplicate option names on the resolved field
|
||||
// - "item_not_in_project" — the issue/PR exists but is not an item on the project
|
||||
// - "wrong_field_type" — the named field is not the data type the caller expected
|
||||
type StructuredResolutionError struct {
|
||||
Kind string `json:"error"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Candidates []any `json:"candidates,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// Error implements the error interface; the message is the JSON body so that the
|
||||
// downstream tool result also carries the structured payload as plain text.
|
||||
func (e *StructuredResolutionError) Error() string {
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(`{"error":%q,"name":%q}`, e.Kind, e.Name)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// NewStructuredResolutionError constructs a StructuredResolutionError.
|
||||
func NewStructuredResolutionError(kind, name, hint string, candidates []any) *StructuredResolutionError {
|
||||
return &StructuredResolutionError{
|
||||
Kind: kind,
|
||||
Name: name,
|
||||
Hint: hint,
|
||||
Candidates: candidates,
|
||||
}
|
||||
}
|
||||
|
||||
// NewStructuredResolutionErrorResponse returns an mcp.CallToolResult whose text body
|
||||
// is the JSON-serialised StructuredResolutionError, suitable for agent self-correction.
|
||||
func NewStructuredResolutionErrorResponse(err *StructuredResolutionError) *mcp.CallToolResult {
|
||||
return utils.NewToolResultError(err.Error())
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/go-github/v89/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) {
|
||||
t.Run("API errors can be added to context and retrieved", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
// Create a mock GitHub response
|
||||
resp := &github.Response{
|
||||
Response: &http.Response{
|
||||
StatusCode: 404,
|
||||
Status: "404 Not Found",
|
||||
},
|
||||
}
|
||||
originalErr := fmt.Errorf("resource not found")
|
||||
|
||||
// When we add an API error to the context
|
||||
updatedCtx, err := NewGitHubAPIErrorToCtx(ctx, "failed to fetch resource", resp, originalErr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then we should be able to retrieve the error from the updated context
|
||||
apiErrors, err := GetGitHubAPIErrors(updatedCtx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apiErrors, 1)
|
||||
|
||||
apiError := apiErrors[0]
|
||||
assert.Equal(t, "failed to fetch resource", apiError.Message)
|
||||
assert.Equal(t, resp, apiError.Response)
|
||||
assert.Equal(t, originalErr, apiError.Err)
|
||||
assert.Equal(t, "failed to fetch resource: resource not found", apiError.Error())
|
||||
})
|
||||
|
||||
t.Run("GraphQL errors can be added to context and retrieved", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
originalErr := fmt.Errorf("GraphQL query failed")
|
||||
|
||||
// When we add a GraphQL error to the context
|
||||
graphQLErr := newGitHubGraphQLError("failed to execute mutation", originalErr)
|
||||
updatedCtx, err := addGitHubGraphQLErrorToContext(ctx, graphQLErr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then we should be able to retrieve the error from the updated context
|
||||
gqlErrors, err := GetGitHubGraphQLErrors(updatedCtx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, gqlErrors, 1)
|
||||
|
||||
gqlError := gqlErrors[0]
|
||||
assert.Equal(t, "failed to execute mutation", gqlError.Message)
|
||||
assert.Equal(t, originalErr, gqlError.Err)
|
||||
assert.Equal(t, "failed to execute mutation: GraphQL query failed", gqlError.Error())
|
||||
})
|
||||
|
||||
t.Run("Raw API errors can be added to context and retrieved", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
// Create a mock HTTP response
|
||||
resp := &http.Response{
|
||||
StatusCode: 404,
|
||||
Status: "404 Not Found",
|
||||
}
|
||||
originalErr := fmt.Errorf("raw content not found")
|
||||
|
||||
// When we add a raw API error to the context
|
||||
rawAPIErr := newGitHubRawAPIError("failed to fetch raw content", resp, originalErr)
|
||||
updatedCtx, err := addRawAPIErrorToContext(ctx, rawAPIErr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then we should be able to retrieve the error from the updated context
|
||||
rawErrors, err := GetGitHubRawAPIErrors(updatedCtx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rawErrors, 1)
|
||||
|
||||
rawError := rawErrors[0]
|
||||
assert.Equal(t, "failed to fetch raw content", rawError.Message)
|
||||
assert.Equal(t, resp, rawError.Response)
|
||||
assert.Equal(t, originalErr, rawError.Err)
|
||||
})
|
||||
|
||||
t.Run("multiple errors can be accumulated in context", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
// When we add multiple API errors
|
||||
resp1 := &github.Response{Response: &http.Response{StatusCode: 404}}
|
||||
resp2 := &github.Response{Response: &http.Response{StatusCode: 403}}
|
||||
|
||||
ctx, err := NewGitHubAPIErrorToCtx(ctx, "first error", resp1, fmt.Errorf("not found"))
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, err = NewGitHubAPIErrorToCtx(ctx, "second error", resp2, fmt.Errorf("forbidden"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// And add a GraphQL error
|
||||
gqlErr := newGitHubGraphQLError("graphql error", fmt.Errorf("query failed"))
|
||||
ctx, err = addGitHubGraphQLErrorToContext(ctx, gqlErr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// And add a raw API error
|
||||
rawErr := newGitHubRawAPIError("raw error", &http.Response{StatusCode: 404}, fmt.Errorf("not found"))
|
||||
ctx, err = addRawAPIErrorToContext(ctx, rawErr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then we should be able to retrieve all errors
|
||||
apiErrors, err := GetGitHubAPIErrors(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, apiErrors, 2)
|
||||
|
||||
gqlErrors, err := GetGitHubGraphQLErrors(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, gqlErrors, 1)
|
||||
|
||||
rawErrors, err := GetGitHubRawAPIErrors(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, rawErrors, 1)
|
||||
|
||||
// Verify error details
|
||||
assert.Equal(t, "first error", apiErrors[0].Message)
|
||||
assert.Equal(t, "second error", apiErrors[1].Message)
|
||||
assert.Equal(t, "graphql error", gqlErrors[0].Message)
|
||||
assert.Equal(t, "raw error", rawErrors[0].Message)
|
||||
})
|
||||
|
||||
t.Run("context pointer sharing allows middleware to inspect errors without context propagation", func(t *testing.T) {
|
||||
// This test demonstrates the key behavior: even when the context itself
|
||||
// isn't propagated through function calls, the pointer to the error slice
|
||||
// is shared, allowing middleware to inspect errors that were added later.
|
||||
|
||||
// Given a context with GitHub error tracking enabled
|
||||
originalCtx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
// Simulate a middleware that captures the context early
|
||||
var middlewareCtx context.Context
|
||||
|
||||
// Middleware function that captures the context
|
||||
middleware := func(ctx context.Context) {
|
||||
middlewareCtx = ctx // Middleware saves the context reference
|
||||
}
|
||||
|
||||
// Call middleware with the original context
|
||||
middleware(originalCtx)
|
||||
|
||||
// Simulate some business logic that adds errors to the context
|
||||
// but doesn't propagate the updated context back to middleware
|
||||
businessLogic := func(ctx context.Context) {
|
||||
resp := &github.Response{Response: &http.Response{StatusCode: 500}}
|
||||
|
||||
// Add an error to the context (this modifies the shared pointer)
|
||||
_, err := NewGitHubAPIErrorToCtx(ctx, "business logic failed", resp, fmt.Errorf("internal error"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add another error
|
||||
_, err = NewGitHubAPIErrorToCtx(ctx, "second failure", resp, fmt.Errorf("another error"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Execute business logic - note that we don't propagate the returned context
|
||||
businessLogic(originalCtx)
|
||||
|
||||
// Then the middleware should be able to see the errors that were added
|
||||
// even though it only has a reference to the original context
|
||||
apiErrors, err := GetGitHubAPIErrors(middlewareCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, apiErrors, 2, "Middleware should see errors added after it captured the context")
|
||||
|
||||
assert.Equal(t, "business logic failed", apiErrors[0].Message)
|
||||
assert.Equal(t, "second failure", apiErrors[1].Message)
|
||||
})
|
||||
|
||||
t.Run("context without GitHub errors returns error", func(t *testing.T) {
|
||||
// Given a regular context without GitHub error tracking
|
||||
ctx := context.Background()
|
||||
|
||||
// When we try to retrieve errors
|
||||
apiErrors, err := GetGitHubAPIErrors(ctx)
|
||||
|
||||
// Then it should return an error
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "context does not contain GitHubCtxErrors")
|
||||
assert.Nil(t, apiErrors)
|
||||
|
||||
// Same for GraphQL errors
|
||||
gqlErrors, err := GetGitHubGraphQLErrors(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "context does not contain GitHubCtxErrors")
|
||||
assert.Nil(t, gqlErrors)
|
||||
|
||||
// Same for raw API errors
|
||||
rawErrors, err := GetGitHubRawAPIErrors(ctx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "context does not contain GitHubCtxErrors")
|
||||
assert.Nil(t, rawErrors)
|
||||
})
|
||||
|
||||
t.Run("ContextWithGitHubErrors resets existing errors", func(t *testing.T) {
|
||||
// Given a context with existing errors
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
resp := &github.Response{Response: &http.Response{StatusCode: 404}}
|
||||
ctx, err := NewGitHubAPIErrorToCtx(ctx, "existing error", resp, fmt.Errorf("error"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a raw API error too
|
||||
rawErr := newGitHubRawAPIError("existing raw error", &http.Response{StatusCode: 404}, fmt.Errorf("error"))
|
||||
ctx, err = addRawAPIErrorToContext(ctx, rawErr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify errors exist
|
||||
apiErrors, err := GetGitHubAPIErrors(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, apiErrors, 1)
|
||||
|
||||
rawErrors, err := GetGitHubRawAPIErrors(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, rawErrors, 1)
|
||||
|
||||
// When we call ContextWithGitHubErrors again
|
||||
resetCtx := ContextWithGitHubErrors(ctx)
|
||||
|
||||
// Then all errors should be cleared
|
||||
apiErrors, err = GetGitHubAPIErrors(resetCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, apiErrors, 0, "API errors should be reset")
|
||||
|
||||
rawErrors, err = GetGitHubRawAPIErrors(resetCtx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, rawErrors, 0, "Raw API errors should be reset")
|
||||
})
|
||||
|
||||
t.Run("NewGitHubAPIErrorResponse creates MCP error result and stores context error", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
resp := &github.Response{Response: &http.Response{StatusCode: 422}}
|
||||
originalErr := fmt.Errorf("validation failed")
|
||||
|
||||
// When we create an API error response
|
||||
result := NewGitHubAPIErrorResponse(ctx, "API call failed", resp, originalErr)
|
||||
|
||||
// Then it should return an MCP error result
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
|
||||
// And the error should be stored in the context
|
||||
apiErrors, err := GetGitHubAPIErrors(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apiErrors, 1)
|
||||
|
||||
apiError := apiErrors[0]
|
||||
assert.Equal(t, "API call failed", apiError.Message)
|
||||
assert.Equal(t, resp, apiError.Response)
|
||||
assert.Equal(t, originalErr, apiError.Err)
|
||||
})
|
||||
|
||||
t.Run("NewGitHubGraphQLErrorResponse creates MCP error result and stores context error", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
originalErr := fmt.Errorf("mutation failed")
|
||||
|
||||
// When we create a GraphQL error response
|
||||
result := NewGitHubGraphQLErrorResponse(ctx, "GraphQL call failed", originalErr)
|
||||
|
||||
// Then it should return an MCP error result
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
|
||||
// And the error should be stored in the context
|
||||
gqlErrors, err := GetGitHubGraphQLErrors(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, gqlErrors, 1)
|
||||
|
||||
gqlError := gqlErrors[0]
|
||||
assert.Equal(t, "GraphQL call failed", gqlError.Message)
|
||||
assert.Equal(t, originalErr, gqlError.Err)
|
||||
})
|
||||
|
||||
t.Run("NewGitHubAPIStatusErrorResponse creates MCP error result from status code", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
resp := &github.Response{Response: &http.Response{StatusCode: 422}}
|
||||
body := []byte(`{"message": "Validation Failed"}`)
|
||||
|
||||
// When we create a status error response
|
||||
result := NewGitHubAPIStatusErrorResponse(ctx, "failed to create issue", resp, body)
|
||||
|
||||
// Then it should return an MCP error result
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, result.IsError)
|
||||
|
||||
// And the error should be stored in the context
|
||||
apiErrors, err := GetGitHubAPIErrors(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apiErrors, 1)
|
||||
|
||||
apiError := apiErrors[0]
|
||||
assert.Equal(t, "failed to create issue", apiError.Message)
|
||||
assert.Equal(t, resp, apiError.Response)
|
||||
// The synthetic error should contain the status code and body
|
||||
assert.Contains(t, apiError.Err.Error(), "unexpected status 422")
|
||||
assert.Contains(t, apiError.Err.Error(), "Validation Failed")
|
||||
})
|
||||
|
||||
t.Run("NewGitHubAPIErrorToCtx with uninitialized context does not error", func(t *testing.T) {
|
||||
// Given a regular context without GitHub error tracking initialized
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a mock GitHub response
|
||||
resp := &github.Response{
|
||||
Response: &http.Response{
|
||||
StatusCode: 500,
|
||||
Status: "500 Internal Server Error",
|
||||
},
|
||||
}
|
||||
originalErr := fmt.Errorf("internal server error")
|
||||
|
||||
// When we try to add an API error to an uninitialized context
|
||||
updatedCtx, err := NewGitHubAPIErrorToCtx(ctx, "failed operation", resp, originalErr)
|
||||
|
||||
// Then it should not return an error (graceful handling)
|
||||
assert.NoError(t, err, "NewGitHubAPIErrorToCtx should handle uninitialized context gracefully")
|
||||
assert.Equal(t, ctx, updatedCtx, "Context should be returned unchanged when not initialized")
|
||||
|
||||
// And attempting to retrieve errors should still return an error since context wasn't initialized
|
||||
apiErrors, err := GetGitHubAPIErrors(updatedCtx)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "context does not contain GitHubCtxErrors")
|
||||
assert.Nil(t, apiErrors)
|
||||
})
|
||||
|
||||
t.Run("NewGitHubAPIErrorToCtx with nil context does not error", func(t *testing.T) {
|
||||
// Given a nil context
|
||||
var ctx context.Context
|
||||
|
||||
// Create a mock GitHub response
|
||||
resp := &github.Response{
|
||||
Response: &http.Response{
|
||||
StatusCode: 400,
|
||||
Status: "400 Bad Request",
|
||||
},
|
||||
}
|
||||
originalErr := fmt.Errorf("bad request")
|
||||
|
||||
// When we try to add an API error to a nil context
|
||||
updatedCtx, err := NewGitHubAPIErrorToCtx(ctx, "failed with nil context", resp, originalErr)
|
||||
|
||||
// Then it should not return an error (graceful handling)
|
||||
assert.NoError(t, err, "NewGitHubAPIErrorToCtx should handle nil context gracefully")
|
||||
assert.Nil(t, updatedCtx, "Context should remain nil when passed as nil")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitHubErrorTypes(t *testing.T) {
|
||||
t.Run("GitHubAPIError implements error interface", func(t *testing.T) {
|
||||
resp := &github.Response{Response: &http.Response{StatusCode: 404}}
|
||||
originalErr := fmt.Errorf("not found")
|
||||
|
||||
apiErr := newGitHubAPIError("test message", resp, originalErr)
|
||||
|
||||
// Should implement error interface
|
||||
var err error = apiErr
|
||||
assert.Equal(t, "test message: not found", err.Error())
|
||||
})
|
||||
|
||||
t.Run("GitHubGraphQLError implements error interface", func(t *testing.T) {
|
||||
originalErr := fmt.Errorf("query failed")
|
||||
|
||||
gqlErr := newGitHubGraphQLError("test message", originalErr)
|
||||
|
||||
// Should implement error interface
|
||||
var err error = gqlErr
|
||||
assert.Equal(t, "test message: query failed", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
// TestMiddlewareScenario demonstrates a realistic middleware scenario
|
||||
func TestMiddlewareScenario(t *testing.T) {
|
||||
t.Run("realistic middleware error collection scenario", func(t *testing.T) {
|
||||
// Simulate a realistic HTTP middleware scenario
|
||||
|
||||
// 1. Request comes in, middleware sets up error tracking
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
// 2. Middleware stores reference to context for later inspection
|
||||
var middlewareCtx context.Context
|
||||
setupMiddleware := func(ctx context.Context) context.Context {
|
||||
middlewareCtx = ctx
|
||||
return ctx
|
||||
}
|
||||
|
||||
// 3. Setup middleware
|
||||
ctx = setupMiddleware(ctx)
|
||||
|
||||
// 4. Simulate multiple service calls that add errors
|
||||
simulateServiceCall1 := func(ctx context.Context) {
|
||||
resp := &github.Response{Response: &http.Response{StatusCode: 403}}
|
||||
_, err := NewGitHubAPIErrorToCtx(ctx, "insufficient permissions", resp, fmt.Errorf("forbidden"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
simulateServiceCall2 := func(ctx context.Context) {
|
||||
resp := &github.Response{Response: &http.Response{StatusCode: 404}}
|
||||
_, err := NewGitHubAPIErrorToCtx(ctx, "resource not found", resp, fmt.Errorf("not found"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
simulateGraphQLCall := func(ctx context.Context) {
|
||||
gqlErr := newGitHubGraphQLError("mutation failed", fmt.Errorf("invalid input"))
|
||||
_, err := addGitHubGraphQLErrorToContext(ctx, gqlErr)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 5. Execute service calls (without context propagation)
|
||||
simulateServiceCall1(ctx)
|
||||
simulateServiceCall2(ctx)
|
||||
simulateGraphQLCall(ctx)
|
||||
|
||||
// 6. Middleware inspects errors at the end of request processing
|
||||
finalizeMiddleware := func(ctx context.Context) ([]string, []string) {
|
||||
var apiErrorMessages []string
|
||||
var gqlErrorMessages []string
|
||||
|
||||
if apiErrors, err := GetGitHubAPIErrors(ctx); err == nil {
|
||||
for _, apiErr := range apiErrors {
|
||||
apiErrorMessages = append(apiErrorMessages, apiErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
if gqlErrors, err := GetGitHubGraphQLErrors(ctx); err == nil {
|
||||
for _, gqlErr := range gqlErrors {
|
||||
gqlErrorMessages = append(gqlErrorMessages, gqlErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
return apiErrorMessages, gqlErrorMessages
|
||||
}
|
||||
|
||||
// 7. Middleware can see all errors that were added during request processing
|
||||
apiMessages, gqlMessages := finalizeMiddleware(middlewareCtx)
|
||||
|
||||
// Verify all errors were captured
|
||||
assert.Len(t, apiMessages, 2)
|
||||
assert.Contains(t, apiMessages, "insufficient permissions")
|
||||
assert.Contains(t, apiMessages, "resource not found")
|
||||
|
||||
assert.Len(t, gqlMessages, 1)
|
||||
assert.Contains(t, gqlMessages, "mutation failed")
|
||||
})
|
||||
}
|
||||
|
||||
// requireErrorText asserts that result is a non-nil MCP tool error and returns its text content.
|
||||
func requireErrorText(t *testing.T, result *mcp.CallToolResult) string {
|
||||
t.Helper()
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.IsError)
|
||||
require.NotEmpty(t, result.Content)
|
||||
text, ok := result.Content[0].(*mcp.TextContent)
|
||||
require.True(t, ok, "expected *mcp.TextContent, got %T", result.Content[0])
|
||||
return text.Text
|
||||
}
|
||||
|
||||
// assertContextHasError asserts that exactly one error is stored in ctx and it matches expectedErr.
|
||||
//
|
||||
//nolint:revive // t must be first for test helpers; context-as-argument doesn't apply here
|
||||
func assertContextHasError(t *testing.T, ctx context.Context, expectedErr error) {
|
||||
t.Helper()
|
||||
apiErrors, err := GetGitHubAPIErrors(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apiErrors, 1)
|
||||
assert.Equal(t, expectedErr, apiErrors[0].Err)
|
||||
}
|
||||
|
||||
func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) {
|
||||
t.Run("RateLimitError produces clean message with retry time", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
resetTime := time.Now().Add(30 * time.Minute)
|
||||
rateLimitErr := &github.RateLimitError{
|
||||
Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
|
||||
Response: &http.Response{StatusCode: 403},
|
||||
Message: "API rate limit exceeded",
|
||||
}
|
||||
resp := &github.Response{Response: rateLimitErr.Response}
|
||||
|
||||
// Capture expected duration before the call so both use the same time.Until snapshot
|
||||
expectedRetryIn := time.Until(resetTime).Round(time.Second)
|
||||
|
||||
// When we create an API error response for a rate limit error
|
||||
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
|
||||
|
||||
// Then the message should be clean and actionable (no raw URLs or status codes)
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn))
|
||||
assert.NotContains(t, text, "https://")
|
||||
assert.NotContains(t, text, "403")
|
||||
|
||||
// And the original error should still be stored in context for middleware
|
||||
assertContextHasError(t, ctx, rateLimitErr)
|
||||
})
|
||||
|
||||
t.Run("AbuseRateLimitError with RetryAfter produces clean message with wait time", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
retryAfter := 47 * time.Second
|
||||
abuseErr := &github.AbuseRateLimitError{
|
||||
Response: &http.Response{StatusCode: 403},
|
||||
Message: "You have exceeded a secondary rate limit.",
|
||||
RetryAfter: &retryAfter,
|
||||
}
|
||||
resp := &github.Response{Response: abuseErr.Response}
|
||||
|
||||
// When we create an API error response for a secondary rate limit error
|
||||
result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr)
|
||||
|
||||
// And the message should include the specific retry duration
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 47s.")
|
||||
assert.NotContains(t, text, "https://")
|
||||
assert.NotContains(t, text, "403")
|
||||
|
||||
// And the original error should still be stored in context for middleware
|
||||
assertContextHasError(t, ctx, abuseErr)
|
||||
})
|
||||
|
||||
t.Run("AbuseRateLimitError without RetryAfter produces clean message without wait time", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
abuseErr := &github.AbuseRateLimitError{
|
||||
Response: &http.Response{StatusCode: 403},
|
||||
Message: "You have exceeded a secondary rate limit.",
|
||||
RetryAfter: nil,
|
||||
}
|
||||
resp := &github.Response{Response: abuseErr.Response}
|
||||
|
||||
// When we create an API error response for a secondary rate limit error without retry info
|
||||
result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr)
|
||||
|
||||
// And the message should be clean and actionable
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.")
|
||||
assert.NotContains(t, text, "https://")
|
||||
assert.NotContains(t, text, "403")
|
||||
|
||||
// And the original error should still be stored in context for middleware
|
||||
assertContextHasError(t, ctx, abuseErr)
|
||||
})
|
||||
|
||||
t.Run("AbuseRateLimitError with sub-second RetryAfter falls back to wait message", func(t *testing.T) {
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
// 200ms rounds to 0s, so should fall back to the generic wait message
|
||||
retryAfter := 200 * time.Millisecond
|
||||
abuseErr := &github.AbuseRateLimitError{
|
||||
Response: &http.Response{StatusCode: 403},
|
||||
Message: "You have exceeded a secondary rate limit.",
|
||||
RetryAfter: &retryAfter,
|
||||
}
|
||||
resp := &github.Response{Response: abuseErr.Response}
|
||||
|
||||
result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr)
|
||||
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.")
|
||||
})
|
||||
|
||||
t.Run("RateLimitError with reset time in the past falls back to wait message", func(t *testing.T) {
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
resetTime := time.Now().Add(-5 * time.Second) // already passed
|
||||
rateLimitErr := &github.RateLimitError{
|
||||
Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
|
||||
Response: &http.Response{StatusCode: 403},
|
||||
Message: "API rate limit exceeded",
|
||||
}
|
||||
resp := &github.Response{Response: rateLimitErr.Response}
|
||||
|
||||
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
|
||||
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.")
|
||||
})
|
||||
|
||||
t.Run("RateLimitError with sub-second reset time falls back to wait message", func(t *testing.T) {
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
// 250ms in the future: still positive, but rounds to 0s, so should fall back
|
||||
resetTime := time.Now().Add(250 * time.Millisecond)
|
||||
rateLimitErr := &github.RateLimitError{
|
||||
Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
|
||||
Response: &http.Response{StatusCode: 403},
|
||||
Message: "API rate limit exceeded",
|
||||
}
|
||||
resp := &github.Response{Response: rateLimitErr.Response}
|
||||
|
||||
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
|
||||
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.")
|
||||
})
|
||||
|
||||
t.Run("RateLimitError with zero reset time falls back to wait message", func(t *testing.T) {
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
rateLimitErr := &github.RateLimitError{
|
||||
Rate: github.Rate{}, // zero Reset time
|
||||
Response: &http.Response{StatusCode: 403},
|
||||
Message: "API rate limit exceeded",
|
||||
}
|
||||
resp := &github.Response{Response: rateLimitErr.Response}
|
||||
|
||||
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr)
|
||||
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.")
|
||||
})
|
||||
|
||||
t.Run("wrapped RateLimitError is handled via errors.As", func(t *testing.T) {
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
resetTime := time.Now().Add(20 * time.Minute)
|
||||
rateLimitErr := &github.RateLimitError{
|
||||
Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}},
|
||||
Response: &http.Response{StatusCode: 403},
|
||||
Message: "API rate limit exceeded",
|
||||
}
|
||||
wrappedErr := fmt.Errorf("transport layer: %w", rateLimitErr)
|
||||
resp := &github.Response{Response: rateLimitErr.Response}
|
||||
|
||||
// Capture expected duration before the call so both use the same time.Until snapshot
|
||||
expectedRetryIn := time.Until(resetTime).Round(time.Second)
|
||||
|
||||
result := NewGitHubAPIErrorResponse(ctx, "search code", resp, wrappedErr)
|
||||
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn))
|
||||
assert.NotContains(t, text, "https://")
|
||||
})
|
||||
|
||||
t.Run("wrapped AbuseRateLimitError is handled via errors.As", func(t *testing.T) {
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
retryAfter := 30 * time.Second
|
||||
abuseErr := &github.AbuseRateLimitError{
|
||||
Response: &http.Response{StatusCode: 403},
|
||||
Message: "secondary rate limit",
|
||||
RetryAfter: &retryAfter,
|
||||
}
|
||||
wrappedErr := fmt.Errorf("transport layer: %w", abuseErr)
|
||||
resp := &github.Response{Response: abuseErr.Response}
|
||||
|
||||
result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, wrappedErr)
|
||||
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 30s.")
|
||||
assert.NotContains(t, text, "https://")
|
||||
})
|
||||
|
||||
t.Run("non-rate-limit GitHub API error passes through the original error message", func(t *testing.T) {
|
||||
// Given a context with GitHub error tracking enabled
|
||||
ctx := ContextWithGitHubErrors(context.Background())
|
||||
|
||||
resp := &github.Response{Response: &http.Response{StatusCode: 422}}
|
||||
originalErr := fmt.Errorf("validation failed")
|
||||
|
||||
// When we create an API error response for a non-rate-limit error
|
||||
result := NewGitHubAPIErrorResponse(ctx, "API call failed", resp, originalErr)
|
||||
|
||||
// Then the message should contain the original error text unchanged
|
||||
text := requireErrorText(t, result)
|
||||
assert.Contains(t, text, "validation failed")
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user