@lodestar/beacon-node
Version:
A Typescript implementation of the beacon chain
269 lines • 10.8 kB
JavaScript
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