@lodestar/api
Version:
A Typescript REST client for the Ethereum Consensus API
317 lines • 15.2 kB
JavaScript
import { ErrorAborted, MapDef, TimeoutError, isValidHttpUrl, retry, toPrintableUrl } from "@lodestar/utils";
import { mergeHeaders } from "../headers.js";
import { HttpStatusCode } from "../httpStatusCode.js";
import { WireFormat } from "../wireFormat.js";
import { fetch, isFetchError } from "./fetch.js";
import { createApiRequest, } from "./request.js";
import { ApiResponse } from "./response.js";
/** A higher default timeout, validator will set its own shorter timeoutMs */
const DEFAULT_TIMEOUT_MS = 60_000;
const DEFAULT_RETRIES = 0;
const DEFAULT_RETRY_DELAY = 200;
/**
* Default to JSON to ensure compatibility with other clients, can be overridden
* per route in case spec states that SSZ requests must be supported by server.
* Alternatively, can be configured via CLI flag to use SSZ for all routes.
*/
const DEFAULT_REQUEST_WIRE_FORMAT = WireFormat.json;
/**
* For responses, it is possible to default to SSZ without breaking compatibility with
* other clients as we will just be stating a preference to receive a SSZ response from
* the server but will still accept a JSON response in case the server does not support it.
*/
const DEFAULT_RESPONSE_WIRE_FORMAT = WireFormat.ssz;
const URL_SCORE_DELTA_SUCCESS = 1;
/** Require 2 success to recover from 1 failed request */
const URL_SCORE_DELTA_ERROR = 2 * URL_SCORE_DELTA_SUCCESS;
/** In case of continued errors, require 10 success to mark the URL as healthy */
const URL_SCORE_MAX = 10 * URL_SCORE_DELTA_SUCCESS;
const URL_SCORE_MIN = 0;
export const defaultInit = {
timeoutMs: DEFAULT_TIMEOUT_MS,
retries: DEFAULT_RETRIES,
retryDelay: DEFAULT_RETRY_DELAY,
requestWireFormat: DEFAULT_REQUEST_WIRE_FORMAT,
responseWireFormat: DEFAULT_RESPONSE_WIRE_FORMAT,
};
export class HttpClient {
get baseUrl() {
return this.urlsInits[0].baseUrl;
}
constructor(opts, { logger, metrics } = {}) {
this.urlsInits = [];
/**
* Cache to keep track of routes per server that do not support SSZ. This cache will only be
* populated if we receive a 415 error response from the server after sending a SSZ request body.
* The request will be retried using a JSON body and all subsequent requests will only use JSON.
*/
this.sszNotSupportedByRouteIdByUrlIndex = new MapDef(() => new Map());
// Cast to all types optional since they are defined with syntax `HttpClientOptions = A | B`
const { baseUrl, urls = [] } = opts;
// Do not merge global signal into url inits
const { signal, ...globalInit } = opts.globalInit ?? {};
// opts.baseUrl is equivalent to `urls: [{baseUrl}]`
// unshift opts.baseUrl to urls, without mutating opts.urls
for (const [i, urlOrInit] of [...(baseUrl ? [baseUrl] : []), ...urls].entries()) {
const init = typeof urlOrInit === "string" ? { baseUrl: urlOrInit } : urlOrInit;
const urlInit = {
...globalInit,
...init,
headers: mergeHeaders(globalInit.headers, init.headers),
};
if (!urlInit.baseUrl) {
throw Error(`HttpClient.urls[${i}] is empty or undefined: ${urlInit.baseUrl}`);
}
if (!isValidHttpUrl(urlInit.baseUrl)) {
throw Error(`HttpClient.urls[${i}] must be a valid URL: ${urlInit.baseUrl}`);
}
// De-duplicate by baseUrl, having two baseUrls with different token or timeouts does not make sense
if (!this.urlsInits.some((opt) => opt.baseUrl === urlInit.baseUrl)) {
this.urlsInits.push({
...urlInit,
baseUrl: urlInit.baseUrl,
urlIndex: i,
printableUrl: toPrintableUrl(urlInit.baseUrl),
});
}
}
if (this.urlsInits.length === 0) {
throw Error("Must set at least 1 URL in HttpClient opts");
}
// Initialize scores to max value to only query first URL on start
this.urlsScore = this.urlsInits.map(() => URL_SCORE_MAX);
this.signal = signal ?? null;
this.fetch = opts.fetch ?? fetch;
this.metrics = metrics ?? null;
this.logger = logger ?? null;
if (metrics) {
metrics.urlsScore.addCollect(() => {
for (let i = 0; i < this.urlsScore.length; i++) {
metrics.urlsScore.set({ urlIndex: i, baseUrl: this.urlsInits[i].printableUrl }, this.urlsScore[i]);
}
});
}
}
async request(definition, args, localInit = {}) {
if (this.urlsInits.length === 1) {
const init = mergeInits(definition, this.urlsInits[0], localInit);
if (init.retries > 0) {
return this.requestWithRetries(definition, args, init);
}
return this.getRequestMethod(init)(definition, args, init);
}
return this.requestWithFallbacks(definition, args, localInit);
}
/**
* Send request to primary server first, retry failed requests on fallbacks
*/
async requestWithFallbacks(definition, args, localInit) {
let i = 0;
// Goals:
// - if first server is stable and responding do not query fallbacks
// - if first server errors, retry that same request on fallbacks
// - until first server is shown to be reliable again, contact all servers
// First loop: retry in sequence, query next URL only after previous errors
for (; i < this.urlsInits.length; i++) {
try {
const res = await new Promise((resolve, reject) => {
let requestCount = 0;
let errorCount = 0;
// Second loop: query all URLs up to the next healthy at once, racing them.
// Score each URL available:
// - If url[0] is good, only send to 0
// - If url[0] has recently errored, send to both 0, 1, etc until url[0] does not error for some time
for (; i < this.urlsInits.length; i++) {
const { printableUrl } = this.urlsInits[i];
const routeId = definition.operationId;
if (i > 0) {
this.metrics?.requestToFallbacks.inc({ routeId, baseUrl: printableUrl });
this.logger?.debug("Requesting fallback URL", { routeId, baseUrl: printableUrl, score: this.urlsScore[i] });
}
// biome-ignore lint/style/useNamingConvention: Author preferred this format
const i_ = i; // Keep local copy of i variable to index urlScore after requestWithBody() resolves
const urlInit = this.urlsInits[i];
if (urlInit === undefined) {
throw Error(`Url at index ${i} does not exist`);
}
const init = mergeInits(definition, urlInit, localInit);
const requestMethod = init.retries > 0 ? this.requestWithRetries.bind(this) : this.getRequestMethod(init);
requestMethod(definition, args, init).then(async (res) => {
if (res.ok) {
this.urlsScore[i_] = Math.min(URL_SCORE_MAX, this.urlsScore[i_] + URL_SCORE_DELTA_SUCCESS);
// Resolve immediately on success
resolve(res);
}
else {
this.urlsScore[i_] = Math.max(URL_SCORE_MIN, this.urlsScore[i_] - URL_SCORE_DELTA_ERROR);
// Resolve failed response only when all queried URLs have errored
if (++errorCount >= requestCount) {
resolve(res);
}
else {
this.logger?.debug("Request error, retrying", { routeId, baseUrl: printableUrl }, res.error());
}
}
}, (err) => {
this.urlsScore[i_] = Math.max(URL_SCORE_MIN, this.urlsScore[i_] - URL_SCORE_DELTA_ERROR);
// Reject only when all queried URLs have errored
// TODO: Currently rejects with last error only, should join errors?
if (++errorCount >= requestCount) {
reject(err);
}
else {
this.logger?.debug("Request error, retrying", { routeId, baseUrl: printableUrl }, err);
}
});
requestCount++;
// Do not query URLs after a healthy URL
if (this.urlsScore[i] >= URL_SCORE_MAX) {
break;
}
}
});
if (res.ok) {
return res;
}
if (i >= this.urlsInits.length - 1) {
return res;
}
this.logger?.debug("Request error, retrying", {}, res.error());
}
catch (e) {
if (i >= this.urlsInits.length - 1) {
throw e;
}
this.logger?.debug("Request error, retrying", {}, e);
}
}
throw Error("loop ended without return or rejection");
}
/**
* Send request to single URL, retry failed requests on same server
*/
async requestWithRetries(definition, args, init) {
const { retries, retryDelay, signal } = init;
const routeId = definition.operationId;
const requestMethod = this.getRequestMethod(init);
return retry(async (attempt) => {
const res = await requestMethod(definition, args, init);
if (!res.ok && attempt <= retries) {
throw res.error();
}
return res;
}, {
retries,
retryDelay,
// Local signal takes precedence over global signal
signal: signal ?? this.signal ?? undefined,
onRetry: (e, attempt) => {
this.logger?.debug("Retrying request", { routeId, attempt, lastError: e.message });
},
});
}
/**
* Send request to single URL, SSZ requests will be retried using JSON
* if a 415 error response is returned by the server. All subsequent requests
* to this server for the route will always be sent as JSON afterwards.
*/
async requestFallbackToJson(definition, args, init) {
const { urlIndex } = init;
const routeId = definition.operationId;
const sszNotSupportedByRouteId = this.sszNotSupportedByRouteIdByUrlIndex.getOrDefault(urlIndex);
if (sszNotSupportedByRouteId.has(routeId)) {
init.requestWireFormat = WireFormat.json;
}
const res = await this._request(definition, args, init);
if (res.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE && init.requestWireFormat === WireFormat.ssz) {
this.logger?.debug("SSZ request failed with status 415, retrying using JSON", { routeId, urlIndex });
sszNotSupportedByRouteId.set(routeId, true);
init.requestWireFormat = WireFormat.json;
return this._request(definition, args, init);
}
return res;
}
/**
* Send request to single URL
*/
async _request(definition, args, init) {
const abortSignals = [this.signal, init.signal];
// Implement fetch timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), init.timeoutMs);
init.signal = controller.signal;
// Attach global/local signal to this request's controller
const onSignalAbort = () => controller.abort();
for (const s of abortSignals) {
s?.addEventListener("abort", onSignalAbort);
}
const routeId = definition.operationId;
const { printableUrl, requestWireFormat, responseWireFormat } = init;
const timer = this.metrics?.requestTime.startTimer({ routeId });
try {
this.logger?.debug("API request", { routeId, requestWireFormat, responseWireFormat });
const request = createApiRequest(definition, args, init);
const response = await this.fetch(request.url, request);
const apiResponse = new ApiResponse(definition, response.body, response);
if (!apiResponse.ok) {
await apiResponse.errorBody();
this.logger?.debug("API response error", { routeId, status: apiResponse.status });
this.metrics?.requestErrors.inc({ routeId, baseUrl: printableUrl });
return apiResponse;
}
const streamTimer = this.metrics?.streamTime.startTimer({ routeId });
try {
await apiResponse.rawBody();
this.logger?.debug("API response success", {
routeId,
status: apiResponse.status,
wireFormat: apiResponse.wireFormat(),
});
return apiResponse;
}
finally {
streamTimer?.();
}
}
catch (e) {
this.metrics?.requestErrors.inc({ routeId, baseUrl: printableUrl });
if (isAbortedError(e)) {
if (abortSignals.some((s) => s?.aborted)) {
throw new ErrorAborted(`${routeId} request`);
}
if (controller.signal.aborted) {
throw new TimeoutError(`${routeId} request`);
}
throw Error("Unknown aborted error");
}
throw e;
}
finally {
timer?.();
clearTimeout(timeout);
for (const s of abortSignals) {
s?.removeEventListener("abort", onSignalAbort);
}
}
}
getRequestMethod(init) {
return init.requestWireFormat === WireFormat.ssz ? this.requestFallbackToJson.bind(this) : this._request.bind(this);
}
}
function mergeInits(definition, urlInit, localInit) {
return {
...defaultInit,
...definition.init,
// Sanitize user provided values
...removeUndefined(urlInit),
...removeUndefined(localInit),
headers: mergeHeaders(urlInit.headers, localInit.headers),
};
}
function removeUndefined(obj) {
return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined));
}
function isAbortedError(e) {
return isFetchError(e) && e.type === "aborted";
}
//# sourceMappingURL=httpClient.js.map