Skip to content

Commit f532124

Browse files
committed
feat: Port HTTP provider
- Ported existing HTTP provider functionality to v-next. - Updated code to support ESM, follow modern TypeScript practices, and use hardhat-utils. - Added tests for request and sendBatch.
1 parent 7a96676 commit f532124

File tree

7 files changed

+1167
-1
lines changed

7 files changed

+1167
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { ProviderRpcError } from "../../types/providers.js";
2+
3+
import { CustomError } from "@ignored/hardhat-vnext-utils/error";
4+
import { isObject } from "@ignored/hardhat-vnext-utils/lang";
5+
6+
const IS_PROVIDER_ERROR_PROPERTY_NAME = "_isProviderError";
7+
8+
/**
9+
* The error codes that a provider can return.
10+
* See https://eips.ethereum.org/EIPS/eip-1474#error-codes
11+
*/
12+
export enum ProviderErrorCode {
13+
LIMIT_EXCEEDED = -32005,
14+
INVALID_PARAMS = -32602,
15+
}
16+
17+
type ProviderErrorMessages = {
18+
[key in ProviderErrorCode]: string;
19+
};
20+
21+
/**
22+
* The error messages associated with each error code.
23+
*/
24+
const ProviderErrorMessage: ProviderErrorMessages = {
25+
[ProviderErrorCode.LIMIT_EXCEEDED]: "Request exceeds defined limit",
26+
[ProviderErrorCode.INVALID_PARAMS]: "Invalid method parameters",
27+
};
28+
29+
export class ProviderError extends CustomError implements ProviderRpcError {
30+
public code: number;
31+
public data?: unknown;
32+
33+
constructor(code: ProviderErrorCode, parentError?: Error) {
34+
super(ProviderErrorMessage[code], parentError);
35+
this.code = code;
36+
37+
Object.defineProperty(this, IS_PROVIDER_ERROR_PROPERTY_NAME, {
38+
configurable: false,
39+
enumerable: false,
40+
writable: false,
41+
value: true,
42+
});
43+
}
44+
45+
public static isProviderError(other: unknown): other is ProviderError {
46+
if (!isObject(other)) {
47+
return false;
48+
}
49+
50+
const isProviderErrorProperty = Object.getOwnPropertyDescriptor(
51+
other,
52+
IS_PROVIDER_ERROR_PROPERTY_NAME,
53+
);
54+
55+
return isProviderErrorProperty?.value === true;
56+
}
57+
}
+286
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import type {
2+
EIP1193Provider,
3+
RequestArguments,
4+
} from "../../types/providers.js";
5+
import type {
6+
JsonRpcResponse,
7+
JsonRpcRequest,
8+
SuccessfulJsonRpcResponse,
9+
} from "../utils/json-rpc.js";
10+
import type {
11+
Dispatcher,
12+
RequestOptions,
13+
} from "@ignored/hardhat-vnext-utils/request";
14+
15+
import EventEmitter from "node:events";
16+
17+
import { HardhatError } from "@ignored/hardhat-vnext-errors";
18+
import { delay, isObject } from "@ignored/hardhat-vnext-utils/lang";
19+
import {
20+
getDispatcher,
21+
isValidUrl,
22+
postJsonRequest,
23+
shouldUseProxy,
24+
ConnectionRefusedError,
25+
RequestTimeoutError,
26+
ResponseStatusCodeError,
27+
} from "@ignored/hardhat-vnext-utils/request";
28+
29+
import {
30+
getJsonRpcRequest,
31+
isFailedJsonRpcResponse,
32+
parseJsonRpcResponse,
33+
} from "../utils/json-rpc.js";
34+
import { getHardhatVersion } from "../utils/package.js";
35+
36+
import { ProviderError, ProviderErrorCode } from "./errors.js";
37+
38+
const TOO_MANY_REQUEST_STATUS = 429;
39+
const MAX_RETRIES = 6;
40+
const MAX_RETRY_WAIT_TIME_SECONDS = 5;
41+
42+
export class HttpProvider extends EventEmitter implements EIP1193Provider {
43+
readonly #url: string;
44+
readonly #networkName: string;
45+
readonly #extraHeaders: Record<string, string>;
46+
readonly #dispatcher: Dispatcher;
47+
#nextRequestId = 1;
48+
49+
/**
50+
* Creates a new instance of `HttpProvider`.
51+
*
52+
* @param url
53+
* @param networkName
54+
* @param extraHeaders
55+
* @param timeout
56+
* @returns
57+
*/
58+
public static async create(
59+
url: string,
60+
networkName: string,
61+
extraHeaders: Record<string, string> = {},
62+
timeout?: number,
63+
): Promise<HttpProvider> {
64+
if (!isValidUrl(url)) {
65+
throw new HardhatError(HardhatError.ERRORS.NETWORK.INVALID_URL, {
66+
value: url,
67+
});
68+
}
69+
70+
const dispatcher = await getHttpDispatcher(url, timeout);
71+
72+
const httpProvider = new HttpProvider(
73+
url,
74+
networkName,
75+
extraHeaders,
76+
dispatcher,
77+
);
78+
79+
return httpProvider;
80+
}
81+
82+
/**
83+
* @private
84+
*
85+
* This constructor is intended for internal use only.
86+
* Use the static method {@link HttpProvider.create} to create an instance of
87+
* `HttpProvider`.
88+
*/
89+
constructor(
90+
url: string,
91+
networkName: string,
92+
extraHeaders: Record<string, string>,
93+
dispatcher: Dispatcher,
94+
) {
95+
super();
96+
97+
this.#url = url;
98+
this.#networkName = networkName;
99+
this.#extraHeaders = extraHeaders;
100+
this.#dispatcher = dispatcher;
101+
}
102+
103+
public async request({ method, params }: RequestArguments): Promise<unknown> {
104+
const jsonRpcRequest = getJsonRpcRequest(
105+
this.#nextRequestId++,
106+
method,
107+
params,
108+
);
109+
const jsonRpcResponse = await this.#fetchJsonRpcResponse(jsonRpcRequest);
110+
111+
if (isFailedJsonRpcResponse(jsonRpcResponse)) {
112+
const error = new ProviderError(jsonRpcResponse.error.code);
113+
error.data = jsonRpcResponse.error.data;
114+
115+
// eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError
116+
throw error;
117+
}
118+
119+
return jsonRpcResponse.result;
120+
}
121+
122+
public async sendBatch(batch: RequestArguments[]): Promise<unknown[]> {
123+
const requests = batch.map(({ method, params }) =>
124+
getJsonRpcRequest(this.#nextRequestId++, method, params),
125+
);
126+
127+
const jsonRpcResponses = await this.#fetchJsonRpcResponse(requests);
128+
129+
const successfulJsonRpcResponses: SuccessfulJsonRpcResponse[] = [];
130+
for (const response of jsonRpcResponses) {
131+
if (isFailedJsonRpcResponse(response)) {
132+
const error = new ProviderError(response.error.code);
133+
error.data = response.error.data;
134+
135+
// eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError
136+
throw error;
137+
} else {
138+
successfulJsonRpcResponses.push(response);
139+
}
140+
}
141+
142+
const sortedResponses = successfulJsonRpcResponses.sort((a, b) =>
143+
`${a.id}`.localeCompare(`${b.id}`, undefined, { numeric: true }),
144+
);
145+
146+
return sortedResponses;
147+
}
148+
149+
async #fetchJsonRpcResponse(
150+
jsonRpcRequest: JsonRpcRequest,
151+
retryCount?: number,
152+
): Promise<JsonRpcResponse>;
153+
async #fetchJsonRpcResponse(
154+
jsonRpcRequest: JsonRpcRequest[],
155+
retryCount?: number,
156+
): Promise<JsonRpcResponse[]>;
157+
async #fetchJsonRpcResponse(
158+
jsonRpcRequest: JsonRpcRequest | JsonRpcRequest[],
159+
retryCount?: number,
160+
): Promise<JsonRpcResponse | JsonRpcResponse[]>;
161+
async #fetchJsonRpcResponse(
162+
jsonRpcRequest: JsonRpcRequest | JsonRpcRequest[],
163+
retryCount = 0,
164+
): Promise<JsonRpcResponse | JsonRpcResponse[]> {
165+
const requestOptions: RequestOptions = {
166+
extraHeaders: {
167+
"User-Agent": `Hardhat ${await getHardhatVersion()}`,
168+
...this.#extraHeaders,
169+
},
170+
};
171+
172+
let response;
173+
try {
174+
response = await postJsonRequest(
175+
this.#url,
176+
jsonRpcRequest,
177+
requestOptions,
178+
this.#dispatcher,
179+
);
180+
} catch (e) {
181+
if (e instanceof ConnectionRefusedError) {
182+
throw new HardhatError(
183+
HardhatError.ERRORS.NETWORK.CONNECTION_REFUSED,
184+
{ network: this.#networkName },
185+
e,
186+
);
187+
}
188+
189+
if (e instanceof RequestTimeoutError) {
190+
throw new HardhatError(HardhatError.ERRORS.NETWORK.NETWORK_TIMEOUT, e);
191+
}
192+
193+
/**
194+
* Nodes can have a rate limit mechanism to avoid abuse. This logic checks
195+
* if the response indicates a rate limit has been reached and retries the
196+
* request after the specified time.
197+
*/
198+
if (
199+
e instanceof ResponseStatusCodeError &&
200+
e.statusCode === TOO_MANY_REQUEST_STATUS
201+
) {
202+
const retryAfterHeader =
203+
isObject(e.headers) && typeof e.headers["retry-after"] === "string"
204+
? e.headers["retry-after"]
205+
: undefined;
206+
const retryAfterSeconds = this.#getRetryAfterSeconds(
207+
retryAfterHeader,
208+
retryCount,
209+
);
210+
if (this.#shouldRetryRequest(retryAfterSeconds, retryCount)) {
211+
return this.#retry(jsonRpcRequest, retryAfterSeconds, retryCount);
212+
}
213+
214+
const error = new ProviderError(ProviderErrorCode.LIMIT_EXCEEDED);
215+
error.data = {
216+
hostname: new URL(this.#url).hostname,
217+
retryAfterSeconds,
218+
};
219+
220+
// eslint-disable-next-line no-restricted-syntax -- allow throwing ProviderError
221+
throw error;
222+
}
223+
224+
throw e;
225+
}
226+
227+
return parseJsonRpcResponse(await response.body.text());
228+
}
229+
230+
#getRetryAfterSeconds(
231+
retryAfterHeader: string | undefined,
232+
retryCount: number,
233+
) {
234+
const parsedRetryAfter = parseInt(`${retryAfterHeader}`, 10);
235+
if (isNaN(parsedRetryAfter)) {
236+
// use an exponential backoff if the retry-after header can't be parsed
237+
return Math.min(2 ** retryCount, MAX_RETRY_WAIT_TIME_SECONDS);
238+
}
239+
240+
return parsedRetryAfter;
241+
}
242+
243+
#shouldRetryRequest(retryAfterSeconds: number, retryCount: number) {
244+
if (retryCount > MAX_RETRIES) {
245+
return false;
246+
}
247+
248+
if (retryAfterSeconds > MAX_RETRY_WAIT_TIME_SECONDS) {
249+
return false;
250+
}
251+
252+
return true;
253+
}
254+
255+
async #retry(
256+
request: JsonRpcRequest | JsonRpcRequest[],
257+
retryAfterSeconds: number,
258+
retryCount: number,
259+
) {
260+
await delay(retryAfterSeconds);
261+
return this.#fetchJsonRpcResponse(request, retryCount + 1);
262+
}
263+
}
264+
265+
/**
266+
* Gets either a pool or proxy dispatcher depending on the URL and the
267+
* environment variable `http_proxy`. This function is used internally by
268+
* `HttpProvider.create` and should not be used directly.
269+
*/
270+
export async function getHttpDispatcher(
271+
url: string,
272+
timeout?: number,
273+
): Promise<Dispatcher> {
274+
let dispatcher: Dispatcher;
275+
276+
if (process.env.http_proxy !== undefined && shouldUseProxy(url)) {
277+
dispatcher = await getDispatcher(url, {
278+
proxy: process.env.http_proxy,
279+
timeout,
280+
});
281+
} else {
282+
dispatcher = await getDispatcher(url, { pool: true, timeout });
283+
}
284+
285+
return dispatcher;
286+
}

0 commit comments

Comments
 (0)