Skip to content

Commit fd50a3e

Browse files
Add retry logic with jitter for sub_issue_write to handle parallel calls
Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com>
1 parent aa81e3d commit fd50a3e

File tree

2 files changed

+206
-18
lines changed

2 files changed

+206
-18
lines changed

pkg/github/issues.go

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"io"
8+
"math/rand/v2"
89
"net/http"
910
"strings"
1011
"time"
@@ -800,37 +801,81 @@ Options are:
800801
}
801802

802803
func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, replaceParent bool) (*mcp.CallToolResult, error) {
804+
const maxRetries = 3
803805
subIssueRequest := github.SubIssueRequest{
804806
SubIssueID: int64(subIssueID),
805807
ReplaceParent: github.Ptr(replaceParent),
806808
}
807809

808-
subIssue, resp, err := client.SubIssue.Add(ctx, owner, repo, int64(issueNumber), subIssueRequest)
809-
if err != nil {
810-
return ghErrors.NewGitHubAPIErrorResponse(ctx,
811-
"failed to add sub-issue",
812-
resp,
813-
err,
814-
), nil
815-
}
810+
var lastResp *github.Response
811+
var lastErr error
816812

817-
defer func() { _ = resp.Body.Close() }()
813+
for attempt := 0; attempt < maxRetries; attempt++ {
814+
if attempt > 0 {
815+
// Random jitter: 50-200ms to desynchronize parallel retries
816+
// #nosec G404 -- Using non-cryptographic random for jitter is acceptable
817+
jitter := time.Duration(50+rand.IntN(151)) * time.Millisecond
818+
time.Sleep(jitter)
819+
}
818820

819-
if resp.StatusCode != http.StatusCreated {
820-
body, err := io.ReadAll(resp.Body)
821-
if err != nil {
822-
return nil, fmt.Errorf("failed to read response body: %w", err)
821+
subIssue, resp, err := client.SubIssue.Add(ctx, owner, repo, int64(issueNumber), subIssueRequest)
822+
lastResp = resp
823+
lastErr = err
824+
825+
// Success case
826+
if err == nil && resp.StatusCode == http.StatusCreated {
827+
defer func() { _ = resp.Body.Close() }()
828+
r, err := json.Marshal(subIssue)
829+
if err != nil {
830+
return nil, fmt.Errorf("failed to marshal response: %w", err)
831+
}
832+
return utils.NewToolResultText(string(r)), nil
833+
}
834+
835+
// Check if this is a retryable priority conflict error
836+
shouldRetry := false
837+
if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity {
838+
// Read the body to check for priority conflict
839+
if resp.Body != nil {
840+
body, readErr := io.ReadAll(resp.Body)
841+
_ = resp.Body.Close()
842+
if readErr == nil {
843+
bodyStr := string(body)
844+
if strings.Contains(bodyStr, "Priority has already been taken") ||
845+
(err != nil && strings.Contains(err.Error(), "Priority has already been taken")) {
846+
shouldRetry = true
847+
}
848+
}
849+
}
850+
}
851+
852+
// If not a retryable error or last attempt, break and return error
853+
if !shouldRetry || attempt == maxRetries-1 {
854+
break
823855
}
824-
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add sub-issue", resp, body), nil
825856
}
826857

827-
r, err := json.Marshal(subIssue)
828-
if err != nil {
829-
return nil, fmt.Errorf("failed to marshal response: %w", err)
858+
// Handle final error after all retries exhausted
859+
if lastErr != nil {
860+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
861+
"failed to add sub-issue",
862+
lastResp,
863+
lastErr,
864+
), nil
830865
}
831866

832-
return utils.NewToolResultText(string(r)), nil
867+
if lastResp != nil {
868+
defer func() { _ = lastResp.Body.Close() }()
869+
if lastResp.StatusCode != http.StatusCreated {
870+
body, err := io.ReadAll(lastResp.Body)
871+
if err != nil {
872+
return nil, fmt.Errorf("failed to read response body: %w", err)
873+
}
874+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add sub-issue", lastResp, body), nil
875+
}
876+
}
833877

878+
return nil, fmt.Errorf("failed to add sub-issue after %d retries", maxRetries)
834879
}
835880

836881
func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int) (*mcp.CallToolResult, error) {

pkg/github/issues_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3020,6 +3020,149 @@ func Test_AddSubIssue(t *testing.T) {
30203020
}
30213021
}
30223022

3023+
func Test_AddSubIssue_RetryLogic(t *testing.T) {
3024+
// Setup mock issue for success case
3025+
mockIssue := &github.Issue{
3026+
Number: github.Ptr(42),
3027+
Title: github.Ptr("Parent Issue"),
3028+
Body: github.Ptr("This is the parent issue with a sub-issue"),
3029+
State: github.Ptr("open"),
3030+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"),
3031+
User: &github.User{
3032+
Login: github.Ptr("testuser"),
3033+
},
3034+
}
3035+
3036+
tests := []struct {
3037+
name string
3038+
mockedClient *http.Client
3039+
requestArgs map[string]interface{}
3040+
expectSuccess bool
3041+
expectedErrMsg string
3042+
}{
3043+
{
3044+
name: "successful retry after priority conflict",
3045+
mockedClient: func() *http.Client {
3046+
// Create a handler that fails twice with priority conflict, then succeeds
3047+
callCount := 0
3048+
handler := func(w http.ResponseWriter, _ *http.Request) {
3049+
callCount++
3050+
if callCount <= 2 {
3051+
// First two calls fail with priority conflict
3052+
w.WriteHeader(http.StatusUnprocessableEntity)
3053+
_, _ = w.Write([]byte(`{"message": "An error occurred while adding the sub-issue to the parent issue. Priority has already been taken"}`))
3054+
} else {
3055+
// Third call succeeds
3056+
w.WriteHeader(http.StatusCreated)
3057+
responseJSON, _ := json.Marshal(mockIssue)
3058+
_, _ = w.Write(responseJSON)
3059+
}
3060+
}
3061+
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
3062+
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: handler,
3063+
})
3064+
}(),
3065+
requestArgs: map[string]interface{}{
3066+
"method": "add",
3067+
"owner": "owner",
3068+
"repo": "repo",
3069+
"issue_number": float64(42),
3070+
"sub_issue_id": float64(123),
3071+
},
3072+
expectSuccess: true,
3073+
},
3074+
{
3075+
name: "exhausted retries with priority conflict",
3076+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
3077+
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
3078+
// Always fail with priority conflict
3079+
w.WriteHeader(http.StatusUnprocessableEntity)
3080+
_, _ = w.Write([]byte(`{"message": "An error occurred while adding the sub-issue to the parent issue. Priority has already been taken"}`))
3081+
},
3082+
}),
3083+
requestArgs: map[string]interface{}{
3084+
"method": "add",
3085+
"owner": "owner",
3086+
"repo": "repo",
3087+
"issue_number": float64(42),
3088+
"sub_issue_id": float64(123),
3089+
},
3090+
expectSuccess: false,
3091+
expectedErrMsg: "Priority has already been taken",
3092+
},
3093+
{
3094+
name: "non-retryable 422 error - sub-issue cannot be parent of itself",
3095+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
3096+
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: func(w http.ResponseWriter, _ *http.Request) {
3097+
// Non-retryable 422 error
3098+
w.WriteHeader(http.StatusUnprocessableEntity)
3099+
_, _ = w.Write([]byte(`{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`))
3100+
},
3101+
}),
3102+
requestArgs: map[string]interface{}{
3103+
"method": "add",
3104+
"owner": "owner",
3105+
"repo": "repo",
3106+
"issue_number": float64(42),
3107+
"sub_issue_id": float64(42),
3108+
},
3109+
expectSuccess: false,
3110+
expectedErrMsg: "failed to add sub-issue",
3111+
},
3112+
{
3113+
name: "first attempt succeeds - no retry needed",
3114+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
3115+
PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue),
3116+
}),
3117+
requestArgs: map[string]interface{}{
3118+
"method": "add",
3119+
"owner": "owner",
3120+
"repo": "repo",
3121+
"issue_number": float64(42),
3122+
"sub_issue_id": float64(123),
3123+
},
3124+
expectSuccess: true,
3125+
},
3126+
}
3127+
3128+
serverTool := SubIssueWrite(translations.NullTranslationHelper)
3129+
3130+
for _, tc := range tests {
3131+
t.Run(tc.name, func(t *testing.T) {
3132+
// Setup client with mock
3133+
client := github.NewClient(tc.mockedClient)
3134+
deps := BaseDeps{
3135+
Client: client,
3136+
}
3137+
handler := serverTool.Handler(deps)
3138+
3139+
// Create call request
3140+
request := createMCPRequest(tc.requestArgs)
3141+
3142+
// Call handler
3143+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
3144+
3145+
if tc.expectSuccess {
3146+
require.NoError(t, err)
3147+
require.NotNil(t, result)
3148+
3149+
// Parse and verify the result
3150+
textContent := getTextResult(t, result)
3151+
var returnedIssue github.Issue
3152+
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
3153+
require.NoError(t, err)
3154+
assert.Equal(t, *mockIssue.Number, *returnedIssue.Number)
3155+
assert.Equal(t, *mockIssue.Title, *returnedIssue.Title)
3156+
} else {
3157+
// Expect error in result content
3158+
require.NotNil(t, result)
3159+
textContent := getTextResult(t, result)
3160+
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
3161+
}
3162+
})
3163+
}
3164+
}
3165+
30233166
func Test_GetSubIssues(t *testing.T) {
30243167
// Verify tool definition once
30253168
serverTool := IssueRead(translations.NullTranslationHelper)

0 commit comments

Comments
 (0)