diff --git a/packages/api/package.json b/packages/api/package.json index 8975a4cef42a..527d84a5d108 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -75,6 +75,7 @@ "@lodestar/params": "^1.10.0", "@lodestar/types": "^1.10.0", "@lodestar/utils": "^1.10.0", + "cross-fetch": "^3.1.8", "eventsource": "^2.0.2", "qs": "^6.11.1" }, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a0436611798e..5a8760f590a2 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -10,9 +10,6 @@ export { HttpError, ApiError, Metrics, - FetchError, - isFetchError, - fetch, } from "./utils/client/index.js"; export * from "./utils/routes.js"; diff --git a/packages/api/src/utils/client/fetch.ts b/packages/api/src/utils/client/fetch.ts deleted file mode 100644 index 9e98c61a91da..000000000000 --- a/packages/api/src/utils/client/fetch.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Native fetch with transparent and consistent error handling - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/fetch) - */ -async function wrappedFetch(url: string | URL, init?: RequestInit): Promise { - try { - return await fetch(url, init); - } catch (e) { - throw new FetchError(url, e); - } -} - -export {wrappedFetch as fetch}; - -export function isFetchError(e: unknown): e is FetchError { - return e instanceof FetchError; -} - -export type FetchErrorType = "failed" | "input" | "aborted" | "unknown"; - -type FetchErrorCause = NativeFetchFailedError["cause"] | NativeFetchInputError["cause"]; - -export class FetchError extends Error { - type: FetchErrorType; - code: string; - cause?: FetchErrorCause; - - constructor(url: string | URL, e: unknown) { - if (isNativeFetchFailedError(e)) { - super(`Request to ${url.toString()} failed, reason: ${e.cause.message}`); - this.type = "failed"; - this.code = e.cause.code || "ERR_FETCH_FAILED"; - this.cause = e.cause; - } else if (isNativeFetchInputError(e)) { - // For input errors the e.message is more detailed - super(e.message); - this.type = "input"; - this.code = e.cause.code || "ERR_INVALID_INPUT"; - this.cause = e.cause; - } else if (isNativeFetchAbortError(e)) { - super(`Request to ${url.toString()} was aborted`); - this.type = "aborted"; - this.code = "ERR_ABORTED"; - } else { - super((e as Error).message); - this.type = "unknown"; - this.code = "ERR_UNKNOWN"; - } - this.name = this.constructor.name; - } -} - -type NativeFetchError = TypeError & { - cause: Error & { - code?: string; - }; -}; - -/** - * ``` - * TypeError: fetch failed - * cause: Error: connect ECONNREFUSED 127.0.0.1:9596 - * errno: -111, - * code: 'ECONNREFUSED', - * syscall: 'connect', - * address: '127.0.0.1', - * port: 9596 - * --------------------------- - * TypeError: fetch failed - * cause: Error: getaddrinfo ENOTFOUND non-existent-domain - * errno: -3008, - * code: 'ENOTFOUND', - * syscall: 'getaddrinfo', - * hostname: 'non-existent-domain' - * --------------------------- - * TypeError: fetch failed - * cause: SocketError: other side closed - * code: 'UND_ERR_SOCKET', - * socket: {} - * --------------------------- - * TypeError: fetch failed - * cause: Error: unknown scheme - * [cause]: undefined - * ``` - */ -type NativeFetchFailedError = NativeFetchError & { - message: "fetch failed"; - cause: { - errno?: string; - syscall?: string; - address?: string; - port?: string; - hostname?: string; - socket?: object; - [prop: string]: unknown; - }; -}; - -/** - * ``` - * TypeError: Failed to parse URL from invalid-url - * [cause]: TypeError [ERR_INVALID_URL]: Invalid URL - * input: 'invalid-url', - * code: 'ERR_INVALID_URL' - * ``` - */ -type NativeFetchInputError = NativeFetchError & { - cause: { - input: unknown; - }; -}; - -/** - * ``` - * DOMException [AbortError]: This operation was aborted - * ``` - */ -type NativeFetchAbortError = DOMException & { - name: "AbortError"; -}; - -function isNativeFetchError(e: unknown): e is NativeFetchError { - return e instanceof TypeError && (e as NativeFetchError).cause instanceof Error; -} - -function isNativeFetchFailedError(e: unknown): e is NativeFetchFailedError { - return isNativeFetchError(e) && (e as NativeFetchFailedError).message === "fetch failed"; -} - -function isNativeFetchInputError(e: unknown): e is NativeFetchInputError { - return isNativeFetchError(e) && (e as NativeFetchInputError).cause.input !== undefined; -} - -function isNativeFetchAbortError(e: unknown): e is NativeFetchAbortError { - return e instanceof DOMException && e.name === "AbortError"; -} diff --git a/packages/api/src/utils/client/httpClient.ts b/packages/api/src/utils/client/httpClient.ts index 6015d9b6715f..084edaae2243 100644 --- a/packages/api/src/utils/client/httpClient.ts +++ b/packages/api/src/utils/client/httpClient.ts @@ -1,7 +1,7 @@ +import {fetch} from "cross-fetch"; import {ErrorAborted, Logger, TimeoutError} from "@lodestar/utils"; import {ReqGeneric, RouteDef} from "../index.js"; import {ApiClientResponse, ApiClientSuccessResponse} from "../../interfaces.js"; -import {fetch, isFetchError} from "./fetch.js"; import {stringifyQuery, urlJoin} from "./format.js"; import {Metrics} from "./metrics.js"; import {HttpStatusCode} from "./httpStatusCode.js"; @@ -261,7 +261,7 @@ export class HttpClient implements IHttpClient { const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? timeoutMs ?? this.globalTimeoutMs); // Attach global signal to this request's controller - const onGlobalSignalAbort = (): void => controller.abort(); + const onGlobalSignalAbort = controller.abort.bind(controller); const signalGlobal = this.getAbortSignal?.(); signalGlobal?.addEventListener("abort", onGlobalSignalAbort); @@ -323,7 +323,7 @@ export class HttpClient implements IHttpClient { } function isAbortedError(e: Error): boolean { - return isFetchError(e) && e.type === "aborted"; + return e.name === "AbortError" || e.message === "The user aborted a request"; } function getErrorMessage(errBody: string): string { diff --git a/packages/api/src/utils/client/index.ts b/packages/api/src/utils/client/index.ts index 7198c22ab89b..6c0adec01c6b 100644 --- a/packages/api/src/utils/client/index.ts +++ b/packages/api/src/utils/client/index.ts @@ -1,3 +1,2 @@ export * from "./client.js"; export * from "./httpClient.js"; -export * from "./fetch.js"; diff --git a/packages/api/test/unit/client/fetch.test.ts b/packages/api/test/unit/client/fetch.test.ts deleted file mode 100644 index bab1c57b2ab0..000000000000 --- a/packages/api/test/unit/client/fetch.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import crypto from "node:crypto"; -import http from "node:http"; -import {expect} from "chai"; -import {FetchError, FetchErrorType, fetch} from "../../../src/utils/client/fetch.js"; - -describe("FetchError", function () { - const port = 37421; - const randomHex = crypto.randomBytes(32).toString("hex"); - - const testCases: { - id: string; - url?: string; - requestListener?: http.RequestListener; - abort?: true; - timeout?: number; - errorType: FetchErrorType; - errorCode: string; - expectCause: boolean; - }[] = [ - { - id: "Bad domain", - // Use random bytes to ensure no collisions - url: `https://${randomHex}.infura.io`, - errorType: "failed", - errorCode: "ENOTFOUND", - expectCause: true, - }, - { - id: "Bad port", - url: `http://localhost:${port + 1}`, - requestListener: (_req, res) => res.end(), - errorType: "failed", - errorCode: "ECONNREFUSED", - expectCause: true, - }, - { - id: "Socket error", - requestListener: (_req, res) => res.socket?.destroy(), - errorType: "failed", - errorCode: "UND_ERR_SOCKET", - expectCause: true, - }, - { - id: "Headers overflow", - requestListener: (_req, res) => { - res.setHeader("Large-Header", "a".repeat(1e6)); - res.end(); - }, - errorType: "failed", - errorCode: "UND_ERR_HEADERS_OVERFLOW", - expectCause: true, - }, - { - id: "Unknown scheme", - url: `httsp://localhost:${port}`, - errorType: "failed", - errorCode: "ERR_FETCH_FAILED", - expectCause: true, - }, - { - id: "Invalid URL", - url: "invalid-url", - errorType: "input", - errorCode: "ERR_INVALID_URL", - expectCause: true, - }, - { - id: "Aborted request", - abort: true, - requestListener: () => { - // leave the request open until aborted - }, - errorType: "aborted", - errorCode: "ERR_ABORTED", - expectCause: false, - }, - ]; - - const afterHooks: (() => Promise)[] = []; - - afterEach(async function () { - while (afterHooks.length) { - const afterHook = afterHooks.pop(); - if (afterHook) - await afterHook().catch((e: Error) => { - // eslint-disable-next-line no-console - console.error("Error in afterEach hook", e); - }); - } - }); - - for (const testCase of testCases) { - const {id, url = `http://localhost:${port}`, requestListener, abort} = testCase; - - it(id, async function () { - if (requestListener) { - 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(); - }) - ) - ); - } - - const controller = new AbortController(); - if (abort) setTimeout(() => controller.abort(), 20); - await expect(fetch(url, {signal: controller.signal})).to.be.rejected.then((error: FetchError) => { - expect(error.type).to.be.equal(testCase.errorType); - expect(error.code).to.be.equal(testCase.errorCode); - if (testCase.expectCause) { - expect(error.cause).to.be.instanceof(Error); - } - }); - }); - } -}); diff --git a/packages/api/test/utils/fetchOpenApiSpec.ts b/packages/api/test/utils/fetchOpenApiSpec.ts index a4d7060fe55a..8db5bd809ec3 100644 --- a/packages/api/test/utils/fetchOpenApiSpec.ts +++ b/packages/api/test/utils/fetchOpenApiSpec.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import {fetch} from "@lodestar/api"; +import fetch from "cross-fetch"; import {OpenApiFile, OpenApiJson} from "./parseOpenApiSpec.js"; /* eslint-disable no-console */ diff --git a/packages/beacon-node/package.json b/packages/beacon-node/package.json index da184d384ee8..f5bf0b42d2c2 100644 --- a/packages/beacon-node/package.json +++ b/packages/beacon-node/package.json @@ -137,6 +137,7 @@ "@types/datastore-level": "^3.0.0", "buffer-xor": "^2.0.2", "c-kzg": "^2.1.0", + "cross-fetch": "^3.1.8", "datastore-core": "^9.1.1", "datastore-level": "^10.1.1", "deepmerge": "^4.3.1", diff --git a/packages/beacon-node/src/eth1/provider/jsonRpcHttpClient.ts b/packages/beacon-node/src/eth1/provider/jsonRpcHttpClient.ts index 694848c14cf9..999147236875 100644 --- a/packages/beacon-node/src/eth1/provider/jsonRpcHttpClient.ts +++ b/packages/beacon-node/src/eth1/provider/jsonRpcHttpClient.ts @@ -1,4 +1,7 @@ -import {fetch} from "@lodestar/api"; +// Uses cross-fetch for browser + NodeJS cross compatibility +// Note: isomorphic-fetch is not well mantained and does not support abort signals +import fetch from "cross-fetch"; + import {ErrorAborted, TimeoutError, retry} from "@lodestar/utils"; import {IGauge, IHistogram} from "../../metrics/interface.js"; import {IJson, RpcPayload} from "../interface.js"; @@ -9,6 +12,16 @@ import {encodeJwtToken} from "./jwt.js"; const maxStringLengthToPrint = 500; const REQUEST_TIMEOUT = 30 * 1000; +// As we are using `cross-fetch` which does not support for types for errors +// We can't use `node-fetch` for browser compatibility +export type FetchError = { + errno: string; + code: string; +}; + +export const isFetchError = (error: unknown): error is FetchError => + (error as FetchError) !== undefined && "code" in (error as FetchError) && "errno" in (error as FetchError); + interface RpcResponse extends RpcResponseError { result?: R; } @@ -188,8 +201,13 @@ export class JsonRpcHttpClient implements IJsonRpcHttpClient { * Fetches JSON and throws detailed errors in case the HTTP request is not ok */ private async fetchJsonOneUrl(url: string, json: T, opts?: ReqOpts): Promise { + // If url is undefined node-fetch throws with `TypeError: Only absolute URLs are supported` + // Throw a better error instead if (!url) throw Error(`Empty or undefined JSON RPC HTTP client url: ${url}`); + // fetch() throws for network errors: + // - request to http://missing-url.com/ failed, reason: getaddrinfo ENOTFOUND missing-url.com + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), opts?.timeout ?? this.opts?.timeout ?? REQUEST_TIMEOUT); diff --git a/packages/beacon-node/src/execution/engine/utils.ts b/packages/beacon-node/src/execution/engine/utils.ts index 13ad8d855062..94e1d9a373a6 100644 --- a/packages/beacon-node/src/execution/engine/utils.ts +++ b/packages/beacon-node/src/execution/engine/utils.ts @@ -1,7 +1,11 @@ -import {isFetchError} from "@lodestar/api"; import {isErrorAborted} from "@lodestar/utils"; import {IJson, RpcPayload} from "../../eth1/interface.js"; -import {IJsonRpcHttpClient, ErrorJsonRpcResponse, HttpRpcError} from "../../eth1/provider/jsonRpcHttpClient.js"; +import { + IJsonRpcHttpClient, + ErrorJsonRpcResponse, + HttpRpcError, + isFetchError, +} from "../../eth1/provider/jsonRpcHttpClient.js"; import {isQueueErrorAborted} from "../../util/queue/errors.js"; import {ExecutionPayloadStatus, ExecutionEngineState} from "./interface.js"; diff --git a/packages/beacon-node/src/monitoring/service.ts b/packages/beacon-node/src/monitoring/service.ts index a6142aeac957..7f8f740e46f6 100644 --- a/packages/beacon-node/src/monitoring/service.ts +++ b/packages/beacon-node/src/monitoring/service.ts @@ -1,5 +1,5 @@ +import fetch from "cross-fetch"; import {Registry} from "prom-client"; -import {fetch} from "@lodestar/api"; import {ErrorAborted, Logger, TimeoutError} from "@lodestar/utils"; import {RegistryMetricCreator} from "../metrics/index.js"; import {HistogramExtra} from "../metrics/utils/histogram.js"; diff --git a/packages/beacon-node/test/e2e/api/impl/config.test.ts b/packages/beacon-node/test/e2e/api/impl/config.test.ts index e4fa5b29211e..3d269733747c 100644 --- a/packages/beacon-node/test/e2e/api/impl/config.test.ts +++ b/packages/beacon-node/test/e2e/api/impl/config.test.ts @@ -1,4 +1,4 @@ -import {fetch} from "@lodestar/api"; +import fetch from "cross-fetch"; import {ForkName, activePreset} from "@lodestar/params"; import {chainConfig} from "@lodestar/config/default"; import {ethereumConsensusSpecsTests} from "../../../spec/specTestVersioning.js"; diff --git a/packages/beacon-node/test/e2e/eth1/jsonRpcHttpClient.test.ts b/packages/beacon-node/test/e2e/eth1/jsonRpcHttpClient.test.ts index 7e68dc899fe6..6de1cebf6fc8 100644 --- a/packages/beacon-node/test/e2e/eth1/jsonRpcHttpClient.test.ts +++ b/packages/beacon-node/test/e2e/eth1/jsonRpcHttpClient.test.ts @@ -2,11 +2,14 @@ import "mocha"; import crypto from "node:crypto"; import http from "node:http"; import {expect} from "chai"; -import {FetchError} from "@lodestar/api"; import {JsonRpcHttpClient} from "../../../src/eth1/provider/jsonRpcHttpClient.js"; import {getGoerliRpcUrl} from "../../testParams.js"; import {RpcPayload} from "../../../src/eth1/interface.js"; +type FetchError = { + code: string; +}; + describe("eth1 / jsonRpcHttpClient", function () { this.timeout("10 seconds"); @@ -35,8 +38,7 @@ describe("eth1 / jsonRpcHttpClient", function () { id: "Bad subdomain", // Use random bytes to ensure no collisions url: `https://${randomHex}.infura.io`, - error: "", - errorCode: "ENOTFOUND", + error: "getaddrinfo ENOTFOUND", }, { id: "Bad port", diff --git a/packages/beacon-node/test/unit/metrics/server/http.test.ts b/packages/beacon-node/test/unit/metrics/server/http.test.ts index b147a283a960..330b099de76c 100644 --- a/packages/beacon-node/test/unit/metrics/server/http.test.ts +++ b/packages/beacon-node/test/unit/metrics/server/http.test.ts @@ -1,4 +1,4 @@ -import {fetch} from "@lodestar/api"; +import fetch from "cross-fetch"; import {getHttpMetricsServer, HttpMetricsServer} from "../../../../src/metrics/index.js"; import {testLogger} from "../../../utils/logger.js"; import {createMetricsTest} from "../utils.js"; diff --git a/packages/beacon-node/test/unit/monitoring/service.test.ts b/packages/beacon-node/test/unit/monitoring/service.test.ts index 53ec4df355e8..921d71a1b2d0 100644 --- a/packages/beacon-node/test/unit/monitoring/service.test.ts +++ b/packages/beacon-node/test/unit/monitoring/service.test.ts @@ -195,7 +195,7 @@ describe("monitoring / service", () => { await service.send(); - assertError({message: `Request to ${endpoint} failed, reason: connect ECONNREFUSED ${new URL(endpoint).host}`}); + assertError({message: `request to ${endpoint} failed, reason: connect ECONNREFUSED ${new URL(endpoint).host}`}); }); it("should abort pending requests if timeout is reached", async () => { diff --git a/packages/light-client/package.json b/packages/light-client/package.json index 8f8b2bb8f3aa..04e2fc9be907 100644 --- a/packages/light-client/package.json +++ b/packages/light-client/package.json @@ -73,6 +73,7 @@ "@lodestar/state-transition": "^1.10.0", "@lodestar/types": "^1.10.0", "@lodestar/utils": "^1.10.0", + "cross-fetch": "^3.1.8", "mitt": "^3.0.0", "strict-event-emitter-types": "^2.0.0" }, diff --git a/packages/validator/package.json b/packages/validator/package.json index 25fefd5626b7..d312505fcf71 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -58,6 +58,7 @@ "@lodestar/types": "^1.10.0", "@lodestar/utils": "^1.10.0", "bigint-buffer": "^1.1.5", + "cross-fetch": "^3.1.8", "strict-event-emitter-types": "^2.0.0" }, "devDependencies": { diff --git a/packages/validator/src/util/externalSignerClient.ts b/packages/validator/src/util/externalSignerClient.ts index 2716533e536f..4bcf66383b05 100644 --- a/packages/validator/src/util/externalSignerClient.ts +++ b/packages/validator/src/util/externalSignerClient.ts @@ -1,5 +1,5 @@ +import fetch from "cross-fetch"; import {ContainerType, toHexString, ValueOf} from "@chainsafe/ssz"; -import {fetch} from "@lodestar/api"; import {phase0, altair, capella} from "@lodestar/types"; import {ForkSeq} from "@lodestar/params"; import {ValidatorRegistrationV1} from "@lodestar/types/bellatrix"; diff --git a/yarn.lock b/yarn.lock index 54b2e155840a..cb636ce61a97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6141,7 +6141,7 @@ create-require@^1.1.0: resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cross-fetch@^3.1.4: +cross-fetch@^3.1.4, cross-fetch@^3.1.8: version "3.1.8" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==