UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

269 lines • 10.8 kB
import { EventEmitter } from "node:events"; import { ErrorAborted, TimeoutError, fetch, isValidHttpUrl, retry } from "@lodestar/utils"; import { encodeJwtToken } from "./jwt.js"; export var JsonRpcHttpClientEvent; (function (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. */ JsonRpcHttpClientEvent["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 */ JsonRpcHttpClientEvent["RESPONSE"] = "jsonRpcHttpClient:response"; })(JsonRpcHttpClientEvent || (JsonRpcHttpClientEvent = {})); export class JsonRpcHttpClientEventEmitter extends EventEmitter { } /** * Limits the amount of response text printed with RPC or parsing errors */ const maxStringLengthToPrint = 500; const REQUEST_TIMEOUT = 30 * 1000; export class JsonRpcHttpClient { constructor(urls, opts) { this.urls = urls; this.opts = opts; this.id = 1; this.emitter = new JsonRpcHttpClientEventEmitter(); // 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(payload, opts) { return this.wrapWithEvents(async () => { const res = await this.fetchJson({ jsonrpc: "2.0", id: this.id++, ...payload }, opts); return parseRpcResponse(res, payload); }, { payload }); } /** * Perform RPC request with retry */ async fetchWithRetries(payload, opts) { return this.wrapWithEvents(async () => { const routeId = opts?.routeId ?? "unknown"; const res = await retry(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(rpcPayloadArr, opts) { return this.wrapWithEvents(async () => { if (rpcPayloadArr.length === 0) return []; const resArr = 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.error !== undefined) { throw new ErrorJsonRpcResponse(resArr, "batch"); } throw Error(`expected array of results, got ${resArr} - ${jsonSerializeTry(resArr)}`); } return resArr.map((res, i) => parseRpcResponse(res, rpcPayloadArr[i])); }, rpcPayloadArr); } async wrapWithEvents(func, payload) { try { const response = await func(); this.emitter.emit(JsonRpcHttpClientEvent.RESPONSE, { payload, response }); return response; } catch (error) { this.emitter.emit(JsonRpcHttpClientEvent.ERROR, { payload, error: error }); throw error; } } async fetchJson(json, opts) { if (this.urls.length === 0) throw Error("No url provided"); const routeId = opts?.routeId ?? "unknown"; let lastError = null; for (let i = 0; i < this.urls.length; i++) { if (i > 0) { this.metrics?.requestUsedFallbackUrl.inc({ routeId }); } try { return await this.fetchJsonOneUrl(this.urls[i], json, opts); } catch (e) { lastError = e; if (this.opts?.shouldNotFallback?.(e)) { break; } } } throw lastError ?? Error("Unknown error"); } /** * Fetches JSON and throws detailed errors in case the HTTP request is not ok */ async fetchJsonOneUrl(url, json, opts) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), opts?.timeout ?? this.opts?.timeout ?? REQUEST_TIMEOUT); const onParentSignalAbort = () => 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 = { "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 = { 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(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(res, payload) { 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(json) { try { return JSON.parse(json); } catch (e) { throw new ErrorParseJson(json, e); } } export class ErrorParseJson extends Error { constructor(json, e) { 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 { constructor(res, payloadMethod) { 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(status, message) { super(message); this.status = status; } } /** * JSON RPC spec errors https://www.jsonrpc.org/specification#response_object */ export function parseJsonRpcErrorCode(code) { 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) { try { return JSON.stringify(obj); } catch (e) { return `Unable to serialize ${String(obj)}: ${e.message}`; } } //# sourceMappingURL=jsonRpcHttpClient.js.map