UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

391 lines (344 loc) • 13.6 kB
import {EventEmitter} from "node:events"; import {StrictEventEmitter} from "strict-event-emitter-types"; import {ErrorAborted, Gauge, Histogram, TimeoutError, fetch, isValidHttpUrl, retry} from "@lodestar/utils"; import {JwtClaim, encodeJwtToken} from "./jwt.js"; import {IJson, RpcPayload} from "./utils.js"; export enum JsonRpcHttpClientEvent { /** * When registered this event will be emitted before the client throws an error. * This is useful for defining the error behavior in a common place at the time of declaration of the client. */ ERROR = "jsonRpcHttpClient:error", /** * When registered this event will be emitted before the client returns the valid response to the caller. * This is useful for defining some common behavior for each request/response cycle */ RESPONSE = "jsonRpcHttpClient:response", } export type JsonRpcHttpClientEvents = { [JsonRpcHttpClientEvent.ERROR]: (event: {payload?: unknown; error: Error}) => void; [JsonRpcHttpClientEvent.RESPONSE]: (event: {payload?: unknown; response: unknown}) => void; }; export class JsonRpcHttpClientEventEmitter extends (EventEmitter as { new (): StrictEventEmitter<EventEmitter, JsonRpcHttpClientEvents>; }) {} /** * Limits the amount of response text printed with RPC or parsing errors */ const maxStringLengthToPrint = 500; const REQUEST_TIMEOUT = 30 * 1000; interface RpcResponse<R> extends RpcResponseError { result?: R; } interface RpcResponseError { jsonrpc: "2.0"; id: number; error: { code: number; // -32601; message: string; // "The method eth_none does not exist/is not available" }; } export type ReqOpts = { timeout?: number; // To label request metrics routeId?: string; // retry opts retries?: number; retryDelay?: number; shouldRetry?: (lastError: Error) => boolean; }; export type JsonRpcHttpClientMetrics = { requestTime: Histogram<{routeId: string}>; streamTime: Histogram<{routeId: string}>; requestErrors: Gauge<{routeId: string}>; requestUsedFallbackUrl: Gauge<{routeId: string}>; activeRequests: Gauge<{routeId: string}>; configUrlsCount: Gauge; retryCount: Gauge<{routeId: string}>; }; export interface IJsonRpcHttpClient { fetch<R, P = IJson[]>(payload: RpcPayload<P>, opts?: ReqOpts): Promise<R>; fetchWithRetries<R, P = IJson[]>(payload: RpcPayload<P>, opts?: ReqOpts): Promise<R>; fetchBatch<R>(rpcPayloadArr: RpcPayload[], opts?: ReqOpts): Promise<R[]>; emitter: JsonRpcHttpClientEventEmitter; } export class JsonRpcHttpClient implements IJsonRpcHttpClient { private id = 1; /** * Optional: If provided, use this jwt secret to HS256 encode and add a jwt token in the * request header which can be authenticated by the RPC server to provide access. * A fresh token is generated on each requests as EL spec mandates the ELs to check * the token freshness +-5 seconds (via `iat` property of the token claim) */ private readonly jwtSecret?: Uint8Array; private readonly jwtId?: string; private readonly jwtVersion?: string; private readonly metrics: JsonRpcHttpClientMetrics | null; readonly emitter = new JsonRpcHttpClientEventEmitter(); constructor( private readonly urls: string[], private readonly opts?: { signal?: AbortSignal; timeout?: number; /** If returns true, do not fallback to other urls and throw early */ shouldNotFallback?: (error: Error) => boolean; /** * Optional: If provided, use this jwt secret to HS256 encode and add a jwt token in the * request header which can be authenticated by the RPC server to provide access. * A fresh token is generated on each requests as EL spec mandates the ELs to check * the token freshness +-5 seconds (via `iat` property of the token claim) * * Otherwise the requests to the RPC server will be unauthorized * and it might deny responses to the RPC requests. */ jwtSecret?: Uint8Array; /** If jwtSecret and jwtId are provided, jwtId will be included in JwtClaim.id */ jwtId?: string; /** If jwtSecret and jwtVersion are provided, jwtVersion will be included in JwtClaim.clv. */ jwtVersion?: string; /** Number of retries per request */ retries?: number; /** Retry delay, only relevant if retries > 0 */ retryDelay?: number; /** Metrics for retry, could be expanded later */ metrics?: JsonRpcHttpClientMetrics | null; } ) { // Sanity check for all URLs to be properly defined. Otherwise it will error in loop on fetch if (urls.length === 0) { throw Error("No urls provided to JsonRpcHttpClient"); } for (const [i, url] of urls.entries()) { if (!url) { throw Error(`JsonRpcHttpClient.urls[${i}] is empty or undefined: ${url}`); } if (!isValidHttpUrl(url)) { throw Error(`JsonRpcHttpClient.urls[${i}] must be a valid URL: ${url}`); } } this.jwtSecret = opts?.jwtSecret; this.jwtId = opts?.jwtId; this.jwtVersion = opts?.jwtVersion; this.metrics = opts?.metrics ?? null; this.metrics?.configUrlsCount.set(urls.length); } /** * Perform RPC request */ async fetch<R, P = IJson[]>(payload: RpcPayload<P>, opts?: ReqOpts): Promise<R> { return this.wrapWithEvents( async () => { const res: RpcResponse<R> = await this.fetchJson({jsonrpc: "2.0", id: this.id++, ...payload}, opts); return parseRpcResponse(res, payload); }, {payload} ); } /** * Perform RPC request with retry */ async fetchWithRetries<R, P = IJson[]>(payload: RpcPayload<P>, opts?: ReqOpts): Promise<R> { return this.wrapWithEvents(async () => { const routeId = opts?.routeId ?? "unknown"; const res = await retry<RpcResponse<R>>( async (_attempt) => { return this.fetchJson({jsonrpc: "2.0", id: this.id++, ...payload}, opts); }, { retries: opts?.retries ?? this.opts?.retries ?? 0, retryDelay: opts?.retryDelay ?? this.opts?.retryDelay, shouldRetry: opts?.shouldRetry, signal: this.opts?.signal, onRetry: () => { this.opts?.metrics?.retryCount.inc({routeId}); }, } ); return parseRpcResponse(res, payload); }, payload); } /** * Perform RPC batched request * Type-wise assumes all requests results have the same type */ async fetchBatch<R>(rpcPayloadArr: RpcPayload[], opts?: ReqOpts): Promise<R[]> { return this.wrapWithEvents(async () => { if (rpcPayloadArr.length === 0) return []; const resArr: RpcResponse<R>[] = await this.fetchJson( rpcPayloadArr.map(({method, params}) => ({jsonrpc: "2.0", method, params, id: this.id++})), opts ); if (!Array.isArray(resArr)) { // Nethermind may reply to batch request with a JSON RPC error if ((resArr as RpcResponseError).error !== undefined) { throw new ErrorJsonRpcResponse(resArr as RpcResponseError, "batch"); } throw Error(`expected array of results, got ${resArr} - ${jsonSerializeTry(resArr)}`); } return resArr.map((res, i) => parseRpcResponse(res, rpcPayloadArr[i])); }, rpcPayloadArr); } private async wrapWithEvents<T>(func: () => Promise<T>, payload?: unknown): Promise<T> { try { const response = await func(); this.emitter.emit(JsonRpcHttpClientEvent.RESPONSE, {payload, response}); return response; } catch (error) { this.emitter.emit(JsonRpcHttpClientEvent.ERROR, {payload, error: error as Error}); throw error; } } private async fetchJson<R, T = unknown>(json: T, opts?: ReqOpts): Promise<R> { if (this.urls.length === 0) throw Error("No url provided"); const routeId = opts?.routeId ?? "unknown"; let lastError: Error | null = null; for (let i = 0; i < this.urls.length; i++) { if (i > 0) { this.metrics?.requestUsedFallbackUrl.inc({routeId}); } try { return await this.fetchJsonOneUrl<R, T>(this.urls[i], json, opts); } catch (e) { lastError = e as Error; if (this.opts?.shouldNotFallback?.(e as Error)) { break; } } } throw lastError ?? Error("Unknown error"); } /** * Fetches JSON and throws detailed errors in case the HTTP request is not ok */ private async fetchJsonOneUrl<R, T = unknown>(url: string, json: T, opts?: ReqOpts): Promise<R> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), opts?.timeout ?? this.opts?.timeout ?? REQUEST_TIMEOUT); const onParentSignalAbort = (): void => controller.abort(); this.opts?.signal?.addEventListener("abort", onParentSignalAbort, {once: true}); // Default to "unknown" to prevent mixing metrics with others. const routeId = opts?.routeId ?? "unknown"; const timer = this.metrics?.requestTime.startTimer({routeId}); this.metrics?.activeRequests.inc({routeId}, 1); try { const headers: Record<string, string> = {"Content-Type": "application/json"}; if (this.jwtSecret) { /** * ELs have a tight +-5 second freshness check on token's iat i.e. issued at * so its better to generate a new token each time. Currently iat is the only claim * we are encoding but potentially we can encode more claims. * Also currently the algorithm for the token generation is mandated to HS256 * * Jwt auth spec: https://github.com/ethereum/execution-apis/pull/167 */ const jwtClaim: JwtClaim = { iat: Math.floor(Date.now() / 1000), id: this.jwtId, clv: this.jwtVersion, }; const token = encodeJwtToken(jwtClaim, this.jwtSecret); headers.Authorization = `Bearer ${token}`; } const res = await fetch(url, { method: "post", body: JSON.stringify(json), headers, signal: controller.signal, }); const streamTimer = this.metrics?.streamTime.startTimer({routeId}); const bodyText = await res.text(); if (!res.ok) { // Infura errors: // - No project ID: Forbidden: {"jsonrpc":"2.0","id":0,"error":{"code":-32600,"message":"project ID is required","data":{"reason":"project ID not provided","see":"https://infura.io/dashboard"}}} throw new HttpRpcError(res.status, `${res.statusText}: ${bodyText.slice(0, maxStringLengthToPrint)}`); } const bodyJson = parseJson<R>(bodyText); streamTimer?.(); return bodyJson; } catch (e) { this.metrics?.requestErrors.inc({routeId}); if (controller.signal.aborted) { // controller will abort on both parent signal abort + timeout of this specific request if (this.opts?.signal?.aborted) { throw new ErrorAborted("request"); } throw new TimeoutError("request"); } throw e; } finally { timer?.(); this.metrics?.activeRequests.dec({routeId}, 1); clearTimeout(timeout); this.opts?.signal?.removeEventListener("abort", onParentSignalAbort); } } } function parseRpcResponse<R, P>(res: RpcResponse<R>, payload: RpcPayload<P>): R { if (res.result !== undefined) { return res.result; } if (res.error !== undefined) { throw new ErrorJsonRpcResponse(res, payload.method); } throw Error(`Invalid JSON RPC response, no result or error property: ${jsonSerializeTry(res)}`); } /** * Util: Parse JSON but display the original source string in case of error * Helps debug instances where an API returns a plain text instead of JSON, * such as getting an HTML page due to a wrong API URL */ function parseJson<T>(json: string): T { try { return JSON.parse(json) as T; } catch (e) { throw new ErrorParseJson(json, e as Error); } } export class ErrorParseJson extends Error { constructor(json: string, e: Error) { super(`Error parsing JSON: ${e.message}\n${json.slice(0, maxStringLengthToPrint)}`); } } /** JSON RPC endpoint returned status code == 200, but with error property set */ export class ErrorJsonRpcResponse extends Error { response: RpcResponseError; constructor(res: RpcResponseError, payloadMethod: string) { const errorMessage = typeof res.error === "object" ? typeof res.error.message === "string" ? res.error.message : typeof res.error.code === "number" ? parseJsonRpcErrorCode(res.error.code) : JSON.stringify(res.error) : String(res.error); super(`JSON RPC error: ${errorMessage}, ${payloadMethod}`); this.response = res; } } /** JSON RPC endpoint returned status code != 200 */ export class HttpRpcError extends Error { constructor( readonly status: number, message: string ) { super(message); } } /** * JSON RPC spec errors https://www.jsonrpc.org/specification#response_object */ export function parseJsonRpcErrorCode(code: number): string { if (code === -32700) return "Parse request error"; if (code === -32600) return "Invalid request object"; if (code === -32601) return "Method not found"; if (code === -32602) return "Invalid params"; if (code === -32603) return "Internal error"; if (code <= -32000 && code >= -32099) return "Server error"; return `Unknown error code ${code}`; } function jsonSerializeTry(obj: unknown): string { try { return JSON.stringify(obj); } catch (e) { return `Unable to serialize ${String(obj)}: ${(e as Error).message}`; } }