import crypto from "node:crypto"; import http from "node:http"; import {afterEach, describe, expect, it, vi} from "vitest"; import {FetchError, sleep} from "@lodestar/utils"; import {JsonRpcHttpClient} from "../../../../src/execution/engine/jsonRpcHttpClient.js"; import {RpcPayload} from "../../../../src/execution/engine/utils.js"; import {getGoerliRpcUrl} from "../../../testParams.js"; describe("execution / engine / jsonRpcHttpClient", () => { vi.setConfig({testTimeout: 10_000}); const port = 36421; const noMethodError = {code: -32601, message: "Method not found"}; const notInSpecError = "JSON RPC Error not in spec"; const randomHex = crypto.randomBytes(32).toString("hex"); const testCases: { id: string; url?: string; payload?: RpcPayload; requestListener?: http.RequestListener; abort?: true; timeout?: number; error: any; errorCode?: string; }[] = [ // // NOTE: This DNS query is very expensive, all cache miss. So it can timeout the tests and cause false positives // { // id: "Bad domain", // url: `https://${randomHex}.com`, // error: "getaddrinfo ENOTFOUND", // }, { id: "Bad subdomain", // Use random bytes to ensure no collisions url: `https://${randomHex}.infura.io`, error: "", errorCode: "ENOTFOUND", }, { id: "Bad port", url: `http://localhost:${port + 1}`, requestListener: (_req, res) => res.end(), error: "", errorCode: "ECONNREFUSED", }, { id: "Not a JSON RPC endpoint", requestListener: (_req, res) => { res.setHeader("Content-Type", "text/html"); res.end(""); }, error: "Error parsing JSON", }, { id: "Endpoint returns HTTP error", requestListener: (_req, res) => { res.statusCode = 404; res.end(); }, error: "Not Found", }, { id: "RPC payload with error", requestListener: (_req, res) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({jsonrpc: "2.0", id: 83, error: noMethodError})); }, error: noMethodError.message, }, { id: "RPC payload with non-spec error: error has no message", requestListener: (_req, res) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({jsonrpc: "2.0", id: 83, error: {code: noMethodError.code}})); }, error: noMethodError.message, }, { id: "RPC payload with non-spec error: error is a string", requestListener: (_req, res) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({jsonrpc: "2.0", id: 83, error: notInSpecError})); }, error: notInSpecError, }, { id: "RPC payload with no result", requestListener: (_req, res) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({jsonrpc: "2.0", id: 83})); }, error: "no result", }, { id: "Aborted request", abort: true, requestListener: () => { // leave the request open until aborted }, error: "Aborted request", }, { id: "Timeout request", timeout: 1, requestListener: () => { // leave the request open until timeout }, error: "Timeout request", }, ]; const afterHooks: (() => Promise)[] = []; afterEach(async () => { while (afterHooks.length) { const afterHook = afterHooks.pop(); if (afterHook) await afterHook(); } }); for (const testCase of testCases) { const {id, requestListener, abort, timeout} = testCase; let {url, payload} = testCase; it(id, async () => { if (requestListener) { if (!url) url = `http://localhost:${port}`; const server = http.createServer(requestListener); await new Promise((resolve) => server.listen(port, resolve)); afterHooks.push( () => new Promise((resolve, reject) => server.close((err) => { if (err) reject(err); else resolve(); }) ) ); } if (!url) url = getGoerliRpcUrl(); if (!payload) payload = {method: "no-method", params: []}; const controller = new AbortController(); if (abort) setTimeout(() => controller.abort(), 50); const jsonRpcClient = new JsonRpcHttpClient([url], {signal: controller.signal}); try { await jsonRpcClient.fetch(payload, {timeout}); } catch (error) { if (testCase.errorCode) { expect((error as FetchError).code).toBe(testCase.errorCode); } else { expect((error as Error).message).toEqual(expect.stringContaining(testCase.error)); } } expect.assertions(1); }); } }); describe("execution / engine / jsonRpcHttpClient - with retries", () => { vi.setConfig({testTimeout: 10_000}); const port = 36421; const noMethodError = {code: -32601, message: "Method not found"}; const afterHooks: (() => Promise)[] = []; afterEach(async () => { while (afterHooks.length) { const afterHook = afterHooks.pop(); if (afterHook) await afterHook().catch((e: Error) => { console.error("Error in afterEach hook", e); }); } }); it("should retry ENOTFOUND", async () => { let retryCount = 0; const url = "https://goerli.fake-website.io"; const payload = {method: "get", params: []}; const retries = 2; const controller = new AbortController(); const jsonRpcClient = new JsonRpcHttpClient([url], {signal: controller.signal}); await expect( jsonRpcClient.fetchWithRetries(payload, { retries, shouldRetry: () => { // using the shouldRetry function to keep tab of the retried requests retryCount++; return true; }, }) ).rejects.toThrow("getaddrinfo ENOTFOUND"); expect(retryCount).toBeWithMessage(retries, "ENOTFOUND should be retried before failing"); }); it("should retry ECONNREFUSED", async () => { let retryCount = 0; const url = `http://localhost:${port + 1}`; const payload = {method: "get", params: []}; const retries = 2; const controller = new AbortController(); const jsonRpcClient = new JsonRpcHttpClient([url], {signal: controller.signal}); await expect( jsonRpcClient.fetchWithRetries(payload, { retries, shouldRetry: () => { // using the shouldRetry function to keep tab of the retried requests retryCount++; return true; }, }) ).rejects.toThrow(expect.objectContaining({code: "ECONNREFUSED"})); expect(retryCount).toBeWithMessage(retries, "code ECONNREFUSED should be retried before failing"); }); it("should retry 404", async () => { let requestCount = 0; const server = http.createServer((_req, res) => { requestCount++; res.statusCode = 404; res.end(); }); await new Promise((resolve) => server.listen(port, resolve)); afterHooks.push( () => new Promise((resolve, reject) => server.close((err) => { if (err) reject(err); else resolve(); }) ) ); const url = `http://localhost:${port}`; const payload = {method: "get", params: []}; const retries = 2; const controller = new AbortController(); const jsonRpcClient = new JsonRpcHttpClient([url], {signal: controller.signal}); await expect(jsonRpcClient.fetchWithRetries(payload, {retries})).rejects.toThrow("Not Found"); expect(requestCount).toBeWithMessage(retries + 1, "404 responses should be retried before failing"); }); it("should retry timeout", async () => { let requestCount = 0; const server = http.createServer(async () => { requestCount++; }); await new Promise((resolve) => server.listen(port, resolve)); afterHooks.push( () => new Promise((resolve, reject) => server.close((err) => { if (err) reject(err); else resolve(); }) ) ); // it's observed that immediate request after the server started end up ECONNRESET await sleep(100); const url = `http://localhost:${port}`; const payload = {method: "get", params: []}; const retries = 2; const timeout = 200; const controller = new AbortController(); const jsonRpcClient = new JsonRpcHttpClient([url], {signal: controller.signal}); await expect(jsonRpcClient.fetchWithRetries(payload, {retries, timeout})).rejects.toThrow("Timeout request"); expect(requestCount).toBeWithMessage(retries + 1, "Timeout request should be retried before failing"); }); it("should not retry aborted", async () => { let requestCount = 0; const server = http.createServer(() => { requestCount++; // leave the request open until timeout }); await new Promise((resolve) => server.listen(port, resolve)); afterHooks.push( () => new Promise((resolve, reject) => server.close((err) => { if (err) reject(err); else resolve(); }) ) ); const url = `http://localhost:${port}`; const payload = {method: "get", params: []}; const retries = 2; const timeout = 200; const controller = new AbortController(); setTimeout(() => controller.abort(), 50); const jsonRpcClient = new JsonRpcHttpClient([url], {signal: controller.signal}); await expect(jsonRpcClient.fetchWithRetries(payload, {retries, timeout})).rejects.toThrow("Aborted"); expect(requestCount).toBeWithMessage(1, "Aborted request should not be retried"); }); it("should not retry payload error", async () => { let requestCount = 0; const server = http.createServer((_req, res) => { requestCount++; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({jsonrpc: "2.0", id: 83, error: noMethodError})); }); await new Promise((resolve) => server.listen(port, resolve)); afterHooks.push( () => new Promise((resolve, reject) => server.close((err) => { if (err) reject(err); else resolve(); }) ) ); const url = `http://localhost:${port}`; const payload = {method: "get", params: []}; const retries = 2; const controller = new AbortController(); const jsonRpcClient = new JsonRpcHttpClient([url], {signal: controller.signal}); await expect(jsonRpcClient.fetchWithRetries(payload, {retries})).rejects.toThrow("Method not found"); expect(requestCount).toBeWithMessage(1, "Payload error (non-network error) should not be retried"); }); });