Skip to content

Commit 379deda

Browse files
authored
feat: retry Vite+ install on transient network failures (#47)
## Summary - Wrap the Vite+ install command (`curl | bash` on \*nix, `irm` on Windows) in a retry loop — 3 attempts with 2s/4s backoff — so transient network failures like `curl: (6) Could not resolve host: viteplus.dev` no longer fail the action on the first try. - Switch to `ignoreReturnCode: true` and catch thrown exec errors too, so both non-zero exits and spawn failures are retryable. - Emit a `warning()` between attempts with the failure reason and attempt counter; final error reports "after 3 attempts". ## Test plan - [x] `vp run test` — 97 passed (4 new cases in `src/install-viteplus.test.ts` covering first-try success, retry-then-success, exhaustion, and thrown exec errors) - [x] `vp run check:fix` clean - [x] `vp run build` — `dist/index.mjs` refreshed - [ ] Observe a real CI run with the new action reference <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: adds a bounded retry/backoff loop around the installer command; main behavior change is potentially longer runtime and slightly different failure messaging when installs consistently fail. > > **Overview** > Makes `installVitePlus` resilient to transient network/process failures by retrying the Vite+ install command up to 3 times with linear backoff, treating both non-zero exit codes and thrown `exec` errors as retryable. > > Adds warnings between attempts with the failure reason and improves the final error to include the attempt count; includes new unit tests covering success, retry-then-success, retry exhaustion, and thrown `exec` errors. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6903c25. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent cebe252 commit 379deda

3 files changed

Lines changed: 174 additions & 75 deletions

File tree

dist/index.mjs

Lines changed: 62 additions & 62 deletions
Large diffs are not rendered by default.

src/install-viteplus.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, it, expect, afterEach, vi } from "vite-plus/test";
2+
import { exec } from "@actions/exec";
3+
import { warning } from "@actions/core";
4+
import { installVitePlus } from "./install-viteplus.js";
5+
import type { Inputs } from "./types.js";
6+
7+
vi.mock("@actions/core", () => ({
8+
info: vi.fn(),
9+
warning: vi.fn(),
10+
addPath: vi.fn(),
11+
}));
12+
13+
vi.mock("@actions/exec", () => ({
14+
exec: vi.fn(),
15+
}));
16+
17+
vi.mock("node:timers/promises", () => ({
18+
setTimeout: vi.fn().mockResolvedValue(undefined),
19+
}));
20+
21+
const baseInputs: Inputs = {
22+
version: "latest",
23+
nodeVersion: undefined,
24+
nodeVersionFile: undefined,
25+
workingDirectory: undefined,
26+
runInstall: [],
27+
cache: false,
28+
cacheDependencyPath: undefined,
29+
registryUrl: undefined,
30+
scope: undefined,
31+
};
32+
33+
describe("installVitePlus", () => {
34+
afterEach(() => {
35+
vi.resetAllMocks();
36+
});
37+
38+
it("should succeed on first attempt without retrying", async () => {
39+
vi.mocked(exec).mockResolvedValueOnce(0);
40+
41+
await installVitePlus(baseInputs);
42+
43+
expect(exec).toHaveBeenCalledTimes(1);
44+
expect(warning).not.toHaveBeenCalled();
45+
});
46+
47+
it("should retry on transient failure and eventually succeed", async () => {
48+
vi.mocked(exec).mockResolvedValueOnce(6).mockResolvedValueOnce(6).mockResolvedValueOnce(0);
49+
50+
await installVitePlus(baseInputs);
51+
52+
expect(exec).toHaveBeenCalledTimes(3);
53+
expect(warning).toHaveBeenCalledTimes(2);
54+
});
55+
56+
it("should throw after exhausting all retries", async () => {
57+
vi.mocked(exec).mockResolvedValue(6);
58+
59+
await expect(installVitePlus(baseInputs)).rejects.toThrow(/after 3 attempts/);
60+
expect(exec).toHaveBeenCalledTimes(3);
61+
});
62+
63+
it("should retry when exec itself throws (e.g. process spawn error)", async () => {
64+
vi.mocked(exec).mockRejectedValueOnce(new Error("spawn bash ENOENT")).mockResolvedValueOnce(0);
65+
66+
await installVitePlus(baseInputs);
67+
68+
expect(exec).toHaveBeenCalledTimes(2);
69+
expect(warning).toHaveBeenCalledTimes(1);
70+
});
71+
});

src/install-viteplus.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { info, addPath } from "@actions/core";
1+
import { info, warning, addPath } from "@actions/core";
22
import { exec } from "@actions/exec";
33
import { join } from "node:path";
4+
import { setTimeout as sleep } from "node:timers/promises";
45
import type { Inputs } from "./types.js";
56
import { DISPLAY_NAME } from "./types.js";
67
import { getVitePlusHome } from "./utils.js";
78

89
const INSTALL_URL_SH = "https://viteplus.dev/install.sh";
910
const INSTALL_URL_PS1 = "https://viteplus.dev/install.ps1";
11+
const INSTALL_MAX_ATTEMPTS = 3;
12+
const INSTALL_RETRY_DELAY_MS = 2000;
1013

1114
export async function installVitePlus(inputs: Inputs): Promise<void> {
1215
const { version } = inputs;
@@ -15,24 +18,49 @@ export async function installVitePlus(inputs: Inputs): Promise<void> {
1518

1619
// TODO: Remove VITE_PLUS_VERSION once vite-plus versions before the VP_* env var
1720
// rename (see https://github.com/voidzero-dev/vite-plus/pull/1166) are no longer supported.
18-
const env = { ...process.env, VP_VERSION: version, VITE_PLUS_VERSION: version };
19-
let exitCode: number;
21+
const env = {
22+
...process.env,
23+
VP_VERSION: version,
24+
VITE_PLUS_VERSION: version,
25+
} as { [key: string]: string };
2026

27+
let failureReason = "";
28+
for (let attempt = 1; attempt <= INSTALL_MAX_ATTEMPTS; attempt++) {
29+
try {
30+
const exitCode = await runInstallCommand(env);
31+
if (exitCode === 0) {
32+
ensureVitePlusBinInPath();
33+
return;
34+
}
35+
failureReason = `exit code ${exitCode}`;
36+
} catch (error) {
37+
failureReason = error instanceof Error ? error.message : String(error);
38+
}
39+
40+
if (attempt < INSTALL_MAX_ATTEMPTS) {
41+
const delay = INSTALL_RETRY_DELAY_MS * attempt;
42+
warning(
43+
`Failed to install ${DISPLAY_NAME} (${failureReason}). Retrying in ${delay}ms... (attempt ${attempt + 1}/${INSTALL_MAX_ATTEMPTS})`,
44+
);
45+
await sleep(delay);
46+
}
47+
}
48+
49+
throw new Error(
50+
`Failed to install ${DISPLAY_NAME} after ${INSTALL_MAX_ATTEMPTS} attempts: ${failureReason}`,
51+
);
52+
}
53+
54+
async function runInstallCommand(env: { [key: string]: string }): Promise<number> {
55+
const options = { env, ignoreReturnCode: true };
2156
if (process.platform === "win32") {
22-
exitCode = await exec(
57+
return exec(
2358
"pwsh",
2459
["-Command", `& ([scriptblock]::Create((irm ${INSTALL_URL_PS1})))`],
25-
{ env },
60+
options,
2661
);
27-
} else {
28-
exitCode = await exec("bash", ["-c", `curl -fsSL ${INSTALL_URL_SH} | bash`], { env });
29-
}
30-
31-
if (exitCode !== 0) {
32-
throw new Error(`Failed to install ${DISPLAY_NAME}. Exit code: ${exitCode}`);
3362
}
34-
35-
ensureVitePlusBinInPath();
63+
return exec("bash", ["-c", `curl -fsSL ${INSTALL_URL_SH} | bash`], options);
3664
}
3765

3866
function ensureVitePlusBinInPath(): void {

0 commit comments

Comments
 (0)