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
12 changes: 11 additions & 1 deletion classes/integrity.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "@eik/common/lib/schemas/assert.js";
import ValidationError from "@eik/common/lib/schemas/validation-error.js";
import typeSlug from "@eik/common/lib/helpers/type-slug.js";
import { joinUrlPathname } from "../utils/url.js";
import { fetchWithRetry } from "../utils/http/retry.js";

/**
* @typedef {object} IntegrityOptions
Expand All @@ -13,6 +14,8 @@ import { joinUrlPathname } from "../utils/url.js";
* @property {string} version
* @property {string} [cwd]
* @property {boolean} [debug]
* @property {number} [retries=2] - Number of retry attempts on transient 5xx/network errors (0 to disable). Default 2 retries = 3 total attempts
* @property {number} [retryDelay=500] - Base delay in ms between retries; doubles each attempt (500 ms, 1000 ms, …)
*/

export default class Integrity {
Expand All @@ -28,6 +31,8 @@ export default class Integrity {
type,
debug = false,
cwd = process.cwd(),
retries,
retryDelay,
}) {
this.log = abslog(logger);
this.server = server;
Expand All @@ -36,6 +41,8 @@ export default class Integrity {
this.debug = debug;
this.cwd = cwd;
this.type = type;
this.retries = retries;
this.retryDelay = retryDelay;
}

async run() {
Expand Down Expand Up @@ -80,7 +87,10 @@ export default class Integrity {
);
this.log.debug(` ==> url: ${url}`);

const res = await fetch(url);
const res = await fetchWithRetry(() => fetch(url), {
maxRetries: this.retries !== undefined ? this.retries + 1 : undefined,
baseDelayMs: this.retryDelay,
});

if (res.ok) {
this.log.debug(` ==> ok: true`);
Expand Down
13 changes: 12 additions & 1 deletion classes/publish/package/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import Cleanup from "./tasks/cleanup.js";
* @property {string[]} [map]
* @property {string} [out="./.eik"]
* @property {Record<string, string>} files
* @property {number} [retries=2] - Number of retry attempts on transient 5xx/network errors (0 to disable). Default 2 retries = 3 total attempts
* @property {number} [retryDelay=500] - Base delay in ms between retries; doubles each attempt (500 ms, 1000 ms, …)
*/

/**
Expand Down Expand Up @@ -59,6 +61,8 @@ export default class Publish {
map = [],
out = "./.eik",
files = {},
retries,
retryDelay,
}) {
const config = new EikConfig(
{
Expand Down Expand Up @@ -106,8 +110,15 @@ export default class Publish {
logger: this.log,
path: this.path,
config,
retries,
retryDelay,
});
this.uploadFiles = new UploadFiles({
logger: this.log,
config,
retries,
retryDelay,
});
this.uploadFiles = new UploadFiles({ logger: this.log, config });
this.saveMetafile = new SaveMetafile({ logger: this.log, cwd, config });
this.cleanup = new Cleanup({
logger: this.log,
Expand Down
7 changes: 6 additions & 1 deletion classes/publish/package/tasks/check-if-already-published.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ export default class CheckIfAlreadyPublished extends Task {
log.debug(" ==> Fetching package metadata from server");

try {
if (await integrity(server, typeSlug(type), name, version)) {
if (
await integrity(server, typeSlug(type), name, version, {
retries: this.retries,
retryDelay: this.retryDelay,
})
) {
throw new Error(
`${name} version ${version} already exists on the Eik server. Publishing is not necessary.`,
);
Expand Down
2 changes: 2 additions & 0 deletions classes/publish/package/tasks/task.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ export default class Task {
this.log = abslog(options.logger);
this.path = options.path;
this.config = options.config;
this.retries = options.retries;
this.retryDelay = options.retryDelay;
}
}
2 changes: 2 additions & 0 deletions classes/publish/package/tasks/upload-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export default class UploadFiles extends Task {
pathname,
file: zipFile,
token,
retries: this.retries,
retryDelay: this.retryDelay,
});

return message;
Expand Down
11 changes: 10 additions & 1 deletion classes/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import hashCompare from "../utils/hash/compare.js";
* @property {string} [out="./.eik"]
* @property {string | Record<string, string>} files
* @property {string} [configFile="eik.json"]
* @property {number} [retries=2] - Number of retry attempts on transient 5xx/network errors (0 to disable). Default 2 retries = 3 total attempts
* @property {number} [retryDelay=500] - Base delay in ms between retries; doubles each attempt (500 ms, 1000 ms, …)
*/

export default class Version {
Expand All @@ -41,6 +43,8 @@ export default class Version {
out = "./.eik",
files,
configFile = "eik.json",
retries,
retryDelay,
}) {
const config = new EikConfig(
{
Expand All @@ -61,6 +65,8 @@ export default class Version {
this.configFile = configFile;
this.path = isAbsolute(config.out) ? config.out : join(cwd, config.out);
this.level = level;
this.retries = retries;
this.retryDelay = retryDelay;
}

/**
Expand Down Expand Up @@ -99,7 +105,10 @@ export default class Version {

let integrityHash;
try {
integrityHash = await integrity(server, typeSlug(type), name, version);
integrityHash = await integrity(server, typeSlug(type), name, version, {
retries: this.retries,
retryDelay: this.retryDelay,
});
} catch (err) {
throw new Error(
`Unable to fetch package metadata from server: ${/** @type {any} */ (err).message}`,
Expand Down
8 changes: 7 additions & 1 deletion commands/integrity.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const builder = (yargs) => {
alias: "s",
describe: "Eik server address, if different from configuration file",
},
retries: {
describe:
"Number of retry attempts on transient server errors. Each retry waits longer than the last (500 ms, 1000 ms, …). Default: 2 retries (3 total attempts). Set to 0 to disable.",
type: "number",
},
})
.example("eik integrity")
.example("eik integrity --server https://assets.myserver.com");
Expand All @@ -25,7 +30,7 @@ export const builder = (yargs) => {
export const handler = commandHandler(
{ command, options: ["server"] },
async (argv, log, spinner) => {
const { name, version, server, out, type, cwd, debug } = argv;
const { name, version, server, out, type, cwd, debug, retries } = argv;

const integrity = await new Integrity({
logger: log,
Expand All @@ -35,6 +40,7 @@ export const handler = commandHandler(
debug,
cwd,
type,
retries,
}).run();

if (integrity) {
Expand Down
7 changes: 7 additions & 0 deletions commands/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const builder = (yargs) => {
type: "string",
alias: "t",
},
retries: {
describe:
"Number of retry attempts on transient server errors. Each retry waits longer than the last (500 ms, 1000 ms, …). Default: 2 retries (3 total attempts). Set to 0 to disable.",
type: "number",
},
})
.example("eik publish")
.example("eik publish --dry-run")
Expand All @@ -49,6 +54,7 @@ export const handler = commandHandler(
files,
type,
configFile,
retries,
} = argv;

if (type === "map") {
Expand All @@ -71,6 +77,7 @@ export const handler = commandHandler(
map,
out,
files,
retries,
};

const publish = await new PublishPackage(options).run();
Expand Down
7 changes: 7 additions & 0 deletions commands/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ export const builder = (yargs) => {
describe: "Log details about the operation and skip upload",
type: "boolean",
},
retries: {
describe:
"Number of retry attempts on transient server errors. Each retry waits longer than the last (500 ms, 1000 ms, …). Default: 2 retries (3 total attempts). Set to 0 to disable.",
type: "number",
},
})
.example("eik version")
.example("eik version minor")
Expand All @@ -46,6 +51,7 @@ export const handler = commandHandler(
type,
files,
configFile,
retries,
} = argv;

const options = {
Expand All @@ -60,6 +66,7 @@ export const handler = commandHandler(
out,
files,
configFile,
retries,
};

const newVersion = await new VersionPackage(options).run();
Expand Down
143 changes: 143 additions & 0 deletions test/http-retry.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Tests that verify retry behaviour of HTTP utilities against transient 5xx errors.
*
* integrity.js — tested against a real Eik server: the first call returns a mocked
* 503, the retry goes to the real server and gets a real integrity response.
*
* request.js — tested with mocked fetch only: the upload format (tar archive,
* multipart content-type) makes real-server testing complex and adds no value
* for verifying the retry mechanism itself.
*/
import fastify from "fastify";
import { promises as fs } from "fs";
import os from "os";
import { join, basename } from "path";
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "url";
import { dirname } from "path";
import EikService from "@eik/service";
import Sink from "@eik/sink-memory";
import cli from "../classes/index.js";
import integrity from "../utils/http/integrity.js";
import request from "../utils/http/request.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

describe("integrity — retry on transient 5xx", () => {
let server;
let address;
let token;
let cwd;

beforeEach(async () => {
const memSink = new Sink();
server = fastify({ logger: false, forceCloseConnections: true });
const service = new EikService({ customSink: memSink });
server.register(service.api());
address = await server.listen({ host: "127.0.0.1", port: 0 });
token = await cli.login({ server: address, key: "change_me" });
cwd = await fs.mkdtemp(join(os.tmpdir(), basename(__filename)));

await cli.publish({
cwd,
server: address,
name: "my-app",
token,
version: "1.0.0",
files: { "index.js": join(__dirname, "./fixtures/client.js") },
});
});

afterEach(async () => {
await server.close();
await fs.rm(cwd, { recursive: true, force: true });
});

test("retries on 503 and returns the integrity from the real server", async (t) => {
const originalFetch = globalThis.fetch;
let call = 0;
t.mock.method(globalThis, "fetch", async (url, opts) => {
call++;
if (call === 1)
return new Response("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
});
return originalFetch(url, opts);
});

const result = await integrity(address, "pkg", "my-app", "1.0.0");
assert.ok(result, "should return an integrity hash");
assert.ok(
result.startsWith("sha512-"),
"integrity should be a sha512 hash",
);
assert.equal(call, 2, "should have retried once after the 503");
});

test("does not retry on 404 — returns null immediately", async (t) => {
const originalFetch = globalThis.fetch;
let call = 0;
t.mock.method(globalThis, "fetch", async (url, opts) => {
call++;
return originalFetch(url, opts);
});

const result = await integrity(address, "pkg", "does-not-exist", "1.0.0");
assert.equal(result, null, "should return null for a missing package");
assert.equal(call, 1, "should not retry on 404");
});
});

describe("request — retry on transient 5xx (mock-based)", () => {
test("retries on 503 and returns the result from the successful attempt", async (t) => {
let call = 0;
t.mock.method(globalThis, "fetch", async () => {
call++;
if (call === 1)
return new Response("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
});
return new Response(JSON.stringify({ message: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
});
});

const result = await request({
host: "http://eik.example",
pathname: "/pkg/my-app/1.0.0",
method: "PUT",
});
assert.equal(result.status, 200);
assert.equal(call, 2, "should have retried once after the 503");
});

test("does not retry on 4xx client errors", async (t) => {
let call = 0;
t.mock.method(globalThis, "fetch", async () => {
call++;
return new Response("Bad Request", {
status: 400,
statusText: "Bad Request",
});
});

await assert.rejects(
() =>
request({
host: "http://eik.example",
pathname: "/pkg/my-app/1.0.0",
method: "PUT",
}),
(err) => {
assert.ok(/** @type {any} */ (err).message.includes("400"));
return true;
},
);
assert.equal(call, 1, "should not retry on 4xx");
});
});
Loading