UNPKG

@lodestar/api

Version:

A Typescript REST client for the Ethereum Consensus API

317 lines • 15.2 kB
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