@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.
- package/lib/beacon/client/beacon.d.ts.map +1 -0
- package/lib/beacon/client/config.d.ts.map +1 -0
- package/lib/beacon/client/debug.d.ts.map +1 -0
- package/lib/beacon/client/events.d.ts.map +1 -0
- package/lib/beacon/client/index.d.ts.map +1 -0
- package/lib/beacon/client/index.js.map +1 -1
- package/lib/beacon/client/lightclient.d.ts.map +1 -0
- package/lib/beacon/client/lodestar.d.ts.map +1 -0
- package/lib/beacon/client/node.d.ts.map +1 -0
- package/lib/beacon/client/proof.d.ts.map +1 -0
- package/lib/beacon/client/validator.d.ts.map +1 -0
- package/lib/beacon/index.d.ts +1 -1
- package/lib/beacon/index.d.ts.map +1 -0
- package/lib/beacon/index.js.map +1 -1
- package/lib/beacon/routes/beacon/block.d.ts.map +1 -0
- package/lib/beacon/routes/beacon/index.d.ts +3 -3
- package/lib/beacon/routes/beacon/index.d.ts.map +1 -0
- package/lib/beacon/routes/beacon/index.js.map +1 -1
- package/lib/beacon/routes/beacon/pool.d.ts +1 -1
- package/lib/beacon/routes/beacon/pool.d.ts.map +1 -0
- package/lib/beacon/routes/beacon/rewards.d.ts.map +1 -0
- package/lib/beacon/routes/beacon/rewards.js.map +1 -1
- package/lib/beacon/routes/beacon/state.d.ts +2 -2
- package/lib/beacon/routes/beacon/state.d.ts.map +1 -0
- package/lib/beacon/routes/config.d.ts +1 -1
- package/lib/beacon/routes/config.d.ts.map +1 -0
- package/lib/beacon/routes/debug.d.ts.map +1 -0
- package/lib/beacon/routes/events.d.ts.map +1 -0
- package/lib/beacon/routes/events.js.map +1 -1
- package/lib/beacon/routes/index.d.ts.map +1 -0
- package/lib/beacon/routes/index.js.map +1 -1
- package/lib/beacon/routes/lightclient.d.ts.map +1 -0
- package/lib/beacon/routes/lodestar.d.ts.map +1 -0
- package/lib/beacon/routes/node.d.ts.map +1 -0
- package/lib/beacon/routes/proof.d.ts.map +1 -0
- package/lib/beacon/routes/validator.d.ts +1 -1
- package/lib/beacon/routes/validator.d.ts.map +1 -0
- package/lib/beacon/server/beacon.d.ts.map +1 -0
- package/lib/beacon/server/config.d.ts.map +1 -0
- package/lib/beacon/server/debug.d.ts.map +1 -0
- package/lib/beacon/server/events.d.ts.map +1 -0
- package/lib/beacon/server/index.d.ts +1 -1
- package/lib/beacon/server/index.d.ts.map +1 -0
- package/lib/beacon/server/index.js.map +1 -1
- package/lib/beacon/server/lightclient.d.ts.map +1 -0
- package/lib/beacon/server/lodestar.d.ts.map +1 -0
- package/lib/beacon/server/node.d.ts.map +1 -0
- package/lib/beacon/server/proof.d.ts.map +1 -0
- package/lib/beacon/server/validator.d.ts.map +1 -0
- package/lib/builder/client.d.ts.map +1 -0
- package/lib/builder/index.d.ts.map +1 -0
- package/lib/builder/index.js.map +1 -1
- package/lib/builder/routes.d.ts.map +1 -0
- package/lib/builder/routes.js.map +1 -1
- package/lib/builder/server/index.d.ts +1 -1
- package/lib/builder/server/index.d.ts.map +1 -0
- package/lib/index.d.ts +6 -6
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +3 -3
- package/lib/index.js.map +1 -1
- package/lib/keymanager/client.d.ts.map +1 -0
- package/lib/keymanager/index.d.ts +2 -2
- package/lib/keymanager/index.d.ts.map +1 -0
- package/lib/keymanager/index.js +1 -2
- package/lib/keymanager/index.js.map +1 -1
- package/lib/keymanager/routes.d.ts.map +1 -0
- package/lib/keymanager/server/index.d.ts +1 -1
- package/lib/keymanager/server/index.d.ts.map +1 -0
- package/lib/server/index.d.ts.map +1 -0
- package/lib/utils/client/error.d.ts.map +1 -0
- package/lib/utils/client/error.js +2 -0
- package/lib/utils/client/error.js.map +1 -1
- package/lib/utils/client/eventSource.d.ts.map +1 -0
- package/lib/utils/client/format.d.ts.map +1 -0
- package/lib/utils/client/httpClient.d.ts +1 -2
- package/lib/utils/client/httpClient.d.ts.map +1 -0
- package/lib/utils/client/httpClient.js +13 -9
- package/lib/utils/client/httpClient.js.map +1 -1
- package/lib/utils/client/index.d.ts +1 -1
- package/lib/utils/client/index.d.ts.map +1 -0
- package/lib/utils/client/index.js +1 -1
- package/lib/utils/client/index.js.map +1 -1
- package/lib/utils/client/method.d.ts.map +1 -0
- package/lib/utils/client/metrics.d.ts.map +1 -0
- package/lib/utils/client/request.d.ts.map +1 -0
- package/lib/utils/client/response.d.ts.map +1 -0
- package/lib/utils/client/response.js +6 -0
- package/lib/utils/client/response.js.map +1 -1
- package/lib/utils/codecs.d.ts.map +1 -0
- package/lib/utils/fork.d.ts.map +1 -0
- package/lib/utils/headers.d.ts.map +1 -0
- package/lib/utils/httpStatusCode.d.ts.map +1 -0
- package/lib/utils/index.d.ts.map +1 -0
- package/lib/utils/metadata.d.ts.map +1 -0
- package/lib/utils/schema.d.ts.map +1 -0
- package/lib/utils/serdes.d.ts.map +1 -0
- package/lib/utils/server/error.d.ts.map +1 -0
- package/lib/utils/server/error.js +1 -0
- package/lib/utils/server/error.js.map +1 -1
- package/lib/utils/server/handler.d.ts.map +1 -0
- package/lib/utils/server/index.d.ts.map +1 -0
- package/lib/utils/server/method.d.ts.map +1 -0
- package/lib/utils/server/parser.d.ts.map +1 -0
- package/lib/utils/server/route.d.ts.map +1 -0
- package/lib/utils/server/route.js.map +1 -1
- package/lib/utils/types.d.ts.map +1 -0
- package/lib/utils/urlFormat.d.ts.map +1 -0
- package/lib/utils/wireFormat.d.ts.map +1 -0
- package/package.json +17 -11
- package/src/beacon/client/beacon.ts +12 -0
- package/src/beacon/client/config.ts +12 -0
- package/src/beacon/client/debug.ts +12 -0
- package/src/beacon/client/events.ts +69 -0
- package/src/beacon/client/index.ts +46 -0
- package/src/beacon/client/lightclient.ts +12 -0
- package/src/beacon/client/lodestar.ts +12 -0
- package/src/beacon/client/node.ts +12 -0
- package/src/beacon/client/proof.ts +12 -0
- package/src/beacon/client/validator.ts +12 -0
- package/src/beacon/index.ts +24 -0
- package/src/beacon/routes/beacon/block.ts +602 -0
- package/src/beacon/routes/beacon/index.ts +66 -0
- package/src/beacon/routes/beacon/pool.ts +503 -0
- package/src/beacon/routes/beacon/rewards.ts +216 -0
- package/src/beacon/routes/beacon/state.ts +588 -0
- package/src/beacon/routes/config.ts +114 -0
- package/src/beacon/routes/debug.ts +231 -0
- package/src/beacon/routes/events.ts +337 -0
- package/src/beacon/routes/index.ts +33 -0
- package/src/beacon/routes/lightclient.ts +241 -0
- package/src/beacon/routes/lodestar.ts +456 -0
- package/src/beacon/routes/node.ts +286 -0
- package/src/beacon/routes/proof.ts +79 -0
- package/src/beacon/routes/validator.ts +1014 -0
- package/src/beacon/server/beacon.ts +7 -0
- package/src/beacon/server/config.ts +7 -0
- package/src/beacon/server/debug.ts +7 -0
- package/src/beacon/server/events.ts +73 -0
- package/src/beacon/server/index.ts +55 -0
- package/src/beacon/server/lightclient.ts +7 -0
- package/src/beacon/server/lodestar.ts +7 -0
- package/src/beacon/server/node.ts +7 -0
- package/src/beacon/server/proof.ts +7 -0
- package/src/beacon/server/validator.ts +7 -0
- package/src/builder/client.ts +9 -0
- package/src/builder/index.ts +26 -0
- package/src/builder/routes.ts +227 -0
- package/src/builder/server/index.ts +19 -0
- package/src/index.ts +19 -0
- package/src/keymanager/client.ts +9 -0
- package/src/keymanager/index.ts +39 -0
- package/src/keymanager/routes.ts +699 -0
- package/src/keymanager/server/index.ts +19 -0
- package/src/server/index.ts +2 -0
- package/src/utils/client/error.ts +10 -0
- package/src/utils/client/eventSource.ts +7 -0
- package/src/utils/client/format.ts +22 -0
- package/src/utils/client/httpClient.ts +444 -0
- package/src/utils/client/index.ts +6 -0
- package/src/utils/client/method.ts +50 -0
- package/src/utils/client/metrics.ts +9 -0
- package/src/utils/client/request.ts +113 -0
- package/src/utils/client/response.ts +205 -0
- package/src/utils/codecs.ts +143 -0
- package/src/utils/fork.ts +44 -0
- package/src/utils/headers.ts +173 -0
- package/src/utils/httpStatusCode.ts +392 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/metadata.ts +170 -0
- package/src/utils/schema.ts +141 -0
- package/src/utils/serdes.ts +120 -0
- package/src/utils/server/error.ts +9 -0
- package/src/utils/server/handler.ts +149 -0
- package/src/utils/server/index.ts +5 -0
- package/src/utils/server/method.ts +38 -0
- package/src/utils/server/parser.ts +15 -0
- package/src/utils/server/route.ts +45 -0
- package/src/utils/types.ts +161 -0
- package/src/utils/urlFormat.ts +112 -0
- 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,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,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
|
+
}
|