From d83a381231a84af8d1766eb1e4e1f3de3de6107c Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 14:02:27 +0100 Subject: [PATCH 1/5] Retry downloading the malware database 3 times --- packages/safe-chain/src/api/aikido.js | 97 +++++++++++++------ .../src/scanning/malwareDatabase.js | 14 +-- 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 5c04360..26c88ea 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -1,5 +1,9 @@ import fetch from "make-fetch-happen"; -import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { + getEcoSystem, + ECOSYSTEM_JS, + ECOSYSTEM_PY, +} from "../config/settings.js"; const malwareDatabaseUrls = { [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", @@ -17,38 +21,77 @@ const malwareDatabaseUrls = { * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} */ export async function fetchMalwareDatabase() { - const ecosystem = getEcoSystem(); - const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)]; - const response = await fetch(malwareDatabaseUrl); - if (!response.ok) { - throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`); - } + return retry(async () => { + const ecosystem = getEcoSystem(); + const malwareDatabaseUrl = + malwareDatabaseUrls[ + /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) + ]; + const response = await fetch(malwareDatabaseUrl); + if (!response.ok) { + throw new Error( + `Error fetching ${ecosystem} malware database: ${response.statusText}` + ); + } - try { - let malwareDatabase = await response.json(); - return { - malwareDatabase: malwareDatabase, - version: response.headers.get("etag") || undefined, - }; - } catch (/** @type {any} */ error) { - throw new Error(`Error parsing malware database: ${error.message}`); - } + try { + let malwareDatabase = await response.json(); + return { + malwareDatabase: malwareDatabase, + version: response.headers.get("etag") || undefined, + }; + } catch (/** @type {any} */ error) { + throw new Error(`Error parsing malware database: ${error.message}`); + } + }, 3); } /** * @returns {Promise} */ export async function fetchMalwareDatabaseVersion() { - const ecosystem = getEcoSystem(); - const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)]; - const response = await fetch(malwareDatabaseUrl, { - method: "HEAD", - }); + return retry(async () => { + const ecosystem = getEcoSystem(); + const malwareDatabaseUrl = + malwareDatabaseUrls[ + /** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) + ]; + const response = await fetch(malwareDatabaseUrl, { + method: "HEAD", + }); - if (!response.ok) { - throw new Error( - `Error fetching ${ecosystem} malware database version: ${response.statusText}` - ); - } - return response.headers.get("etag") || undefined; + if (!response.ok) { + throw new Error( + `Error fetching ${ecosystem} malware database version: ${response.statusText}` + ); + } + return response.headers.get("etag") || undefined; + }, 3); +} + +/** + * Retries an asynchronous function multiple times until it succeeds or exhausts all attempts. + * + * @template T + * @param {() => Promise} func - The asynchronous function to retry + * @param {number} times - The number of retry attempts (will execute times + 1 total attempts) + * @returns {Promise} The return value of the function if successful + * @throws {Error} The last error encountered if all retry attempts fail + */ +async function retry(func, times) { + let lastError; + + for (let i = 0; i <= times; i++) { + try { + return await func(); + } catch (error) { + lastError = error; + } + + if (i < times) { + await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 500)); + } + } + + throw lastError; } diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 4aba43c..120c438 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -48,13 +48,13 @@ export async function openMalwareDatabase() { */ function getPackageStatus(name, version) { const normalizedName = normalizePackageName(name); - const packageData = malwareDatabase.find( - (pkg) => { - const normalizedPkgName = normalizePackageName(pkg.package_name); - return normalizedPkgName === normalizedName && - (pkg.version === version || pkg.version === "*"); - } - ); + const packageData = malwareDatabase.find((pkg) => { + const normalizedPkgName = normalizePackageName(pkg.package_name); + return ( + normalizedPkgName === normalizedName && + (pkg.version === version || pkg.version === "*") + ); + }); if (!packageData) { return MALWARE_STATUS_OK; From 8d2655a4bf1b59d61c34d06d28b0b4f1992a0f48 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 14:41:06 +0100 Subject: [PATCH 2/5] Add tests for malware db retry --- packages/safe-chain/src/api/aikido.spec.js | 125 +++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 packages/safe-chain/src/api/aikido.spec.js diff --git a/packages/safe-chain/src/api/aikido.spec.js b/packages/safe-chain/src/api/aikido.spec.js new file mode 100644 index 0000000..2191d42 --- /dev/null +++ b/packages/safe-chain/src/api/aikido.spec.js @@ -0,0 +1,125 @@ +import { describe, it, mock, beforeEach } from "node:test"; +import assert from "node:assert"; + +describe("aikido API", async () => { + const mockFetch = mock.fn(); + + mock.module("make-fetch-happen", { + defaultExport: mockFetch, + }); + + mock.module("../config/settings.js", { + namedExports: { + getEcoSystem: () => "js", + ECOSYSTEM_JS: "js", + ECOSYSTEM_PY: "py", + }, + }); + + const { fetchMalwareDatabase, fetchMalwareDatabaseVersion } = + await import("./aikido.js"); + + beforeEach(() => { + mockFetch.mock.resetCalls(); + }); + + describe("fetchMalwareDatabase", () => { + it("should succeed immediately when fetch succeeds on first try", async () => { + const malwareData = [ + { package_name: "malicious-pkg", version: "1.0.0", reason: "test" }, + ]; + mockFetch.mock.mockImplementationOnce(() => ({ + ok: true, + json: async () => malwareData, + headers: { get: () => '"etag-123"' }, + })); + + const result = await fetchMalwareDatabase(); + + assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.deepStrictEqual(result.malwareDatabase, malwareData); + assert.strictEqual(result.version, '"etag-123"'); + }); + + it("should throw error after exhausting all retries", async () => { + mockFetch.mock.mockImplementation(() => { + throw new Error("Network error"); + }); + + await assert.rejects(() => fetchMalwareDatabase(), { + message: "Network error", + }); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + }); + + it("should succeed after failing 3 times and succeeding on 4th attempt", async () => { + const malwareData = [ + { package_name: "bad-pkg", version: "2.0.0", reason: "malware" }, + ]; + let callCount = 0; + mockFetch.mock.mockImplementation(() => { + callCount++; + if (callCount < 4) { + throw new Error("Network error"); + } + return { + ok: true, + json: async () => malwareData, + headers: { get: () => '"etag-456"' }, + }; + }); + + const result = await fetchMalwareDatabase(); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + assert.deepStrictEqual(result.malwareDatabase, malwareData); + assert.strictEqual(result.version, '"etag-456"'); + }); + }); + + describe("fetchMalwareDatabaseVersion", () => { + it("should succeed immediately when fetch succeeds on first try", async () => { + mockFetch.mock.mockImplementationOnce(() => ({ + ok: true, + headers: { get: () => '"version-etag"' }, + })); + + const result = await fetchMalwareDatabaseVersion(); + + assert.strictEqual(mockFetch.mock.calls.length, 1); + assert.strictEqual(result, '"version-etag"'); + }); + + it("should throw error after exhausting all retries", async () => { + mockFetch.mock.mockImplementation(() => { + throw new Error("Connection refused"); + }); + + await assert.rejects(() => fetchMalwareDatabaseVersion(), { + message: "Connection refused", + }); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + }); + + it("should succeed after failing 3 times and succeeding on 4th attempt", async () => { + let callCount = 0; + mockFetch.mock.mockImplementation(() => { + callCount++; + if (callCount < 4) { + throw new Error("Timeout"); + } + return { + ok: true, + headers: { get: () => '"final-etag"' }, + }; + }); + + const result = await fetchMalwareDatabaseVersion(); + + assert.strictEqual(mockFetch.mock.calls.length, 4); + assert.strictEqual(result, '"final-etag"'); + }); + }); +}); From a5d545f29b2ad95e47c0029b038b7bdfb6a01ac3 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 14:55:11 +0100 Subject: [PATCH 3/5] Handle pr comments --- packages/safe-chain/src/api/aikido.js | 22 ++++++++++++++----- .../src/scanning/malwareDatabase.js | 14 ++++++------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 26c88ea..88dffb2 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -21,6 +21,8 @@ const malwareDatabaseUrls = { * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} */ export async function fetchMalwareDatabase() { + const numberOfAttempts = 4; + return retry(async () => { const ecosystem = getEcoSystem(); const malwareDatabaseUrl = @@ -43,13 +45,15 @@ export async function fetchMalwareDatabase() { } catch (/** @type {any} */ error) { throw new Error(`Error parsing malware database: ${error.message}`); } - }, 3); + }, numberOfAttempts); } /** * @returns {Promise} */ export async function fetchMalwareDatabaseVersion() { + const numberOfAttempts = 4; + return retry(async () => { const ecosystem = getEcoSystem(); const malwareDatabaseUrl = @@ -66,7 +70,7 @@ export async function fetchMalwareDatabaseVersion() { ); } return response.headers.get("etag") || undefined; - }, 3); + }, numberOfAttempts); } /** @@ -74,21 +78,27 @@ export async function fetchMalwareDatabaseVersion() { * * @template T * @param {() => Promise} func - The asynchronous function to retry - * @param {number} times - The number of retry attempts (will execute times + 1 total attempts) + * @param {number} attempts - The number of attempts * @returns {Promise} The return value of the function if successful * @throws {Error} The last error encountered if all retry attempts fail */ -async function retry(func, times) { +async function retry(func, attempts) { let lastError; - for (let i = 0; i <= times; i++) { + for (let i = 0; i < attempts; i++) { try { return await func(); } catch (error) { lastError = error; } - if (i < times) { + if (i < attempts - 1) { + // When this is not the last try, back-off expenentially: + // 1st attempt - 500ms delay + // 2nd attempt - 1000ms delay + // 3rd attempt - 2000ms delay + // 4th attempt - 4000ms delay + // ... await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 500)); } } diff --git a/packages/safe-chain/src/scanning/malwareDatabase.js b/packages/safe-chain/src/scanning/malwareDatabase.js index 120c438..4aba43c 100644 --- a/packages/safe-chain/src/scanning/malwareDatabase.js +++ b/packages/safe-chain/src/scanning/malwareDatabase.js @@ -48,13 +48,13 @@ export async function openMalwareDatabase() { */ function getPackageStatus(name, version) { const normalizedName = normalizePackageName(name); - const packageData = malwareDatabase.find((pkg) => { - const normalizedPkgName = normalizePackageName(pkg.package_name); - return ( - normalizedPkgName === normalizedName && - (pkg.version === version || pkg.version === "*") - ); - }); + const packageData = malwareDatabase.find( + (pkg) => { + const normalizedPkgName = normalizePackageName(pkg.package_name); + return normalizedPkgName === normalizedName && + (pkg.version === version || pkg.version === "*"); + } + ); if (!packageData) { return MALWARE_STATUS_OK; From 6f4eaf5234447948397e6e021e009aa51ea7370b Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 14 Jan 2026 15:31:37 +0100 Subject: [PATCH 4/5] Don't swallow error on retry --- packages/safe-chain/src/api/aikido.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index 88dffb2..be01518 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -4,6 +4,7 @@ import { ECOSYSTEM_JS, ECOSYSTEM_PY, } from "../config/settings.js"; +import { ui } from "../environment/userInteraction.js"; const malwareDatabaseUrls = { [ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", @@ -89,6 +90,10 @@ async function retry(func, attempts) { try { return await func(); } catch (error) { + ui.writeVerbose( + "An error occurred while trying to download the Aikido Malware database", + error + ); lastError = error; } From 9d55afbf857a29f402c7e4234cb651a361124b5e Mon Sep 17 00:00:00 2001 From: bitterpanda Date: Wed, 14 Jan 2026 15:33:09 +0100 Subject: [PATCH 5/5] Update packages/safe-chain/src/api/aikido.js --- packages/safe-chain/src/api/aikido.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/safe-chain/src/api/aikido.js b/packages/safe-chain/src/api/aikido.js index be01518..abb2135 100644 --- a/packages/safe-chain/src/api/aikido.js +++ b/packages/safe-chain/src/api/aikido.js @@ -98,7 +98,7 @@ async function retry(func, attempts) { } if (i < attempts - 1) { - // When this is not the last try, back-off expenentially: + // When this is not the last try, back-off exponentially: // 1st attempt - 500ms delay // 2nd attempt - 1000ms delay // 3rd attempt - 2000ms delay