Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/beacon-node/src/util/asyncIterableToEvents.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {LinkedList} from "./array.js";
import {ThreadBoundaryError, fromThreadBoundaryError, toThreadBoundaryError} from "./error.js";

export type RequestEvent<T> = {
callArgs: T;
Expand All @@ -14,7 +15,7 @@ export enum IteratorEventType {
export type IteratorEvent<V> =
| {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<Args, Item> = {
emitRequest(data: RequestEvent<Args>): void;
Expand Down Expand Up @@ -113,7 +114,7 @@ export class AsyncIterableBridgeCaller<Args, Item> {

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);
Expand Down Expand Up @@ -155,7 +156,7 @@ export class AsyncIterableBridgeHandler<Args, Item> {
this.events.emitResponse({
type: IteratorEventType.error,
id: data.id,
error: e,
error: toThreadBoundaryError(e as Error),
});
}
}
Expand Down
56 changes: 56 additions & 0 deletions packages/beacon-node/src/util/error.ts
Original file line number Diff line number Diff line change
@@ -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<string, FromObjectFn>([
[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;
}
42 changes: 42 additions & 0 deletions packages/beacon-node/test/unit/util/error.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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());
});
});
4 changes: 2 additions & 2 deletions packages/reqresp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
16 changes: 13 additions & 3 deletions packages/reqresp/src/request/errors.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<RequestErrorType> {
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);
}
}

Expand Down
27 changes: 24 additions & 3 deletions packages/reqresp/src/response/errors.ts
Original file line number Diff line number Diff line change
@@ -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<RespStatus, RespStatus.SUCCESS>;
Expand All @@ -13,17 +13,38 @@ 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.
*/
export class ResponseError extends LodestarError<RequestErrorType> {
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
);
}
}
27 changes: 21 additions & 6 deletions packages/utils/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,42 @@
export type LodestarErrorMetaData = Record<string, string | number | null>;
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<T extends {code: string}> 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<string, string | number | null> {
getMetadata(): LodestarErrorMetaData {
return this.type;
}

/**
* Get the metadata and the stacktrace for the error.
*/
toObject(): Record<string, string | number | null> {
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);
}
}

/**
Expand Down