diff --git a/packages/api/src/utils/client/httpClient.ts b/packages/api/src/utils/client/httpClient.ts index 62af8469f859..c671dea4a658 100644 --- a/packages/api/src/utils/client/httpClient.ts +++ b/packages/api/src/utils/client/httpClient.ts @@ -1,4 +1,4 @@ -import {ErrorAborted, Logger, TimeoutError, enhanceFetchErrors} from "@lodestar/utils"; +import {ErrorAborted, Logger, TimeoutError, fetch, isFetchAbortError} from "@lodestar/utils"; import {ReqGeneric, RouteDef} from "../index.js"; import {ApiClientResponse, ApiClientSuccessResponse} from "../../interfaces.js"; import {stringifyQuery, urlJoin} from "./format.js"; @@ -301,7 +301,7 @@ export class HttpClient implements IHttpClient { } catch (e) { this.metrics?.requestErrors.inc({routeId}); - if (isAbortedError(e as Error)) { + if (isFetchAbortError(e)) { if (signalGlobal?.aborted) { throw new ErrorAborted("REST client"); } else if (controller.signal.aborted) { @@ -310,7 +310,6 @@ export class HttpClient implements IHttpClient { throw Error("Unknown aborted error"); } } else { - enhanceFetchErrors(e); throw e; } } finally { @@ -322,10 +321,6 @@ export class HttpClient implements IHttpClient { } } -function isAbortedError(e: Error): boolean { - return e.name === "AbortError"; -} - function getErrorMessage(errBody: string): string { try { const errJson = JSON.parse(errBody) as {message: string}; diff --git a/packages/api/test/utils/fetchOpenApiSpec.ts b/packages/api/test/utils/fetchOpenApiSpec.ts index f40570789ef1..6672849a2806 100644 --- a/packages/api/test/utils/fetchOpenApiSpec.ts +++ b/packages/api/test/utils/fetchOpenApiSpec.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import {fetch} from "@lodestar/utils"; import {OpenApiFile, OpenApiJson} from "./parseOpenApiSpec.js"; /* eslint-disable no-console */ diff --git a/packages/beacon-node/src/eth1/provider/jsonRpcHttpClient.ts b/packages/beacon-node/src/eth1/provider/jsonRpcHttpClient.ts index f06a0b92a5bd..4371d3b7e3ec 100644 --- a/packages/beacon-node/src/eth1/provider/jsonRpcHttpClient.ts +++ b/packages/beacon-node/src/eth1/provider/jsonRpcHttpClient.ts @@ -1,4 +1,4 @@ -import {ErrorAborted, TimeoutError, enhanceFetchErrors, retry} from "@lodestar/utils"; +import {ErrorAborted, TimeoutError, retry, fetch} from "@lodestar/utils"; import {IGauge, IHistogram} from "../../metrics/interface.js"; import {IJson, RpcPayload} from "../interface.js"; import {encodeJwtToken} from "./jwt.js"; @@ -187,13 +187,8 @@ 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 fetch throws with `TypeError: Failed to parse URL from undefined` - // Throw a better error instead if (!url) throw Error(`Empty or undefined JSON RPC HTTP client url: ${url}`); - // fetch() throws for network errors: - // - cause: Error: getaddrinfo ENOTFOUND missing-url.com - const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), opts?.timeout ?? this.opts?.timeout ?? REQUEST_TIMEOUT); @@ -250,7 +245,6 @@ export class JsonRpcHttpClient implements IJsonRpcHttpClient { throw new TimeoutError("request"); } } else { - enhanceFetchErrors(e); throw e; } } finally { diff --git a/packages/beacon-node/src/execution/engine/utils.ts b/packages/beacon-node/src/execution/engine/utils.ts index dc6e9a779ce6..f722e6300f45 100644 --- a/packages/beacon-node/src/execution/engine/utils.ts +++ b/packages/beacon-node/src/execution/engine/utils.ts @@ -55,11 +55,11 @@ export function getExecutionEngineState({ return ExecutionEngineState.OFFLINE; } - if (payloadError && isFetchError(payloadError) && fatalErrorCodes.includes(payloadError.cause.code)) { + if (payloadError && isFetchError(payloadError) && fatalErrorCodes.includes(payloadError.code)) { return ExecutionEngineState.OFFLINE; } - if (payloadError && isFetchError(payloadError) && connectionErrorCodes.includes(payloadError.cause.code)) { + if (payloadError && isFetchError(payloadError) && connectionErrorCodes.includes(payloadError.code)) { return ExecutionEngineState.AUTH_FAILED; } diff --git a/packages/beacon-node/src/monitoring/service.ts b/packages/beacon-node/src/monitoring/service.ts index 4bb5cc1b9b78..501efe54e199 100644 --- a/packages/beacon-node/src/monitoring/service.ts +++ b/packages/beacon-node/src/monitoring/service.ts @@ -1,5 +1,5 @@ import {Registry} from "prom-client"; -import {ErrorAborted, Logger, TimeoutError, enhanceFetchErrors} from "@lodestar/utils"; +import {ErrorAborted, Logger, TimeoutError, fetch} from "@lodestar/utils"; import {RegistryMetricCreator} from "../metrics/index.js"; import {HistogramExtra} from "../metrics/utils/histogram.js"; import {defaultMonitoringOptions, MonitoringOptions} from "./options.js"; @@ -180,7 +180,6 @@ export class MonitoringService { if (!signal.aborted) { // error was thrown by fetch - enhanceFetchErrors(e); throw e; } 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 c764be7271e6..f1bc62ca0ada 100644 --- a/packages/beacon-node/test/e2e/api/impl/config.test.ts +++ b/packages/beacon-node/test/e2e/api/impl/config.test.ts @@ -1,3 +1,4 @@ +import {fetch} from "@lodestar/utils"; 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 9ab0068c06a0..2524931e9a14 100644 --- a/packages/beacon-node/test/e2e/eth1/jsonRpcHttpClient.test.ts +++ b/packages/beacon-node/test/e2e/eth1/jsonRpcHttpClient.test.ts @@ -2,6 +2,7 @@ import "mocha"; import crypto from "node:crypto"; import http from "node:http"; import {expect} from "chai"; +import {FetchError} from "@lodestar/utils"; import {JsonRpcHttpClient} from "../../../src/eth1/provider/jsonRpcHttpClient.js"; import {getGoerliRpcUrl} from "../../testParams.js"; import {RpcPayload} from "../../../src/eth1/interface.js"; @@ -22,6 +23,7 @@ describe("eth1 / jsonRpcHttpClient", function () { 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 // { @@ -33,13 +35,15 @@ describe("eth1 / jsonRpcHttpClient", function () { id: "Bad subdomain", // Use random bytes to ensure no collisions url: `https://${randomHex}.infura.io`, - error: "getaddrinfo ENOTFOUND", + error: "", + errorCode: "ENOTFOUND", }, { id: "Bad port", url: `http://localhost:${port + 1}`, requestListener: (req, res) => res.end(), - error: "connect ECONNREFUSED", + error: "", + errorCode: "ECONNREFUSED", }, { id: "Not a JSON RPC endpoint", @@ -122,7 +126,6 @@ describe("eth1 / jsonRpcHttpClient", function () { for (const testCase of testCases) { const {id, requestListener, abort, timeout} = testCase; - const error = testCase.error as Error; let {url, payload} = testCase; it(id, async function () { @@ -148,7 +151,13 @@ describe("eth1 / jsonRpcHttpClient", function () { const controller = new AbortController(); if (abort) setTimeout(() => controller.abort(), 50); const eth1JsonRpcClient = new JsonRpcHttpClient([url], {signal: controller.signal}); - await expect(eth1JsonRpcClient.fetch(payload, {timeout})).to.be.rejectedWith(error); + await expect(eth1JsonRpcClient.fetch(payload, {timeout})).to.be.rejected.then((error) => { + if (testCase.errorCode) { + expect((error as FetchError).code).to.be.equal(testCase.errorCode); + } else { + expect((error as Error).message).to.include(testCase.error); + } + }); }); } }); @@ -210,8 +219,10 @@ describe("eth1 / jsonRpcHttpClient - with retries", function () { return true; }, }) - ).to.be.rejectedWith("connect ECONNREFUSED"); - expect(retryCount).to.be.equal(retryAttempts, "connect ECONNREFUSED should be retried before failing"); + ).to.be.rejected.then((error) => { + expect((error as FetchError).code).to.be.equal("ECONNREFUSED"); + }); + expect(retryCount).to.be.equal(retryAttempts, "code ECONNREFUSED should be retried before failing"); }); it("should retry 404", async function () { 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 b3d8519f18ff..7b651bec7751 100644 --- a/packages/beacon-node/test/unit/metrics/server/http.test.ts +++ b/packages/beacon-node/test/unit/metrics/server/http.test.ts @@ -1,3 +1,4 @@ +import {fetch} from "@lodestar/utils"; 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 076a08cf8832..53ec4df355e8 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: `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/utils/src/fetch.ts b/packages/utils/src/fetch.ts index 26dd74b33bb4..d05fabd2e634 100644 --- a/packages/utils/src/fetch.ts +++ b/packages/utils/src/fetch.ts @@ -1,22 +1,128 @@ -export type FetchError = Error & { +/** + * Wrapper around native fetch to improve error handling + */ +async function wrappedFetch(url: string | URL, init?: RequestInit): Promise { + try { + return await fetch(url, init); + } catch (e) { + throw new FetchError(e, url); + } +} + +export {wrappedFetch as fetch}; + +export function isFetchError(e: unknown): e is FetchError { + return e instanceof FetchError; +} + +export function isFetchAbortError(e: unknown): e is FetchError { + return e instanceof FetchError && e.type === "aborted"; +} + +export type FetchErrorType = "system" | "input" | "aborted" | "unknown"; + +export type FetchErrorCause = NativeFetchSystemError["cause"] | NativeFetchInputError["cause"]; + +export class FetchError extends Error { + type: FetchErrorType; + code: string; + cause?: FetchErrorCause; + + constructor(e: unknown, url: string | URL) { + if (isNativeSystemFetchError(e)) { + super(`Request to ${url.toString()} failed, reason: ${e.cause.message}`); + this.type = "system"; + this.code = e.cause.code; + this.cause = e.cause; + } else if (isNativeFetchInputError(e)) { + super(e.message); + this.type = "input"; + this.code = e.cause.code; + 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; + } +} + +/** + * ``` + * 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' + * ``` + */ +type NativeFetchSystemError = Error & { cause: Error & { errno: string; code: string; + syscall: string; + address?: string; + port?: string; + hostname?: string; }; }; -export function isFetchError(error: unknown): error is FetchError { +/** + * ``` + * TypeError: Failed to parse URL from invalid-url + * [cause]: TypeError [ERR_INVALID_URL]: Invalid URL + * input: 'invalid-url', + * code: 'ERR_INVALID_URL' + * ``` + */ +type NativeFetchInputError = Error & { + cause: Error & { + input: unknown; + code: string; + }; +}; + +/** + * ``` + * DOMException [AbortError]: This operation was aborted + * ``` + */ +type NativeFetchAbortError = DOMException & { + name: "AbortError"; +}; + +function isNativeSystemFetchError(e: unknown): e is NativeFetchSystemError { return ( - error instanceof Error && - (error as FetchError).cause instanceof Error && - (error as FetchError).cause.errno !== undefined && - (error as FetchError).cause.code !== undefined + e instanceof Error && + (e as NativeFetchSystemError).cause instanceof Error && + (e as NativeFetchSystemError).cause.code !== undefined && + (e as NativeFetchSystemError).cause.syscall !== undefined ); } -export function enhanceFetchErrors(error: unknown): void { - if (isFetchError(error)) { - // Override message with cause.message to get more detailed errors - error.message = error.cause.message; - } +function isNativeFetchInputError(e: unknown): e is NativeFetchInputError { + return ( + e instanceof Error && + (e as NativeFetchInputError).cause instanceof Error && + (e as NativeFetchInputError).cause.code !== undefined && + (e as NativeFetchInputError).cause.input !== undefined + ); +} + +function isNativeFetchAbortError(e: unknown): e is NativeFetchAbortError { + return e instanceof DOMException && e.name === "AbortError"; } diff --git a/packages/validator/src/util/externalSignerClient.ts b/packages/validator/src/util/externalSignerClient.ts index 040f2e93a8c5..27a397ceb2d4 100644 --- a/packages/validator/src/util/externalSignerClient.ts +++ b/packages/validator/src/util/externalSignerClient.ts @@ -1,6 +1,7 @@ import {ContainerType, toHexString, ValueOf} from "@chainsafe/ssz"; import {phase0, altair, capella} from "@lodestar/types"; import {ForkSeq} from "@lodestar/params"; +import {fetch} from "@lodestar/utils"; import {ValidatorRegistrationV1} from "@lodestar/types/bellatrix"; import {BeaconConfig} from "@lodestar/config"; import {computeEpochAtSlot, blindedOrFullBlockToHeader} from "@lodestar/state-transition";