From 3c739a8cf3a36907c339cf2574cb56bd2ee923d6 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Thu, 3 Jul 2025 15:56:19 -0700 Subject: [PATCH] Add retry logic for intermittent 403 errors in MCP file operations (#232) - Extract retry logic to shared utility in src/utils/retry.ts - Update token.ts to use shared retry utility - Add retry with exponential backoff to git reference updates - Only retry on 403 errors, fail immediately on other errors - Use shorter delays (1-5s) for transient GitHub API failures This handles intermittent 403 'Resource not accessible by integration' errors transparently without requiring workflow permission changes. These errors appear to be transient GitHub API issues that succeed on retry. --- src/github/token.ts | 42 +--------- src/mcp/github-file-ops-server.ts | 125 +++++++++++++++++++++--------- src/utils/retry.ts | 40 ++++++++++ 3 files changed, 128 insertions(+), 79 deletions(-) create mode 100644 src/utils/retry.ts diff --git a/src/github/token.ts b/src/github/token.ts index 13863eb..234070c 100644 --- a/src/github/token.ts +++ b/src/github/token.ts @@ -1,47 +1,7 @@ #!/usr/bin/env bun import * as core from "@actions/core"; - -type RetryOptions = { - maxAttempts?: number; - initialDelayMs?: number; - maxDelayMs?: number; - backoffFactor?: number; -}; - -async function retryWithBackoff( - operation: () => Promise, - options: RetryOptions = {}, -): Promise { - const { - maxAttempts = 3, - initialDelayMs = 5000, - maxDelayMs = 20000, - backoffFactor = 2, - } = options; - - let delayMs = initialDelayMs; - let lastError: Error | undefined; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - console.log(`Attempt ${attempt} of ${maxAttempts}...`); - return await operation(); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - console.error(`Attempt ${attempt} failed:`, lastError.message); - - if (attempt < maxAttempts) { - console.log(`Retrying in ${delayMs / 1000} seconds...`); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - delayMs = Math.min(delayMs * backoffFactor, maxDelayMs); - } - } - } - - console.error(`Operation failed after ${maxAttempts} attempts`); - throw lastError; -} +import { retryWithBackoff } from "../utils/retry"; async function getOidcToken(): Promise { try { diff --git a/src/mcp/github-file-ops-server.ts b/src/mcp/github-file-ops-server.ts index ef03178..e00c887 100644 --- a/src/mcp/github-file-ops-server.ts +++ b/src/mcp/github-file-ops-server.ts @@ -9,6 +9,7 @@ import fetch from "node-fetch"; import { GITHUB_API_URL } from "../github/api/config"; import { Octokit } from "@octokit/rest"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; +import { retryWithBackoff } from "../utils/retry"; type GitHubRef = { object: { @@ -233,26 +234,50 @@ server.tool( // 6. Update the reference to point to the new commit const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`; - const updateRefResponse = await fetch(updateRefUrl, { - method: "PATCH", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${githubToken}`, - "X-GitHub-Api-Version": "2022-11-28", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - sha: newCommitData.sha, - force: false, - }), - }); - if (!updateRefResponse.ok) { - const errorText = await updateRefResponse.text(); - throw new Error( - `Failed to update reference: ${updateRefResponse.status} - ${errorText}`, - ); - } + // We're seeing intermittent 403 "Resource not accessible by integration" errors + // on certain repos when updating git references. These appear to be transient + // GitHub API issues that succeed on retry. + await retryWithBackoff( + async () => { + const updateRefResponse = await fetch(updateRefUrl, { + method: "PATCH", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${githubToken}`, + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sha: newCommitData.sha, + force: false, + }), + }); + + if (!updateRefResponse.ok) { + const errorText = await updateRefResponse.text(); + const error = new Error( + `Failed to update reference: ${updateRefResponse.status} - ${errorText}`, + ); + + // Only retry on 403 errors - these are the intermittent failures we're targeting + if (updateRefResponse.status === 403) { + console.log("Received 403 error, will retry..."); + throw error; + } + + // For non-403 errors, fail immediately without retry + console.error("Non-retryable error:", updateRefResponse.status); + throw error; + } + }, + { + maxAttempts: 3, + initialDelayMs: 1000, // Start with 1 second delay + maxDelayMs: 5000, // Max 5 seconds delay + backoffFactor: 2, // Double the delay each time + }, + ); const simplifiedResult = { commit: { @@ -427,26 +452,50 @@ server.tool( // 6. Update the reference to point to the new commit const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`; - const updateRefResponse = await fetch(updateRefUrl, { - method: "PATCH", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${githubToken}`, - "X-GitHub-Api-Version": "2022-11-28", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - sha: newCommitData.sha, - force: false, - }), - }); - if (!updateRefResponse.ok) { - const errorText = await updateRefResponse.text(); - throw new Error( - `Failed to update reference: ${updateRefResponse.status} - ${errorText}`, - ); - } + // We're seeing intermittent 403 "Resource not accessible by integration" errors + // on certain repos when updating git references. These appear to be transient + // GitHub API issues that succeed on retry. + await retryWithBackoff( + async () => { + const updateRefResponse = await fetch(updateRefUrl, { + method: "PATCH", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${githubToken}`, + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sha: newCommitData.sha, + force: false, + }), + }); + + if (!updateRefResponse.ok) { + const errorText = await updateRefResponse.text(); + const error = new Error( + `Failed to update reference: ${updateRefResponse.status} - ${errorText}`, + ); + + // Only retry on 403 errors - these are the intermittent failures we're targeting + if (updateRefResponse.status === 403) { + console.log("Received 403 error, will retry..."); + throw error; + } + + // For non-403 errors, fail immediately without retry + console.error("Non-retryable error:", updateRefResponse.status); + throw error; + } + }, + { + maxAttempts: 3, + initialDelayMs: 1000, // Start with 1 second delay + maxDelayMs: 5000, // Max 5 seconds delay + backoffFactor: 2, // Double the delay each time + }, + ); const simplifiedResult = { commit: { diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..bdcb541 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,40 @@ +export type RetryOptions = { + maxAttempts?: number; + initialDelayMs?: number; + maxDelayMs?: number; + backoffFactor?: number; +}; + +export async function retryWithBackoff( + operation: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxAttempts = 3, + initialDelayMs = 5000, + maxDelayMs = 20000, + backoffFactor = 2, + } = options; + + let delayMs = initialDelayMs; + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + console.log(`Attempt ${attempt} of ${maxAttempts}...`); + return await operation(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + console.error(`Attempt ${attempt} failed:`, lastError.message); + + if (attempt < maxAttempts) { + console.log(`Retrying in ${delayMs / 1000} seconds...`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs * backoffFactor, maxDelayMs); + } + } + } + + console.error(`Operation failed after ${maxAttempts} attempts`); + throw lastError; +}