diff --git a/packages/beacon-node/src/util/asyncIterableToEvents.ts b/packages/beacon-node/src/util/asyncIterableToEvents.ts index b24f8583fb58..9108495a6f2c 100644 --- a/packages/beacon-node/src/util/asyncIterableToEvents.ts +++ b/packages/beacon-node/src/util/asyncIterableToEvents.ts @@ -1,4 +1,5 @@ import {LinkedList} from "./array.js"; +import {ThreadBoundaryError, fromThreadBoundaryError, toThreadBoundaryError} from "./error.js"; export type RequestEvent = { callArgs: T; @@ -14,7 +15,7 @@ export enum IteratorEventType { export type IteratorEvent = | {type: IteratorEventType.next; id: number; item: V} | {type: IteratorEventType.done; id: number} - | {type: IteratorEventType.error; id: number; error: unknown}; + | {type: IteratorEventType.error; id: number; error: ThreadBoundaryError}; export type AsyncIterableEventBus = { emitRequest(data: RequestEvent): void; @@ -113,7 +114,7 @@ export class AsyncIterableBridgeCaller { case IteratorEventType.error: // What if there's already an error? - req.error = data.error as Error; + req.error = fromThreadBoundaryError(data.error); // Do not expect more responses this.pending.delete(data.id); @@ -155,7 +156,7 @@ export class AsyncIterableBridgeHandler { this.events.emitResponse({ type: IteratorEventType.error, id: data.id, - error: e, + error: toThreadBoundaryError(e as Error), }); } } diff --git a/packages/beacon-node/src/util/error.ts b/packages/beacon-node/src/util/error.ts new file mode 100644 index 000000000000..8174dcb4f5c5 --- /dev/null +++ b/packages/beacon-node/src/util/error.ts @@ -0,0 +1,56 @@ +import {REQUEST_ERROR_CLASS_NAME, RESPONSE_ERROR_CLASS_NAME, RequestError, ResponseError} from "@lodestar/reqresp"; +import {FromObjectFn, LodestarError, LodestarErrorObject} from "@lodestar/utils"; + +/** + * Error that can be passed across thread boundaries + */ +export type ThreadBoundaryError = {error: null; object: LodestarErrorObject} | {error: Error; object: null}; + +/** + * Structured clone does not work with Error objects. + * For LodestarError, we want to specify the LodestarErrorObject with className so that we can deserialize later. + */ +export function toThreadBoundaryError(error: Error): ThreadBoundaryError { + if (error instanceof LodestarError) { + return {error: null, object: error.toObject()}; + } + + // note that non-clonable errors will be deserialized as a generic Error object + return {error, object: null}; +} + +/** + * Only RequestError and ResponseError pass through thread boundaries. + * If we pass more errors in the future through thread boundaries, we need to add them here. + */ +const fromObjectFnRegistry = new Map([ + [RESPONSE_ERROR_CLASS_NAME, ResponseError.fromObject], + [REQUEST_ERROR_CLASS_NAME, RequestError.fromObject], +]); + +/** + * If error is LodestarError, deserialize it from the LodestarErrorObject. + * Else use the generic Error object. + */ +export function fromThreadBoundaryError(error: ThreadBoundaryError): Error { + if (error.error) { + // this is always a generic Error object + return error.error; + } + + let clonedError: Error; + const fromObjectFn = fromObjectFnRegistry.get(error.object.className); + if (fromObjectFn) { + clonedError = fromObjectFn(error.object); + } else { + // should not happen as a LodestarError class should implement "fromObject" method and register it + // try our best to clone the error with the same stack trace + clonedError = new LodestarError( + {code: "UNKNOWN_ERROR_CLASS"}, + `Unknown error class ${error.object.className}`, + error.object.stack + ); + } + + return clonedError; +} diff --git a/packages/beacon-node/test/unit/util/error.test.ts b/packages/beacon-node/test/unit/util/error.test.ts new file mode 100644 index 000000000000..8cdfa1e7c32e --- /dev/null +++ b/packages/beacon-node/test/unit/util/error.test.ts @@ -0,0 +1,42 @@ +import v8 from "node:v8"; +import {expect} from "chai"; +import {RequestError, RequestErrorCode, RespStatus, ResponseError} from "@lodestar/reqresp"; +import {fromThreadBoundaryError, toThreadBoundaryError} from "../../../src/util/error.js"; + +function structuredClone(value: T): T { + return v8.deserialize(v8.serialize(value)) as T; +} + +describe("ThreadBoundaryError", () => { + it("should clone RequestError through thread boundary", () => { + const requestError = new RequestError({code: RequestErrorCode.TTFB_TIMEOUT}); + const threadBoundaryError = toThreadBoundaryError(requestError); + const clonedError = structuredClone(threadBoundaryError); + expect(clonedError.error).to.be.null; + if (!clonedError.object) { + // should not happen + expect.fail("clonedError.object should not be null"); + } + const clonedRequestError = fromThreadBoundaryError(clonedError); + if (!(clonedRequestError instanceof RequestError)) { + expect.fail("clonedRequestError should be instance of RequestError"); + } + expect((clonedRequestError as RequestError).toObject()).to.be.deep.equal(requestError.toObject()); + }); + + it("should clone ResponseError through thread boundary", () => { + const responseError = new ResponseError(RespStatus.SERVER_ERROR, "internal server error"); + const threadBoundaryError = toThreadBoundaryError(responseError); + const clonedError = structuredClone(threadBoundaryError); + expect(clonedError.error).to.be.null; + if (!clonedError.object) { + // should not happen + expect.fail("clonedError.object should not be null"); + } + const clonedResponseError = fromThreadBoundaryError(clonedError); + if (!(clonedResponseError instanceof ResponseError)) { + expect.fail("clonedResponseError should be instance of ResponseError"); + } + expect((clonedResponseError as ResponseError).toObject()).to.be.deep.equal(responseError.toObject()); + }); +}); diff --git a/packages/reqresp/src/index.ts b/packages/reqresp/src/index.ts index 557501ebb3ae..9a39d4fe0d5f 100644 --- a/packages/reqresp/src/index.ts +++ b/packages/reqresp/src/index.ts @@ -3,6 +3,6 @@ export {getMetrics, Metrics, MetricsRegister} from "./metrics.js"; export {Encoding as ReqRespEncoding} from "./types.js"; // Expose enums renamed export * from "./types.js"; export * from "./interface.js"; -export {ResponseErrorCode, ResponseError} from "./response/errors.js"; -export {RequestErrorCode, RequestError} from "./request/errors.js"; +export * from "./response/errors.js"; +export * from "./request/errors.js"; export {collectExactOne, collectMaxResponse, formatProtocolID, parseProtocolID} from "./utils/index.js"; diff --git a/packages/reqresp/src/request/errors.ts b/packages/reqresp/src/request/errors.ts index 63ed176018df..ccc3c12f97a1 100644 --- a/packages/reqresp/src/request/errors.ts +++ b/packages/reqresp/src/request/errors.ts @@ -1,4 +1,4 @@ -import {LodestarError} from "@lodestar/utils"; +import {LodestarError, LodestarErrorObject} from "@lodestar/utils"; import {ResponseError} from "../response/index.js"; import {RespStatus, RpcResponseStatusError} from "../interface.js"; @@ -49,9 +49,19 @@ type RequestErrorType = | {code: RequestErrorCode.RESP_TIMEOUT} | {code: RequestErrorCode.REQUEST_RATE_LIMITED}; +export const REQUEST_ERROR_CLASS_NAME = "RequestError"; + export class RequestError extends LodestarError { - constructor(type: RequestErrorType) { - super(type, renderErrorMessage(type)); + constructor(type: RequestErrorType, message?: string, stack?: string) { + super(type, message ?? renderErrorMessage(type), stack); + } + + static fromObject(obj: LodestarErrorObject): RequestError { + if (obj.className !== "RequestError") { + throw new Error(`Expected className to be RequestError, but got ${obj.className}`); + } + + return new RequestError(obj.type as RequestErrorType, obj.message, obj.stack); } } diff --git a/packages/reqresp/src/response/errors.ts b/packages/reqresp/src/response/errors.ts index 44c311ae1ce6..b28a7540b467 100644 --- a/packages/reqresp/src/response/errors.ts +++ b/packages/reqresp/src/response/errors.ts @@ -1,4 +1,4 @@ -import {LodestarError} from "@lodestar/utils"; +import {LodestarError, LodestarErrorMetaData, LodestarErrorObject} from "@lodestar/utils"; import {RespStatus, RpcResponseStatusError} from "../interface.js"; type RpcResponseStatusNotSuccess = Exclude; @@ -13,6 +13,8 @@ type RequestErrorType = { errorMessage: string; }; +export const RESPONSE_ERROR_CLASS_NAME = "ResponseError"; + /** * Used internally only to signal a response status error. Since the error should never bubble up to the user, * the error code and error message does not matter much. @@ -20,10 +22,29 @@ type RequestErrorType = { export class ResponseError extends LodestarError { status: RpcResponseStatusNotSuccess; errorMessage: string; - constructor(status: RpcResponseStatusNotSuccess, errorMessage: string) { + constructor(status: RpcResponseStatusNotSuccess, errorMessage: string, stack?: string) { const type = {code: ResponseErrorCode.RESPONSE_STATUS_ERROR, status, errorMessage}; - super(type, `RESPONSE_ERROR_${RespStatus[status]}: ${errorMessage}`); + super(type, `RESPONSE_ERROR_${RespStatus[status]}: ${errorMessage}`, stack); this.status = status; this.errorMessage = errorMessage; } + + getMetadata(): LodestarErrorMetaData { + return { + status: this.status, + errorMessage: this.errorMessage, + }; + } + + static fromObject(obj: LodestarErrorObject): ResponseError { + if (obj.className !== RESPONSE_ERROR_CLASS_NAME) { + throw new Error(`Expected className to be ResponseError, but got ${obj.className}`); + } + + return new ResponseError( + obj.type.status as RpcResponseStatusNotSuccess, + obj.type.errorMessage as string, + obj.stack + ); + } } diff --git a/packages/utils/src/errors.ts b/packages/utils/src/errors.ts index 2faeebbd4054..6a29bcea213d 100644 --- a/packages/utils/src/errors.ts +++ b/packages/utils/src/errors.ts @@ -1,27 +1,42 @@ +export type LodestarErrorMetaData = Record; +export type LodestarErrorObject = { + message: string; + stack: string; + className: string; + type: LodestarErrorMetaData; +}; +export type FromObjectFn = (object: LodestarErrorObject) => Error; + /** * Generic Lodestar error with attached metadata */ export class LodestarError extends Error { type: T; - constructor(type: T, message?: string) { + constructor(type: T, message?: string, stack?: string) { super(message || type.code); this.type = type; + if (stack) this.stack = stack; } - getMetadata(): Record { + getMetadata(): LodestarErrorMetaData { return this.type; } /** * Get the metadata and the stacktrace for the error. */ - toObject(): Record { + toObject(): LodestarErrorObject { return { - // Ignore message since it's just type.code - ...this.getMetadata(), - stack: this.stack || "", + type: this.getMetadata(), + message: this.message ?? "", + stack: this.stack ?? "", + className: this.constructor.name, }; } + + static fromObject(obj: LodestarErrorObject): LodestarError<{code: string}> { + return new LodestarError(obj.type as {code: string}, obj.message, obj.stack); + } } /**