From aded4181e0c8030634e8ba1bcc380ba516e4d6c3 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 30 Jan 2025 15:30:53 -0700 Subject: [PATCH] Add ability to create RPC service chain We want to update the `fetch` middleware in `eth-json-rpc-middleware` and the Infura middleware in `eth-json-rpc-infura` to automatically fail over to alternate RPC endpoints when the desired endpoint is down. To do this, we need a way to string together a collection of RPC services, where the first service represents the primary endpoint, the second service is failover for the first, the third service is a failover for the second, etc. This composite object should conform to the same interface as an RPC service so consumers do not have to care whether they are working with a single endpoint or a chain of them. Also expose `AbstractRpcService` so that we can make use of it in the middleware packages. --- packages/network-controller/CHANGELOG.md | 4 + packages/network-controller/src/index.ts | 1 + .../src/rpc-service/rpc-service-chain.test.ts | 684 ++++++++++++++++++ .../src/rpc-service/rpc-service-chain.ts | 209 ++++++ 4 files changed, 898 insertions(+) create mode 100644 packages/network-controller/src/rpc-service/rpc-service-chain.test.ts create mode 100644 packages/network-controller/src/rpc-service/rpc-service-chain.ts diff --git a/packages/network-controller/CHANGELOG.md b/packages/network-controller/CHANGELOG.md index 15cf6bbd000..7c6d6d069dc 100644 --- a/packages/network-controller/CHANGELOG.md +++ b/packages/network-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AbstractRpcService`, the interface for all RPC services ([#5226](https://github.com/MetaMask/core/pull/5226)) + ### Changed - Bump `@metamask/base-controller` from `^7.0.0` to `^7.1.0` ([#5079](https://github.com/MetaMask/core/pull/5079)) diff --git a/packages/network-controller/src/index.ts b/packages/network-controller/src/index.ts index 51e17ce1084..3ec1ff120dc 100644 --- a/packages/network-controller/src/index.ts +++ b/packages/network-controller/src/index.ts @@ -52,3 +52,4 @@ export type { } from './types'; export { NetworkClientType } from './types'; export type { NetworkClient } from './create-network-client'; +export type { AbstractRpcService } from './rpc-service/abstract-rpc-service'; diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts new file mode 100644 index 00000000000..c7af19afa92 --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.test.ts @@ -0,0 +1,684 @@ +import nock from 'nock'; +import { useFakeTimers } from 'sinon'; +import type { SinonFakeTimers } from 'sinon'; + +import { RpcServiceChain } from './rpc-service-chain'; + +describe('RpcServiceChain', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('onRetry', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://rpc.example.chain', + }, + ], + }); + + const onRetryListener = rpcServiceChain.onRetry(() => { + // do whatever + }); + expect(onRetryListener.dispose()).toBeUndefined(); + }); + }); + + describe('onBreak', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://rpc.example.chain', + }, + ], + }); + + const onBreakListener = rpcServiceChain.onBreak(() => { + // do whatever + }); + expect(onBreakListener.dispose()).toBeUndefined(); + }); + }); + + describe('onDegraded', () => { + it('returns a listener which can be disposed', () => { + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://rpc.example.chain', + }, + ], + }); + + const onDegradedListener = rpcServiceChain.onDegraded(() => { + // do whatever + }); + expect(onDegradedListener.dispose()).toBeUndefined(); + }); + }); + + describe('request', () => { + it('returns what the first RPC service in the chain returns, if it succeeds', async () => { + nock('https://first.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + }, + ], + }); + + const response = await rpcServiceChain.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + }); + + it('uses the other RPC services in the chain as failovers', async () => { + nock('https://first.chain') + .post( + '/', + { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }, + { + reqheaders: {}, + }, + ) + .times(15) + .reply(503); + nock('https://second.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://third.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + }, + ], + }); + rpcServiceChain.onRetry(() => { + // We don't need to await this promise; adding it to the promise + // queue is enough to continue. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.nextAsync(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be retried, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit. This is finally a success. + const response = await rpcServiceChain.request(jsonRpcRequest); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + }); + + it("allows each RPC service's fetch options to be configured separately, yet passes the fetch options given to request to all of them", async () => { + const firstEndpointScope = nock('https://first.chain', { + reqheaders: { + 'X-Fizz': 'Buzz', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + const secondEndpointScope = nock('https://second.chain', { + reqheaders: { + 'X-Foo': 'Bar', + 'X-Fizz': 'Buzz', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + const thirdEndpointScope = nock('https://third.chain', { + reqheaders: { + 'X-Foo': 'Bar', + 'X-Fizz': 'Buzz', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + fetchOptions: { + referrer: 'https://some.referrer', + }, + }, + ], + }); + rpcServiceChain.onRetry(() => { + // We don't need to await this promise; adding it to the promise + // queue is enough to continue. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.nextAsync(); + }); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + const fetchOptions = { + headers: { + 'X-Fizz': 'Buzz', + }, + }; + // Retry the first endpoint until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow('Gateway timeout'); + // Retry the first endpoint again, until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow('Gateway timeout'); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be retried, until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow('Gateway timeout'); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + await expect( + rpcServiceChain.request(jsonRpcRequest, fetchOptions), + ).rejects.toThrow('Gateway timeout'); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit. This is finally a success. + await rpcServiceChain.request(jsonRpcRequest, fetchOptions); + + expect(firstEndpointScope.isDone()).toBe(true); + expect(secondEndpointScope.isDone()).toBe(true); + expect(thirdEndpointScope.isDone()).toBe(true); + }); + + it('calls onRetry each time an RPC service in the chain retries its request', async () => { + nock('https://first.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://second.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://third.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + }, + ], + }); + const onRetryListener = jest.fn< + ReturnType[0]>, + Parameters[0]> + >(() => { + // We don't need to await this promise; adding it to the promise + // queue is enough to continue. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.nextAsync(); + }); + rpcServiceChain.onRetry(onRetryListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be retried, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit. This is finally a success. + await rpcServiceChain.request(jsonRpcRequest); + + const onRetryListenerCallCountsByEndpointUrl = + onRetryListener.mock.calls.reduce( + (memo, call) => { + const { endpointUrl } = call[0]; + // There is nothing wrong with this. + // eslint-disable-next-line jest/no-conditional-in-test + memo[endpointUrl] = (memo[endpointUrl] ?? 0) + 1; + return memo; + }, + {} as Record, + ); + + expect(onRetryListenerCallCountsByEndpointUrl).toStrictEqual({ + 'https://first.chain/': 12, + 'https://second.chain/': 12, + }); + }); + + it('calls onBreak each time the underlying circuit for each RPC service in the chain breaks', async () => { + nock('https://first.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://second.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://third.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: 'ok', + }); + + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + }, + ], + }); + const onBreakListener = jest.fn< + ReturnType[0]>, + Parameters[0]> + >(); + rpcServiceChain.onRetry(() => { + // We don't need to await this promise; adding it to the promise + // queue is enough to continue. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.nextAsync(); + }); + rpcServiceChain.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be retried, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit. This is finally a success. + await rpcServiceChain.request(jsonRpcRequest); + + expect(onBreakListener).toHaveBeenCalledTimes(2); + expect(onBreakListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + endpointUrl: 'https://first.chain/', + }), + ); + expect(onBreakListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + endpointUrl: 'https://second.chain/', + }), + ); + }); + + it('calls onDegraded each time an RPC service in the chain gives up before the circuit breaks or responds successfully but slowly', async () => { + nock('https://first.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://second.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(503); + nock('https://third.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, () => { + clock.tick(6000); + return { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }; + }); + + const rpcServiceChain = new RpcServiceChain({ + fetch, + btoa, + serviceConfigurations: [ + { + endpointUrl: 'https://first.chain', + }, + { + endpointUrl: 'https://second.chain', + fetchOptions: { + headers: { + 'X-Foo': 'Bar', + }, + }, + }, + { + endpointUrl: 'https://third.chain', + }, + ], + }); + const onDegradedListener = jest.fn< + ReturnType[0]>, + Parameters[0]> + >(); + rpcServiceChain.onRetry(() => { + // We don't need to await this promise; adding it to the promise + // queue is enough to continue. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + clock.nextAsync(); + }); + rpcServiceChain.onDegraded(onDegradedListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + // Retry the first endpoint until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Retry the first endpoint again, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Retry the first endpoint for a third time, until max retries is hit. + // The circuit will break on the last time, and the second endpoint will + // be retried, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + await expect(rpcServiceChain.request(jsonRpcRequest)).rejects.toThrow( + 'Gateway timeout', + ); + // Try the first endpoint, see that the circuit is broken, and retry the + // second endpoint, until max retries is hit. + // The circuit will break on the last time, and the third endpoint will + // be hit. This is finally a success. + await rpcServiceChain.request(jsonRpcRequest); + + const onDegradedListenerCallCountsByEndpointUrl = + onDegradedListener.mock.calls.reduce( + (memo, call) => { + const { endpointUrl } = call[0]; + // There is nothing wrong with this. + // eslint-disable-next-line jest/no-conditional-in-test + memo[endpointUrl] = (memo[endpointUrl] ?? 0) + 1; + return memo; + }, + {} as Record, + ); + + expect(onDegradedListenerCallCountsByEndpointUrl).toStrictEqual({ + 'https://first.chain/': 2, + 'https://second.chain/': 2, + 'https://third.chain/': 1, + }); + }); + }); +}); diff --git a/packages/network-controller/src/rpc-service/rpc-service-chain.ts b/packages/network-controller/src/rpc-service/rpc-service-chain.ts new file mode 100644 index 00000000000..0431c83a9be --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service-chain.ts @@ -0,0 +1,209 @@ +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import type { AbstractRpcService } from './abstract-rpc-service'; +import { RpcService } from './rpc-service'; +import type { FetchOptions } from './shared'; + +/** + * The subset of options accepted by the RpcServiceChain constructor which + * represent a single endpoint. + */ +type RpcServiceConfiguration = { + /** + * The URL of the endpoint. + */ + endpointUrl: URL | string; + /** + * The options to pass to `fetch` when making the request to the endpoint. + */ + fetchOptions?: FetchOptions; +}; + +/** + * This class constructs a chain of RpcService objects which represent a + * particular network. The first object in the chain is intended to the primary + * way of reaching the network and the remaining objects are used as failovers. + */ +export class RpcServiceChain implements AbstractRpcService { + readonly #services: RpcService[]; + + /** + * Constructs a new RpcServiceChain object. + * + * @param args - The arguments. + * @param args.fetch - A function that can be used to make an HTTP request. + * If your JavaScript environment supports `fetch` natively, you'll probably + * want to pass that; otherwise you can pass an equivalent (such as `fetch` + * via `node-fetch`). + * @param args.btoa - A function that can be used to convert a binary string + * into base-64. Used to encode authorization credentials. + * @param args.serviceConfigurations - The options for the RPC services that + * you want to construct. This class takes a set of configuration objects and + * not literal `RpcService`s to account for the possibility that we may want + * to send request headers to official Infura endpoints and not failovers. + */ + constructor({ + fetch: givenFetch, + btoa: givenBtoa, + serviceConfigurations, + }: { + fetch: typeof fetch; + btoa: typeof btoa; + serviceConfigurations: RpcServiceConfiguration[]; + }) { + this.#services = this.#buildRpcServiceChain({ + serviceConfigurations, + fetch: givenFetch, + btoa: givenBtoa, + }); + } + + /** + * Listens for when any of the RPC services retry a request. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link RpcService.onRetry} returns. + */ + onRetry(listener: Parameters[0]) { + const disposables = this.#services.map((service) => + service.onRetry(listener), + ); + + return { + dispose() { + disposables.forEach((disposable) => disposable.dispose()); + }, + }; + } + + /** + * Listens for when any of the RPC services retry the request too many times + * in a row. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link RpcService.onBreak} returns. + */ + onBreak(listener: Parameters[0]) { + const disposables = this.#services.map((service) => + service.onBreak(listener), + ); + + return { + dispose() { + disposables.forEach((disposable) => disposable.dispose()); + }, + }; + } + + /** + * Listens for when any of the RPC services send a slow request. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link RpcService.onRetry} returns. + */ + onDegraded(listener: Parameters[0]) { + const disposables = this.#services.map((service) => + service.onDegraded(listener), + ); + + return { + dispose() { + disposables.forEach((disposable) => disposable.dispose()); + }, + }; + } + + /** + * Makes a request to the first RPC service in the chain. If this service is + * down, then the request is forwarded to the next service in the chain, etc. + * + * This overload is specifically designed for `eth_getBlockByNumber`, which + * can return a `result` of `null` despite an expected `Result` being + * provided. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws A "method not found" error if the response status is 405. + * @throws A rate limiting error if the response HTTP status is 429. + * @throws A timeout error if the response HTTP status is 503 or 504. + * @throws A generic error if the response HTTP status is not 2xx but also not + * 405, 429, 503, or 504. + */ + async request( + jsonRpcRequest: JsonRpcRequest & { method: 'eth_getBlockByNumber' }, + fetchOptions?: FetchOptions, + ): Promise | JsonRpcResponse>; + + /** + * Makes a request to the first RPC service in the chain. If this service is + * down, then the request is forwarded to the next service in the chain, etc. + * + * This overload is designed for all RPC methods except for + * `eth_getBlockByNumber`, which are expected to return a `result` of the + * expected `Result`. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - An options bag for {@link fetch} which further + * specifies the request. + * @returns The decoded JSON-RPC response from the endpoint. + * @throws A "method not found" error if the response status is 405. + * @throws A rate limiting error if the response HTTP status is 429. + * @throws A timeout error if the response HTTP status is 503 or 504. + * @throws A generic error if the response HTTP status is not 2xx but also not + * 405, 429, 503, or 504. + */ + async request( + jsonRpcRequest: JsonRpcRequest, + fetchOptions?: FetchOptions, + ): Promise>; + + async request( + jsonRpcRequest: JsonRpcRequest, + fetchOptions: FetchOptions = {}, + ): Promise> { + return this.#services[0].request(jsonRpcRequest, fetchOptions); + } + + /** + * Constructs the chain of RPC services. The second RPC service is + * configured as the failover for the first, the third service is + * configured as the failover for the second, etc. + * + * @param args - The arguments. + * @param args.serviceConfigurations - The options for the RPC services that + * you want to construct. + * @param args.fetch - A function that can be used to make an HTTP request. + * @param args.btoa - A function that can be used to convert a binary string + * into base-64. Used to encode authorization credentials. + * @returns The constructed chain of RPC services. + */ + #buildRpcServiceChain({ + serviceConfigurations, + fetch: givenFetch, + btoa: givenBtoa, + }: { + serviceConfigurations: RpcServiceConfiguration[]; + fetch: typeof fetch; + btoa: typeof btoa; + }): RpcService[] { + return [...serviceConfigurations] + .reverse() + .reduce((workingServices, serviceConfiguration, index) => { + const failoverService = index > 0 ? workingServices[0] : undefined; + const service = new RpcService({ + fetch: givenFetch, + btoa: givenBtoa, + ...serviceConfiguration, + failoverService, + }); + return [service, ...workingServices]; + }, [] as RpcService[]); + } +}