@lodestar/api 1.35.0-dev.f80d2d52da → 1.35.0-dev.fcf8d024ea

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/lib/beacon/client/beacon.d.ts.map +1 -0
  2. package/lib/beacon/client/config.d.ts.map +1 -0
  3. package/lib/beacon/client/debug.d.ts.map +1 -0
  4. package/lib/beacon/client/events.d.ts.map +1 -0
  5. package/lib/beacon/client/index.d.ts.map +1 -0
  6. package/lib/beacon/client/index.js.map +1 -1
  7. package/lib/beacon/client/lightclient.d.ts.map +1 -0
  8. package/lib/beacon/client/lodestar.d.ts.map +1 -0
  9. package/lib/beacon/client/node.d.ts.map +1 -0
  10. package/lib/beacon/client/proof.d.ts.map +1 -0
  11. package/lib/beacon/client/validator.d.ts.map +1 -0
  12. package/lib/beacon/index.d.ts +1 -1
  13. package/lib/beacon/index.d.ts.map +1 -0
  14. package/lib/beacon/index.js.map +1 -1
  15. package/lib/beacon/routes/beacon/block.d.ts.map +1 -0
  16. package/lib/beacon/routes/beacon/index.d.ts +3 -3
  17. package/lib/beacon/routes/beacon/index.d.ts.map +1 -0
  18. package/lib/beacon/routes/beacon/index.js.map +1 -1
  19. package/lib/beacon/routes/beacon/pool.d.ts +1 -1
  20. package/lib/beacon/routes/beacon/pool.d.ts.map +1 -0
  21. package/lib/beacon/routes/beacon/rewards.d.ts.map +1 -0
  22. package/lib/beacon/routes/beacon/rewards.js.map +1 -1
  23. package/lib/beacon/routes/beacon/state.d.ts +2 -2
  24. package/lib/beacon/routes/beacon/state.d.ts.map +1 -0
  25. package/lib/beacon/routes/config.d.ts +1 -1
  26. package/lib/beacon/routes/config.d.ts.map +1 -0
  27. package/lib/beacon/routes/debug.d.ts.map +1 -0
  28. package/lib/beacon/routes/events.d.ts.map +1 -0
  29. package/lib/beacon/routes/events.js.map +1 -1
  30. package/lib/beacon/routes/index.d.ts.map +1 -0
  31. package/lib/beacon/routes/index.js.map +1 -1
  32. package/lib/beacon/routes/lightclient.d.ts.map +1 -0
  33. package/lib/beacon/routes/lodestar.d.ts.map +1 -0
  34. package/lib/beacon/routes/node.d.ts.map +1 -0
  35. package/lib/beacon/routes/proof.d.ts.map +1 -0
  36. package/lib/beacon/routes/validator.d.ts +1 -1
  37. package/lib/beacon/routes/validator.d.ts.map +1 -0
  38. package/lib/beacon/server/beacon.d.ts.map +1 -0
  39. package/lib/beacon/server/config.d.ts.map +1 -0
  40. package/lib/beacon/server/debug.d.ts.map +1 -0
  41. package/lib/beacon/server/events.d.ts.map +1 -0
  42. package/lib/beacon/server/index.d.ts +1 -1
  43. package/lib/beacon/server/index.d.ts.map +1 -0
  44. package/lib/beacon/server/index.js.map +1 -1
  45. package/lib/beacon/server/lightclient.d.ts.map +1 -0
  46. package/lib/beacon/server/lodestar.d.ts.map +1 -0
  47. package/lib/beacon/server/node.d.ts.map +1 -0
  48. package/lib/beacon/server/proof.d.ts.map +1 -0
  49. package/lib/beacon/server/validator.d.ts.map +1 -0
  50. package/lib/builder/client.d.ts.map +1 -0
  51. package/lib/builder/index.d.ts.map +1 -0
  52. package/lib/builder/index.js.map +1 -1
  53. package/lib/builder/routes.d.ts.map +1 -0
  54. package/lib/builder/routes.js.map +1 -1
  55. package/lib/builder/server/index.d.ts +1 -1
  56. package/lib/builder/server/index.d.ts.map +1 -0
  57. package/lib/index.d.ts +6 -6
  58. package/lib/index.d.ts.map +1 -0
  59. package/lib/index.js +3 -3
  60. package/lib/index.js.map +1 -1
  61. package/lib/keymanager/client.d.ts.map +1 -0
  62. package/lib/keymanager/index.d.ts +2 -2
  63. package/lib/keymanager/index.d.ts.map +1 -0
  64. package/lib/keymanager/index.js +1 -2
  65. package/lib/keymanager/index.js.map +1 -1
  66. package/lib/keymanager/routes.d.ts.map +1 -0
  67. package/lib/keymanager/server/index.d.ts +1 -1
  68. package/lib/keymanager/server/index.d.ts.map +1 -0
  69. package/lib/server/index.d.ts.map +1 -0
  70. package/lib/utils/client/error.d.ts.map +1 -0
  71. package/lib/utils/client/error.js +2 -0
  72. package/lib/utils/client/error.js.map +1 -1
  73. package/lib/utils/client/eventSource.d.ts.map +1 -0
  74. package/lib/utils/client/format.d.ts.map +1 -0
  75. package/lib/utils/client/httpClient.d.ts +1 -2
  76. package/lib/utils/client/httpClient.d.ts.map +1 -0
  77. package/lib/utils/client/httpClient.js +13 -9
  78. package/lib/utils/client/httpClient.js.map +1 -1
  79. package/lib/utils/client/index.d.ts +1 -1
  80. package/lib/utils/client/index.d.ts.map +1 -0
  81. package/lib/utils/client/index.js +1 -1
  82. package/lib/utils/client/index.js.map +1 -1
  83. package/lib/utils/client/method.d.ts.map +1 -0
  84. package/lib/utils/client/metrics.d.ts.map +1 -0
  85. package/lib/utils/client/request.d.ts.map +1 -0
  86. package/lib/utils/client/response.d.ts.map +1 -0
  87. package/lib/utils/client/response.js +6 -0
  88. package/lib/utils/client/response.js.map +1 -1
  89. package/lib/utils/codecs.d.ts.map +1 -0
  90. package/lib/utils/fork.d.ts.map +1 -0
  91. package/lib/utils/headers.d.ts.map +1 -0
  92. package/lib/utils/httpStatusCode.d.ts.map +1 -0
  93. package/lib/utils/index.d.ts.map +1 -0
  94. package/lib/utils/metadata.d.ts.map +1 -0
  95. package/lib/utils/schema.d.ts.map +1 -0
  96. package/lib/utils/serdes.d.ts.map +1 -0
  97. package/lib/utils/server/error.d.ts.map +1 -0
  98. package/lib/utils/server/error.js +1 -0
  99. package/lib/utils/server/error.js.map +1 -1
  100. package/lib/utils/server/handler.d.ts.map +1 -0
  101. package/lib/utils/server/index.d.ts.map +1 -0
  102. package/lib/utils/server/method.d.ts.map +1 -0
  103. package/lib/utils/server/parser.d.ts.map +1 -0
  104. package/lib/utils/server/route.d.ts.map +1 -0
  105. package/lib/utils/server/route.js.map +1 -1
  106. package/lib/utils/types.d.ts.map +1 -0
  107. package/lib/utils/urlFormat.d.ts.map +1 -0
  108. package/lib/utils/wireFormat.d.ts.map +1 -0
  109. package/package.json +17 -11
  110. package/src/beacon/client/beacon.ts +12 -0
  111. package/src/beacon/client/config.ts +12 -0
  112. package/src/beacon/client/debug.ts +12 -0
  113. package/src/beacon/client/events.ts +69 -0
  114. package/src/beacon/client/index.ts +46 -0
  115. package/src/beacon/client/lightclient.ts +12 -0
  116. package/src/beacon/client/lodestar.ts +12 -0
  117. package/src/beacon/client/node.ts +12 -0
  118. package/src/beacon/client/proof.ts +12 -0
  119. package/src/beacon/client/validator.ts +12 -0
  120. package/src/beacon/index.ts +24 -0
  121. package/src/beacon/routes/beacon/block.ts +602 -0
  122. package/src/beacon/routes/beacon/index.ts +66 -0
  123. package/src/beacon/routes/beacon/pool.ts +503 -0
  124. package/src/beacon/routes/beacon/rewards.ts +216 -0
  125. package/src/beacon/routes/beacon/state.ts +588 -0
  126. package/src/beacon/routes/config.ts +114 -0
  127. package/src/beacon/routes/debug.ts +231 -0
  128. package/src/beacon/routes/events.ts +337 -0
  129. package/src/beacon/routes/index.ts +33 -0
  130. package/src/beacon/routes/lightclient.ts +241 -0
  131. package/src/beacon/routes/lodestar.ts +456 -0
  132. package/src/beacon/routes/node.ts +286 -0
  133. package/src/beacon/routes/proof.ts +79 -0
  134. package/src/beacon/routes/validator.ts +1014 -0
  135. package/src/beacon/server/beacon.ts +7 -0
  136. package/src/beacon/server/config.ts +7 -0
  137. package/src/beacon/server/debug.ts +7 -0
  138. package/src/beacon/server/events.ts +73 -0
  139. package/src/beacon/server/index.ts +55 -0
  140. package/src/beacon/server/lightclient.ts +7 -0
  141. package/src/beacon/server/lodestar.ts +7 -0
  142. package/src/beacon/server/node.ts +7 -0
  143. package/src/beacon/server/proof.ts +7 -0
  144. package/src/beacon/server/validator.ts +7 -0
  145. package/src/builder/client.ts +9 -0
  146. package/src/builder/index.ts +26 -0
  147. package/src/builder/routes.ts +227 -0
  148. package/src/builder/server/index.ts +19 -0
  149. package/src/index.ts +19 -0
  150. package/src/keymanager/client.ts +9 -0
  151. package/src/keymanager/index.ts +39 -0
  152. package/src/keymanager/routes.ts +699 -0
  153. package/src/keymanager/server/index.ts +19 -0
  154. package/src/server/index.ts +2 -0
  155. package/src/utils/client/error.ts +10 -0
  156. package/src/utils/client/eventSource.ts +7 -0
  157. package/src/utils/client/format.ts +22 -0
  158. package/src/utils/client/httpClient.ts +444 -0
  159. package/src/utils/client/index.ts +6 -0
  160. package/src/utils/client/method.ts +50 -0
  161. package/src/utils/client/metrics.ts +9 -0
  162. package/src/utils/client/request.ts +113 -0
  163. package/src/utils/client/response.ts +205 -0
  164. package/src/utils/codecs.ts +143 -0
  165. package/src/utils/fork.ts +44 -0
  166. package/src/utils/headers.ts +173 -0
  167. package/src/utils/httpStatusCode.ts +392 -0
  168. package/src/utils/index.ts +3 -0
  169. package/src/utils/metadata.ts +170 -0
  170. package/src/utils/schema.ts +141 -0
  171. package/src/utils/serdes.ts +120 -0
  172. package/src/utils/server/error.ts +9 -0
  173. package/src/utils/server/handler.ts +149 -0
  174. package/src/utils/server/index.ts +5 -0
  175. package/src/utils/server/method.ts +38 -0
  176. package/src/utils/server/parser.ts +15 -0
  177. package/src/utils/server/route.ts +45 -0
  178. package/src/utils/types.ts +161 -0
  179. package/src/utils/urlFormat.ts +112 -0
  180. package/src/utils/wireFormat.ts +24 -0
@@ -0,0 +1,19 @@
1
+ import type {FastifyInstance} from "fastify";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {AnyEndpoint} from "../../utils/codecs.js";
4
+ import {ApplicationMethods, FastifyRoute, FastifyRoutes, createFastifyRoutes} from "../../utils/server/index.js";
5
+ import {Endpoints, getDefinitions} from "../routes.js";
6
+
7
+ export type KeymanagerApiMethods = ApplicationMethods<Endpoints>;
8
+
9
+ export function getRoutes(config: ChainForkConfig, methods: KeymanagerApiMethods): FastifyRoutes<Endpoints> {
10
+ return createFastifyRoutes(getDefinitions(config), methods);
11
+ }
12
+
13
+ export function registerRoutes(server: FastifyInstance, config: ChainForkConfig, methods: KeymanagerApiMethods): void {
14
+ const routes = getRoutes(config, methods);
15
+
16
+ for (const route of Object.values(routes)) {
17
+ server.route(route as FastifyRoute<AnyEndpoint>);
18
+ }
19
+ }
@@ -0,0 +1,2 @@
1
+ // Server specific code to be exported from /server subpath
2
+ export * from "../utils/server/index.js";
@@ -0,0 +1,10 @@
1
+ export class ApiError extends Error {
2
+ status: number;
3
+ operationId: string;
4
+
5
+ constructor(message: string, status: number, operationId: string) {
6
+ super(`${operationId} failed with status ${status}: ${message}`);
7
+ this.status = status;
8
+ this.operationId = operationId;
9
+ }
10
+ }
@@ -0,0 +1,7 @@
1
+ // This function switches between the native web implementation and a nodejs implemnetation
2
+ export async function getEventSource(): Promise<typeof EventSource> {
3
+ if (globalThis.EventSource) {
4
+ return EventSource;
5
+ }
6
+ return (await import("eventsource")).default as unknown as typeof EventSource;
7
+ }
@@ -0,0 +1,22 @@
1
+ import {stringify as queryStringStringify} from "qs";
2
+
3
+ /**
4
+ * Ethereum Beacon API requires the query with format:
5
+ * - arrayFormat: repeat `topic=topic1&topic=topic2`
6
+ */
7
+ export function stringifyQuery(query: unknown): string {
8
+ return queryStringStringify(query, {arrayFormat: "repeat"});
9
+ }
10
+
11
+ /**
12
+ * TODO: Optimize, two regex is a bit wasteful
13
+ */
14
+ export function urlJoin(...args: string[]): string {
15
+ return (
16
+ args
17
+ .join("/")
18
+ .replace(/([^:]\/)\/+/g, "$1")
19
+ // Remove duplicate slashes in the front
20
+ .replace(/^(\/)+/, "/")
21
+ );
22
+ }
@@ -0,0 +1,444 @@
1
+ import {
2
+ ErrorAborted,
3
+ Logger,
4
+ MapDef,
5
+ TimeoutError,
6
+ fetch,
7
+ isFetchError,
8
+ isValidHttpUrl,
9
+ retry,
10
+ toPrintableUrl,
11
+ } from "@lodestar/utils";
12
+ import {mergeHeaders} from "../headers.js";
13
+ import {HttpStatusCode} from "../httpStatusCode.js";
14
+ import {Endpoint} from "../types.js";
15
+ import {WireFormat} from "../wireFormat.js";
16
+ import {Metrics} from "./metrics.js";
17
+ import {
18
+ ApiRequestInit,
19
+ ApiRequestInitRequired,
20
+ ExtraRequestInit,
21
+ RouteDefinitionExtra,
22
+ UrlInit,
23
+ UrlInitRequired,
24
+ createApiRequest,
25
+ } from "./request.js";
26
+ import {ApiResponse} from "./response.js";
27
+
28
+ /** A higher default timeout, validator will set its own shorter timeoutMs */
29
+ const DEFAULT_TIMEOUT_MS = 60_000;
30
+ const DEFAULT_RETRIES = 0;
31
+ const DEFAULT_RETRY_DELAY = 200;
32
+ /**
33
+ * Default to JSON to ensure compatibility with other clients, can be overridden
34
+ * per route in case spec states that SSZ requests must be supported by server.
35
+ * Alternatively, can be configured via CLI flag to use SSZ for all routes.
36
+ */
37
+ const DEFAULT_REQUEST_WIRE_FORMAT = WireFormat.json;
38
+ /**
39
+ * For responses, it is possible to default to SSZ without breaking compatibility with
40
+ * other clients as we will just be stating a preference to receive a SSZ response from
41
+ * the server but will still accept a JSON response in case the server does not support it.
42
+ */
43
+ const DEFAULT_RESPONSE_WIRE_FORMAT = WireFormat.ssz;
44
+
45
+ const URL_SCORE_DELTA_SUCCESS = 1;
46
+ /** Require 2 success to recover from 1 failed request */
47
+ const URL_SCORE_DELTA_ERROR = 2 * URL_SCORE_DELTA_SUCCESS;
48
+ /** In case of continued errors, require 10 success to mark the URL as healthy */
49
+ const URL_SCORE_MAX = 10 * URL_SCORE_DELTA_SUCCESS;
50
+ const URL_SCORE_MIN = 0;
51
+
52
+ export const defaultInit: Required<ExtraRequestInit> = {
53
+ timeoutMs: DEFAULT_TIMEOUT_MS,
54
+ retries: DEFAULT_RETRIES,
55
+ retryDelay: DEFAULT_RETRY_DELAY,
56
+ requestWireFormat: DEFAULT_REQUEST_WIRE_FORMAT,
57
+ responseWireFormat: DEFAULT_RESPONSE_WIRE_FORMAT,
58
+ };
59
+
60
+ export interface IHttpClient {
61
+ readonly baseUrl: string;
62
+ readonly urlsInits: UrlInitRequired[];
63
+ readonly urlsScore: number[];
64
+
65
+ request<E extends Endpoint>(
66
+ definition: RouteDefinitionExtra<E>,
67
+ args: E["args"],
68
+ localInit?: ApiRequestInit
69
+ ): Promise<ApiResponse<E>>;
70
+ }
71
+
72
+ export type HttpClientOptions = ({baseUrl: string} | {urls: (string | UrlInit)[]}) & {
73
+ globalInit?: ApiRequestInit;
74
+ /** Override fetch function */
75
+ fetch?: typeof fetch;
76
+ };
77
+
78
+ export type HttpClientModules = {
79
+ logger?: Logger;
80
+ metrics?: Metrics;
81
+ };
82
+
83
+ export class HttpClient implements IHttpClient {
84
+ readonly urlsInits: UrlInitRequired[] = [];
85
+ readonly urlsScore: number[];
86
+
87
+ private readonly signal: null | AbortSignal;
88
+ private readonly fetch: typeof fetch;
89
+ private readonly metrics: null | Metrics;
90
+ private readonly logger: null | Logger;
91
+
92
+ /**
93
+ * Cache to keep track of routes per server that do not support SSZ. This cache will only be
94
+ * populated if we receive a 415 error response from the server after sending a SSZ request body.
95
+ * The request will be retried using a JSON body and all subsequent requests will only use JSON.
96
+ */
97
+ private readonly sszNotSupportedByRouteIdByUrlIndex = new MapDef<number, Map<string, boolean>>(() => new Map());
98
+
99
+ get baseUrl(): string {
100
+ return this.urlsInits[0].baseUrl;
101
+ }
102
+
103
+ constructor(opts: HttpClientOptions, {logger, metrics}: HttpClientModules = {}) {
104
+ // Cast to all types optional since they are defined with syntax `HttpClientOptions = A | B`
105
+ const {baseUrl, urls = []} = opts as {baseUrl?: string; urls?: (string | UrlInit)[]};
106
+ // Do not merge global signal into url inits
107
+ const {signal, ...globalInit} = opts.globalInit ?? {};
108
+
109
+ // opts.baseUrl is equivalent to `urls: [{baseUrl}]`
110
+ // unshift opts.baseUrl to urls, without mutating opts.urls
111
+ for (const [i, urlOrInit] of [...(baseUrl ? [baseUrl] : []), ...urls].entries()) {
112
+ const init = typeof urlOrInit === "string" ? {baseUrl: urlOrInit} : urlOrInit;
113
+ const urlInit: UrlInit = {
114
+ ...globalInit,
115
+ ...init,
116
+ headers: mergeHeaders(globalInit.headers, init.headers),
117
+ };
118
+
119
+ if (!urlInit.baseUrl) {
120
+ throw Error(`HttpClient.urls[${i}] is empty or undefined: ${urlInit.baseUrl}`);
121
+ }
122
+ if (!isValidHttpUrl(urlInit.baseUrl)) {
123
+ throw Error(`HttpClient.urls[${i}] must be a valid URL: ${urlInit.baseUrl}`);
124
+ }
125
+ // De-duplicate by baseUrl, having two baseUrls with different token or timeouts does not make sense
126
+ if (!this.urlsInits.some((opt) => opt.baseUrl === urlInit.baseUrl)) {
127
+ this.urlsInits.push({
128
+ ...urlInit,
129
+ baseUrl: urlInit.baseUrl,
130
+ urlIndex: i,
131
+ printableUrl: toPrintableUrl(urlInit.baseUrl),
132
+ });
133
+ }
134
+ }
135
+
136
+ if (this.urlsInits.length === 0) {
137
+ throw Error("Must set at least 1 URL in HttpClient opts");
138
+ }
139
+
140
+ // Initialize scores to max value to only query first URL on start
141
+ this.urlsScore = this.urlsInits.map(() => URL_SCORE_MAX);
142
+
143
+ this.signal = signal ?? null;
144
+ this.fetch = opts.fetch ?? fetch;
145
+ this.metrics = metrics ?? null;
146
+ this.logger = logger ?? null;
147
+
148
+ if (metrics) {
149
+ metrics.urlsScore.addCollect(() => {
150
+ for (let i = 0; i < this.urlsScore.length; i++) {
151
+ metrics.urlsScore.set({urlIndex: i, baseUrl: this.urlsInits[i].printableUrl}, this.urlsScore[i]);
152
+ }
153
+ });
154
+ }
155
+ }
156
+
157
+ async request<E extends Endpoint>(
158
+ definition: RouteDefinitionExtra<E>,
159
+ args: E["args"],
160
+ localInit: ApiRequestInit = {}
161
+ ): Promise<ApiResponse<E>> {
162
+ if (this.urlsInits.length === 1) {
163
+ const init = mergeInits(definition, this.urlsInits[0], localInit);
164
+
165
+ if (init.retries > 0) {
166
+ return this.requestWithRetries(definition, args, init);
167
+ }
168
+ return this.getRequestMethod(init)(definition, args, init);
169
+ }
170
+ return this.requestWithFallbacks(definition, args, localInit);
171
+ }
172
+
173
+ /**
174
+ * Send request to primary server first, retry failed requests on fallbacks
175
+ */
176
+ private async requestWithFallbacks<E extends Endpoint>(
177
+ definition: RouteDefinitionExtra<E>,
178
+ args: E["args"],
179
+ localInit: ApiRequestInit
180
+ ): Promise<ApiResponse<E>> {
181
+ let i = 0;
182
+
183
+ // Goals:
184
+ // - if first server is stable and responding do not query fallbacks
185
+ // - if first server errors, retry that same request on fallbacks
186
+ // - until first server is shown to be reliable again, contact all servers
187
+
188
+ // First loop: retry in sequence, query next URL only after previous errors
189
+ for (; i < this.urlsInits.length; i++) {
190
+ try {
191
+ const res = await new Promise<ApiResponse<E>>((resolve, reject) => {
192
+ let requestCount = 0;
193
+ let errorCount = 0;
194
+
195
+ // Second loop: query all URLs up to the next healthy at once, racing them.
196
+ // Score each URL available:
197
+ // - If url[0] is good, only send to 0
198
+ // - If url[0] has recently errored, send to both 0, 1, etc until url[0] does not error for some time
199
+ for (; i < this.urlsInits.length; i++) {
200
+ const {printableUrl} = this.urlsInits[i];
201
+ const routeId = definition.operationId;
202
+
203
+ if (i > 0) {
204
+ this.metrics?.requestToFallbacks.inc({routeId, baseUrl: printableUrl});
205
+ this.logger?.debug("Requesting fallback URL", {routeId, baseUrl: printableUrl, score: this.urlsScore[i]});
206
+ }
207
+
208
+ // biome-ignore lint/style/useNamingConvention: Author preferred this format
209
+ const i_ = i; // Keep local copy of i variable to index urlScore after requestWithBody() resolves
210
+
211
+ const urlInit = this.urlsInits[i];
212
+ if (urlInit === undefined) {
213
+ throw Error(`Url at index ${i} does not exist`);
214
+ }
215
+ const init = mergeInits(definition, urlInit, localInit);
216
+
217
+ const requestMethod = init.retries > 0 ? this.requestWithRetries.bind(this) : this.getRequestMethod(init);
218
+
219
+ requestMethod(definition, args, init).then(
220
+ async (res) => {
221
+ if (res.ok) {
222
+ this.urlsScore[i_] = Math.min(URL_SCORE_MAX, this.urlsScore[i_] + URL_SCORE_DELTA_SUCCESS);
223
+ // Resolve immediately on success
224
+ resolve(res);
225
+ } else {
226
+ this.urlsScore[i_] = Math.max(URL_SCORE_MIN, this.urlsScore[i_] - URL_SCORE_DELTA_ERROR);
227
+
228
+ // Resolve failed response only when all queried URLs have errored
229
+ if (++errorCount >= requestCount) {
230
+ resolve(res);
231
+ } else {
232
+ this.logger?.debug(
233
+ "Request error, retrying",
234
+ {routeId, baseUrl: printableUrl},
235
+ res.error() as Error
236
+ );
237
+ }
238
+ }
239
+ },
240
+ (err) => {
241
+ this.urlsScore[i_] = Math.max(URL_SCORE_MIN, this.urlsScore[i_] - URL_SCORE_DELTA_ERROR);
242
+
243
+ // Reject only when all queried URLs have errored
244
+ // TODO: Currently rejects with last error only, should join errors?
245
+ if (++errorCount >= requestCount) {
246
+ reject(err);
247
+ } else {
248
+ this.logger?.debug("Request error, retrying", {routeId, baseUrl: printableUrl}, err);
249
+ }
250
+ }
251
+ );
252
+
253
+ requestCount++;
254
+
255
+ // Do not query URLs after a healthy URL
256
+ if (this.urlsScore[i] >= URL_SCORE_MAX) {
257
+ break;
258
+ }
259
+ }
260
+ });
261
+ if (res.ok) {
262
+ return res;
263
+ }
264
+ if (i >= this.urlsInits.length - 1) {
265
+ return res;
266
+ }
267
+ this.logger?.debug("Request error, retrying", {}, res.error() as Error);
268
+ } catch (e) {
269
+ if (i >= this.urlsInits.length - 1) {
270
+ throw e;
271
+ }
272
+ this.logger?.debug("Request error, retrying", {}, e as Error);
273
+ }
274
+ }
275
+
276
+ throw Error("loop ended without return or rejection");
277
+ }
278
+
279
+ /**
280
+ * Send request to single URL, retry failed requests on same server
281
+ */
282
+ private async requestWithRetries<E extends Endpoint>(
283
+ definition: RouteDefinitionExtra<E>,
284
+ args: E["args"],
285
+ init: ApiRequestInitRequired
286
+ ): Promise<ApiResponse<E>> {
287
+ const {retries, retryDelay, signal} = init;
288
+ const routeId = definition.operationId;
289
+ const requestMethod = this.getRequestMethod(init);
290
+
291
+ return retry(
292
+ async (attempt) => {
293
+ const res = await requestMethod(definition, args, init);
294
+ if (!res.ok && attempt <= retries) {
295
+ throw res.error();
296
+ }
297
+ return res;
298
+ },
299
+ {
300
+ retries,
301
+ retryDelay,
302
+ // Local signal takes precedence over global signal
303
+ signal: signal ?? this.signal ?? undefined,
304
+ onRetry: (e, attempt) => {
305
+ this.logger?.debug("Retrying request", {routeId, attempt, lastError: e.message});
306
+ },
307
+ }
308
+ );
309
+ }
310
+
311
+ /**
312
+ * Send request to single URL, SSZ requests will be retried using JSON
313
+ * if a 415 error response is returned by the server. All subsequent requests
314
+ * to this server for the route will always be sent as JSON afterwards.
315
+ */
316
+ private async requestFallbackToJson<E extends Endpoint>(
317
+ definition: RouteDefinitionExtra<E>,
318
+ args: E["args"],
319
+ init: ApiRequestInitRequired
320
+ ): Promise<ApiResponse<E>> {
321
+ const {urlIndex} = init;
322
+ const routeId = definition.operationId;
323
+
324
+ const sszNotSupportedByRouteId = this.sszNotSupportedByRouteIdByUrlIndex.getOrDefault(urlIndex);
325
+ if (sszNotSupportedByRouteId.has(routeId)) {
326
+ init.requestWireFormat = WireFormat.json;
327
+ }
328
+
329
+ const res = await this._request(definition, args, init);
330
+
331
+ if (res.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE && init.requestWireFormat === WireFormat.ssz) {
332
+ this.logger?.debug("SSZ request failed with status 415, retrying using JSON", {routeId, urlIndex});
333
+
334
+ sszNotSupportedByRouteId.set(routeId, true);
335
+ init.requestWireFormat = WireFormat.json;
336
+
337
+ return this._request(definition, args, init);
338
+ }
339
+
340
+ return res;
341
+ }
342
+
343
+ /**
344
+ * Send request to single URL
345
+ */
346
+ private async _request<E extends Endpoint>(
347
+ definition: RouteDefinitionExtra<E>,
348
+ args: E["args"],
349
+ init: ApiRequestInitRequired
350
+ ): Promise<ApiResponse<E>> {
351
+ const abortSignals = [this.signal, init.signal];
352
+
353
+ // Implement fetch timeout
354
+ const controller = new AbortController();
355
+ const timeout = setTimeout(() => controller.abort(), init.timeoutMs);
356
+ init.signal = controller.signal;
357
+
358
+ // Attach global/local signal to this request's controller
359
+ const onSignalAbort = (): void => controller.abort();
360
+ for (const s of abortSignals) {
361
+ s?.addEventListener("abort", onSignalAbort);
362
+ }
363
+
364
+ const routeId = definition.operationId;
365
+ const {printableUrl, requestWireFormat, responseWireFormat} = init;
366
+ const timer = this.metrics?.requestTime.startTimer({routeId});
367
+
368
+ try {
369
+ this.logger?.debug("API request", {routeId, requestWireFormat, responseWireFormat});
370
+ const request = createApiRequest(definition, args, init);
371
+ const response = await this.fetch(request.url, request);
372
+ const apiResponse = new ApiResponse(definition, response.body, response);
373
+
374
+ if (!apiResponse.ok) {
375
+ await apiResponse.errorBody();
376
+ this.logger?.debug("API response error", {routeId, status: apiResponse.status});
377
+ this.metrics?.requestErrors.inc({routeId, baseUrl: printableUrl});
378
+ return apiResponse;
379
+ }
380
+
381
+ const streamTimer = this.metrics?.streamTime.startTimer({routeId});
382
+ try {
383
+ await apiResponse.rawBody();
384
+ this.logger?.debug("API response success", {
385
+ routeId,
386
+ status: apiResponse.status,
387
+ wireFormat: apiResponse.wireFormat(),
388
+ });
389
+ return apiResponse;
390
+ } finally {
391
+ streamTimer?.();
392
+ }
393
+ } catch (e) {
394
+ this.metrics?.requestErrors.inc({routeId, baseUrl: printableUrl});
395
+
396
+ if (isAbortedError(e)) {
397
+ if (abortSignals.some((s) => s?.aborted)) {
398
+ throw new ErrorAborted(`${routeId} request`);
399
+ }
400
+ if (controller.signal.aborted) {
401
+ throw new TimeoutError(`${routeId} request`);
402
+ }
403
+ throw Error("Unknown aborted error");
404
+ }
405
+ throw e;
406
+ } finally {
407
+ timer?.();
408
+
409
+ clearTimeout(timeout);
410
+ for (const s of abortSignals) {
411
+ s?.removeEventListener("abort", onSignalAbort);
412
+ }
413
+ }
414
+ }
415
+
416
+ private getRequestMethod(init: ApiRequestInitRequired): typeof this._request {
417
+ return init.requestWireFormat === WireFormat.ssz ? this.requestFallbackToJson.bind(this) : this._request.bind(this);
418
+ }
419
+ }
420
+
421
+ function mergeInits<E extends Endpoint>(
422
+ definition: RouteDefinitionExtra<E>,
423
+ urlInit: UrlInitRequired,
424
+ localInit: ApiRequestInit
425
+ ): ApiRequestInitRequired {
426
+ return {
427
+ ...defaultInit,
428
+ ...definition.init,
429
+ // Sanitize user provided values
430
+ ...removeUndefined(urlInit),
431
+ ...removeUndefined(localInit),
432
+ headers: mergeHeaders(urlInit.headers, localInit.headers),
433
+ };
434
+ }
435
+
436
+ function removeUndefined<T extends object>(obj: T): {[K in keyof T]: Exclude<T[K], undefined>} {
437
+ return Object.fromEntries(Object.entries(obj).filter(([_, v]) => v !== undefined)) as {
438
+ [K in keyof T]: Exclude<T[K], undefined>;
439
+ };
440
+ }
441
+
442
+ function isAbortedError(e: unknown): boolean {
443
+ return isFetchError(e) && e.type === "aborted";
444
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./error.js";
2
+ export * from "./httpClient.js";
3
+ export * from "./method.js";
4
+ export * from "./metrics.js";
5
+ export * from "./request.js";
6
+ export * from "./response.js";
@@ -0,0 +1,50 @@
1
+ import {mapValues} from "@lodestar/utils";
2
+ import {Endpoint, HasOnlyOptionalProps, RouteDefinition, RouteDefinitions} from "../types.js";
3
+ import {compileRouteUrlFormatter} from "../urlFormat.js";
4
+ import {IHttpClient} from "./httpClient.js";
5
+ import {ApiRequestInit} from "./request.js";
6
+ import {ApiResponse} from "./response.js";
7
+
8
+ export type ApiClientMethod<E extends Endpoint> = E["args"] extends void
9
+ ? (init?: ApiRequestInit) => Promise<ApiResponse<E>>
10
+ : HasOnlyOptionalProps<E["args"]> extends true
11
+ ? (args?: E["args"], init?: ApiRequestInit) => Promise<ApiResponse<E>>
12
+ : (args: E["args"], init?: ApiRequestInit) => Promise<ApiResponse<E>>;
13
+
14
+ export type ApiClientMethods<Es extends Record<string, Endpoint>> = {[K in keyof Es]: ApiClientMethod<Es[K]>};
15
+
16
+ export function createApiClientMethod<E extends Endpoint>(
17
+ definition: RouteDefinition<E>,
18
+ client: IHttpClient,
19
+ operationId: string
20
+ ): ApiClientMethod<E> {
21
+ const urlFormatter = compileRouteUrlFormatter(definition.url);
22
+ const definitionExtended = {
23
+ ...definition,
24
+ urlFormatter,
25
+ operationId,
26
+ };
27
+
28
+ // If the request args is void, then completely remove the args parameter
29
+ if (
30
+ definition.req.schema.body === undefined &&
31
+ definition.req.schema.params === undefined &&
32
+ definition.req.schema.query === undefined
33
+ ) {
34
+ return (async (init?: ApiRequestInit) => {
35
+ return client.request(definitionExtended, undefined, init);
36
+ }) as ApiClientMethod<E>;
37
+ }
38
+ return async (args?: E["args"], init?: ApiRequestInit) => {
39
+ return client.request(definitionExtended, args ?? {}, init);
40
+ };
41
+ }
42
+
43
+ export function createApiClientMethods<Es extends Record<string, Endpoint>>(
44
+ definitions: RouteDefinitions<Es>,
45
+ client: IHttpClient
46
+ ): ApiClientMethods<Es> {
47
+ return mapValues(definitions, (definition, operationId) => {
48
+ return createApiClientMethod(definition, client, operationId as string);
49
+ }) as unknown as ApiClientMethods<Es>;
50
+ }
@@ -0,0 +1,9 @@
1
+ import {Gauge, GaugeExtra, Histogram} from "@lodestar/utils";
2
+
3
+ export type Metrics = {
4
+ requestTime: Histogram<{routeId: string}>;
5
+ streamTime: Histogram<{routeId: string}>;
6
+ requestErrors: Gauge<{routeId: string; baseUrl: string}>;
7
+ requestToFallbacks: Gauge<{routeId: string; baseUrl: string}>;
8
+ urlsScore: GaugeExtra<{urlIndex: number; baseUrl: string}>;
9
+ };
@@ -0,0 +1,113 @@
1
+ import {HttpHeader, MediaType, mergeHeaders, setAuthorizationHeader} from "../headers.js";
2
+ import {
3
+ Endpoint,
4
+ JsonRequestMethods,
5
+ RequestWithBodyCodec,
6
+ RouteDefinition,
7
+ SszRequestMethods,
8
+ isRequestWithoutBody,
9
+ } from "../types.js";
10
+ import {WireFormat} from "../wireFormat.js";
11
+ import {stringifyQuery, urlJoin} from "./format.js";
12
+
13
+ export type ExtraRequestInit = {
14
+ /** Wire format to use in HTTP requests to server */
15
+ requestWireFormat?: `${WireFormat}`;
16
+ /** Preferred wire format for HTTP responses from server */
17
+ responseWireFormat?: `${WireFormat}`;
18
+ /** Timeout of requests in milliseconds */
19
+ timeoutMs?: number;
20
+ /** Number of retries per request */
21
+ retries?: number;
22
+ /** Retry delay, only relevant if retries > 0 */
23
+ retryDelay?: number;
24
+ };
25
+
26
+ export type OptionalRequestInit = {
27
+ bearerToken?: string;
28
+ };
29
+
30
+ export type UrlInit = ApiRequestInit & {baseUrl?: string};
31
+ export type UrlInitRequired = ApiRequestInit & {
32
+ urlIndex: number;
33
+ baseUrl: string;
34
+ /** Used in logs and metrics to prevent leaking user credentials */
35
+ printableUrl: string;
36
+ };
37
+ export type ApiRequestInit = ExtraRequestInit & OptionalRequestInit & RequestInit;
38
+ export type ApiRequestInitRequired = Required<ExtraRequestInit> & UrlInitRequired;
39
+
40
+ /** Route definition with computed extra properties */
41
+ export type RouteDefinitionExtra<E extends Endpoint> = RouteDefinition<E> & {
42
+ operationId: string;
43
+ urlFormatter: (args: Record<string, string | number>) => string;
44
+ };
45
+
46
+ export function createApiRequest<E extends Endpoint>(
47
+ definition: RouteDefinitionExtra<E>,
48
+ args: E["args"],
49
+ init: ApiRequestInitRequired
50
+ ): Request {
51
+ const headers = new Headers();
52
+
53
+ let req: E["request"];
54
+
55
+ if (isRequestWithoutBody(definition)) {
56
+ req = definition.req.writeReq(args);
57
+ } else {
58
+ const requestWireFormat = (definition.req as RequestWithBodyCodec<E>).onlySupport ?? init.requestWireFormat;
59
+ switch (requestWireFormat) {
60
+ case WireFormat.json:
61
+ req = (definition.req as JsonRequestMethods<E>).writeReqJson(args);
62
+ if (req.body) {
63
+ req.body = JSON.stringify(req.body);
64
+ headers.set(HttpHeader.ContentType, MediaType.json);
65
+ }
66
+ break;
67
+ case WireFormat.ssz:
68
+ req = (definition.req as SszRequestMethods<E>).writeReqSsz(args);
69
+ if (req.body) {
70
+ headers.set(HttpHeader.ContentType, MediaType.ssz);
71
+ }
72
+ break;
73
+ default:
74
+ throw Error(`Invalid requestWireFormat: ${requestWireFormat}`);
75
+ }
76
+ }
77
+ const queryString = req.query ? stringifyQuery(req.query) : "";
78
+ const url = new URL(
79
+ urlJoin(init.baseUrl, definition.urlFormatter(req.params ?? {})) + (queryString ? `?${queryString}` : "")
80
+ );
81
+ setAuthorizationHeader(url, headers, init);
82
+
83
+ if (definition.resp.isEmpty) {
84
+ // Do not set Accept header
85
+ } else if (definition.resp.onlySupport !== undefined) {
86
+ switch (definition.resp.onlySupport) {
87
+ case WireFormat.json:
88
+ headers.set(HttpHeader.Accept, MediaType.json);
89
+ break;
90
+ case WireFormat.ssz:
91
+ headers.set(HttpHeader.Accept, MediaType.ssz);
92
+ break;
93
+ }
94
+ } else {
95
+ switch (init.responseWireFormat) {
96
+ case WireFormat.json:
97
+ headers.set(HttpHeader.Accept, `${MediaType.json};q=1,${MediaType.ssz};q=0.9`);
98
+ break;
99
+ case WireFormat.ssz:
100
+ headers.set(HttpHeader.Accept, `${MediaType.ssz};q=1,${MediaType.json};q=0.9`);
101
+ break;
102
+ default:
103
+ throw Error(`Invalid responseWireFormat: ${init.responseWireFormat}`);
104
+ }
105
+ }
106
+
107
+ return new Request(url, {
108
+ ...init,
109
+ method: definition.method,
110
+ headers: mergeHeaders(headers, req.headers, init.headers),
111
+ body: req.body as BodyInit,
112
+ });
113
+ }