diff --git a/.changeset/brave-clouds-retry.md b/.changeset/brave-clouds-retry.md new file mode 100644 index 0000000..ff3a51d --- /dev/null +++ b/.changeset/brave-clouds-retry.md @@ -0,0 +1,5 @@ +--- +"@neuledge/context": patch +--- + +Retry git clones on transient network failures (connection timeouts, DNS errors, 5xx) with exponential backoff, so a single network hiccup no longer fails package builds diff --git a/packages/context/src/git.test.ts b/packages/context/src/git.test.ts index afb0f96..59baeda 100644 --- a/packages/context/src/git.test.ts +++ b/packages/context/src/git.test.ts @@ -1,5 +1,43 @@ import { describe, expect, it } from "vitest"; -import { findLatestStableVersion, parseMonorepoTag } from "./git.js"; +import { + findLatestStableVersion, + isTransientGitError, + parseMonorepoTag, +} from "./git.js"; + +describe("isTransientGitError", () => { + it("detects connection failures", () => { + expect( + isTransientGitError( + "fatal: unable to access 'https://github.com/vuejs/docs/': Failed to connect to github.com port 443 after 135337 ms: Couldn't connect to server", + ), + ).toBe(true); + }); + + it("detects DNS failures", () => { + expect( + isTransientGitError( + "fatal: unable to access 'https://github.com/x/y/': Could not resolve host: github.com", + ), + ).toBe(true); + }); + + it("detects server errors", () => { + expect( + isTransientGitError("error: The requested URL returned error: 503"), + ).toBe(true); + }); + + it("rejects permanent errors", () => { + expect( + isTransientGitError("fatal: Remote branch v99.0.0 not found in upstream"), + ).toBe(false); + expect(isTransientGitError("remote: Repository not found.")).toBe(false); + expect( + isTransientGitError("fatal: Authentication failed for 'https://...'"), + ).toBe(false); + }); +}); describe("parseMonorepoTag", () => { it("parses plain version tags", () => { diff --git a/packages/context/src/git.ts b/packages/context/src/git.ts index db19327..edc2f5c 100755 --- a/packages/context/src/git.ts +++ b/packages/context/src/git.ts @@ -207,34 +207,80 @@ function extractGitError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * Git error patterns that indicate a transient network failure worth + * retrying, as opposed to permanent errors (missing ref, repo not found, + * auth failure) where retrying just wastes time. + */ +const TRANSIENT_GIT_ERROR_PATTERNS = [ + /could ?not resolve host/i, + /failed to connect/i, + /connection (timed out|reset|refused)/i, + /operation timed out/i, + /gnutls recv error/i, + /early eof/i, + /remote end hung up unexpectedly/i, + /rpc failed/i, + /returned error: (429|5\d\d)/i, +]; + +/** + * Check if a git error message looks like a transient network failure. + */ +export function isTransientGitError(message: string): boolean { + return TRANSIENT_GIT_ERROR_PATTERNS.some((re) => re.test(message)); +} + +/** + * Synchronous sleep (cloneRepository is sync, so no await available). + */ +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +const CLONE_ATTEMPTS = 3; + /** * Clone a git repository to a temporary directory. + * Retries transient network failures with exponential backoff (2s, 4s). */ export function cloneRepository(url: string, ref?: string): GitCloneResult { - const tempDir = mkdtempSync(join(tmpdir(), "context-git-")); + for (let attempt = 1; ; attempt++) { + const tempDir = mkdtempSync(join(tmpdir(), "context-git-")); - try { - // Clone with depth 1 for efficiency (shallow clone) - const cloneArgs = ["clone", "--depth", "1"]; - if (ref) { - cloneArgs.push("--branch", ref); - } - cloneArgs.push(url, tempDir); + try { + // Clone with depth 1 for efficiency (shallow clone) + const cloneArgs = ["clone", "--depth", "1"]; + if (ref) { + cloneArgs.push("--branch", ref); + } + cloneArgs.push(url, tempDir); - execSync(`git ${cloneArgs.join(" ")}`, { - stdio: ["pipe", "pipe", "pipe"], - encoding: "utf-8", - }); - } catch (error) { - // Clean up on failure - rmSync(tempDir, { recursive: true, force: true }); - throw new Error(`Git clone failed: ${extractGitError(error)}`); - } + execSync(`git ${cloneArgs.join(" ")}`, { + stdio: ["pipe", "pipe", "pipe"], + encoding: "utf-8", + }); - return { - tempDir, - cleanup: () => rmSync(tempDir, { recursive: true, force: true }), - }; + return { + tempDir, + cleanup: () => rmSync(tempDir, { recursive: true, force: true }), + }; + } catch (error) { + // Clean up on failure + rmSync(tempDir, { recursive: true, force: true }); + + const message = extractGitError(error); + if (attempt >= CLONE_ATTEMPTS || !isTransientGitError(message)) { + throw new Error(`Git clone failed: ${message}`); + } + + const delayMs = 2 ** attempt * 1000; + console.error( + `Clone of ${url} failed (attempt ${attempt}/${CLONE_ATTEMPTS}), retrying in ${delayMs / 1000}s...`, + ); + sleepSync(delayMs); + } + } } /**