Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/brave-clouds-retry.md
Original file line number Diff line number Diff line change
@@ -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
40 changes: 39 additions & 1 deletion packages/context/src/git.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
88 changes: 67 additions & 21 deletions packages/context/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}

/**
Expand Down
Loading