From a5d7141fb10805044ee89eb234da8dea9dc18e6e Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 9 Jan 2025 13:19:29 -0700 Subject: [PATCH 1/4] Add NetworkController RPC service class This commit adds an RPC service class that will ultimately be used inside of middleware to make JSON-RPC requests to an RPC endpoint. It makes uses of `createServicePolicy`, added in a previous commit, to encapsulate the request code using the retry and circuit breaker policies. As this service class is designed to replace large parts of the fetch and Infura middleware, it customizes the service policy so that the request will be retried only when the network is perceived to be "down". This occurs when: - The `fetch` call throws a "Failed to fetch" error (or something similar to it; see code for the full list of variations) - The `fetch` call throws an ETIMEDOUT or ECONNRESET error - The response status is 503 or 504 - The response body is invalid JSON In contrast, the network is not perceived to be "down" if: - The `fetch` call throws an unexpected error (e.g. if the request options are invalid) - The response status is not 2xx, but is also not 503 or 504 - The response body is an unsuccessful JSON-RPC response - The response body is a successful, but empty, JSON-RPC response --- .../src/rpc-service/abstract-rpc-service.ts | 12 + .../src/rpc-service/rpc-service.test.ts | 890 ++++++++++++++++++ .../src/rpc-service/rpc-service.ts | 388 ++++++++ 3 files changed, 1290 insertions(+) create mode 100644 packages/network-controller/src/rpc-service/abstract-rpc-service.ts create mode 100644 packages/network-controller/src/rpc-service/rpc-service.test.ts create mode 100644 packages/network-controller/src/rpc-service/rpc-service.ts diff --git a/packages/network-controller/src/rpc-service/abstract-rpc-service.ts b/packages/network-controller/src/rpc-service/abstract-rpc-service.ts new file mode 100644 index 0000000000..604c527a0f --- /dev/null +++ b/packages/network-controller/src/rpc-service/abstract-rpc-service.ts @@ -0,0 +1,12 @@ +import type { ServicePolicy } from '@metamask/controller-utils'; +import type { Json } from '@metamask/utils'; + +/** + * The interface for a service class responsible for making a request to an RPC + * endpoint. + */ +export type AbstractRpcService = Partial< + Pick +> & { + request(options?: RequestInit): Promise; +}; diff --git a/packages/network-controller/src/rpc-service/rpc-service.test.ts b/packages/network-controller/src/rpc-service/rpc-service.test.ts new file mode 100644 index 0000000000..9d3a191cc7 --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service.test.ts @@ -0,0 +1,890 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import nock from 'nock'; +import { useFakeTimers } from 'sinon'; +import type { SinonFakeTimers } from 'sinon'; + +import { NETWORK_UNREACHABLE_ERRORS, RpcService } from './rpc-service'; + +describe('RpcService', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('request', () => { + describe.each([...NETWORK_UNREACHABLE_ERRORS].slice(0, 1))( + `if making the request throws a "%s" error (as a "network unreachable" error)`, + (errorMessage) => { + const error = new TypeError(errorMessage); + testsForRetriableFetchErrors({ + getClock: () => clock, + producedError: error, + expectedError: error, + }); + }, + ); + + describe('if making the request throws a "Gateway timeout" error', () => { + const error = new Error('Gateway timeout'); + testsForRetriableFetchErrors({ + getClock: () => clock, + producedError: error, + expectedError: error, + }); + }); + + describe.each(['ETIMEDOUT', 'ECONNRESET'])( + 'if making the request throws a %s error', + (errorCode) => { + const error = new Error('timed out'); + // @ts-expect-error `code` does not exist on the Error type, but is + // still used by Node. + error.code = errorCode; + + testsForRetriableFetchErrors({ + getClock: () => clock, + producedError: error, + expectedError: error, + }); + }, + ); + + describe('if making the request throws an unknown error', () => { + it('re-throws the error without retrying the request', async () => { + const error = new Error('oops'); + const mockFetch = jest.fn(() => { + throw error; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await expect(promise).rejects.toThrow(error); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('does not call onBreak', async () => { + const error = new Error('oops'); + const mockFetch = jest.fn(() => { + throw error; + }); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.onBreak(onBreakListener); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await ignoreRejection(promise); + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe.each([503, 504])( + 'if the endpoint consistently has a %d response', + (httpStatus) => { + testsForRetriableResponses({ + getClock: () => clock, + httpStatus, + expectedError: rpcErrors.internal({ + message: + 'Gateway timeout. The request took too long to process. This can happen when querying logs over too wide a block range.', + }), + }); + }, + ); + + describe('if the endpoint has a 405 response', () => { + it('throws a non-existent method error without retrying the request', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }) + .reply(405); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }); + await expect(promise).rejects.toThrow( + 'The method does not exist / is not available.', + ); + }); + + it('does not call onBreak', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }) + .reply(405); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.onBreak(onBreakListener); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await ignoreRejection(promise); + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe('if the endpoint has a 429 response', () => { + it('throws a rate-limiting error without retrying the request', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(429); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await expect(promise).rejects.toThrow('Request is being rate limited.'); + }); + + it('does not call onBreak', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_unknownMethod', + params: [], + }) + .reply(429); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.onBreak(onBreakListener); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await ignoreRejection(promise); + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe('when the endpoint has a response that is neither 2xx, nor 405, 429, 503, or 504', () => { + it('throws a generic error without retrying the request', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(500, { + id: 1, + jsonrpc: '2.0', + error: 'oops', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await expect(promise).rejects.toThrow( + expect.objectContaining({ + message: "Non-200 status code: '500'", + data: { + id: 1, + jsonrpc: '2.0', + error: 'oops', + }, + }), + ); + }); + + it('does not call onBreak', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(500, { + id: 1, + jsonrpc: '2.0', + error: 'oops', + }); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.onBreak(onBreakListener); + + const promise = service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + await ignoreRejection(promise); + expect(onBreakListener).not.toHaveBeenCalled(); + }); + }); + + describe('if the endpoint consistently responds with invalid JSON', () => { + testsForRetriableResponses({ + getClock: () => clock, + httpStatus: 200, + responseBody: 'invalid JSON', + expectedError: expect.objectContaining({ + message: expect.stringContaining('is not valid JSON'), + }), + }); + }); + + it('removes non-JSON-RPC-compliant properties from the request body before sending it to the endpoint', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + + // @ts-expect-error Intentionally passing bad input. + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + some: 'extra', + properties: 'here', + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + }); + + it('extracts a username and password from the URL to the Authorization header', async () => { + nock('https://rpc.example.chain', { + reqheaders: { + Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + }); + + it('makes the request with Accept and Content-Type headers by default', async () => { + const scope = nock('https://rpc.example.chain', { + reqheaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + }); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(scope.isDone()).toBe(true); + }); + + it('mixes the given request options into the default request options', async () => { + const scope = nock('https://rpc.example.chain', { + reqheaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Foo': 'Bar', + }, + }) + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: '0x1', + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://username:password@rpc.example.chain', + }); + + await service.request( + { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }, + { + headers: { + 'X-Foo': 'Bar', + }, + }, + ); + + expect(scope.isDone()).toBe(true); + }); + + it('returns the JSON-decoded response if the request succeeds', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x68b3', false], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + result: { + number: '0x68b3', + hash: '0xd5f1812548be429cbdc6376b29611fc49e06f1359758c4ceaaa3b393e2239f9c', + nonce: '0x378da40ff335b070', + gasLimit: '0x47e7c4', + gasUsed: '0x37993', + timestamp: '0x5835c54d', + transactions: [ + '0xa0807e117a8dd124ab949f460f08c36c72b710188f01609595223b325e58e0fc', + '0xeae6d797af50cb62a596ec3939114d63967c374fa57de9bc0f4e2b576ed6639d', + ], + baseFeePerGas: '0x7', + }, + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x68b3', false], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: { + number: '0x68b3', + hash: '0xd5f1812548be429cbdc6376b29611fc49e06f1359758c4ceaaa3b393e2239f9c', + nonce: '0x378da40ff335b070', + gasLimit: '0x47e7c4', + gasUsed: '0x37993', + timestamp: '0x5835c54d', + transactions: [ + '0xa0807e117a8dd124ab949f460f08c36c72b710188f01609595223b325e58e0fc', + '0xeae6d797af50cb62a596ec3939114d63967c374fa57de9bc0f4e2b576ed6639d', + ], + baseFeePerGas: '0x7', + }, + }); + }); + + it('does not throw if the endpoint returns an unsuccessful JSON-RPC response', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .reply(200, { + id: 1, + jsonrpc: '2.0', + error: { + code: -32000, + message: 'oops', + }, + }); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + error: { + code: -32000, + message: 'oops', + }, + }); + }); + + it('interprets a "Not Found" response for eth_getBlockByNumber as an empty result', async () => { + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x999999999', false], + }) + .reply(200, 'Not Found'); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + + const response = await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_getBlockByNumber', + params: ['0x999999999', false], + }); + + expect(response).toStrictEqual({ + id: 1, + jsonrpc: '2.0', + result: null, + }); + }); + + it('calls the onDegraded callback if the endpoint takes more than 5 seconds to respond', async () => { + nock('https://rpc.example.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 onDegradedListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.onDegraded(onDegradedListener); + + await service.request({ + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }); + + expect(onDegradedListener).toHaveBeenCalledTimes(1); + }); + }); +}); + +/** + * Some tests involve a rejected promise that is not necessarily the focus of + * the test. In these cases we don't want to ignore the error in case the + * promise _isn't_ rejected, but we don't want to highlight the assertion, + * either. + * + * @param promiseOrFn - A promise that rejects, or a function that returns a + * promise that rejects. + */ +async function ignoreRejection( + promiseOrFn: Promise | (() => T | Promise), +) { + await expect(promiseOrFn).rejects.toThrow(expect.any(Error)); +} + +/** + * These are tests that exercise logic for cases in which the request cannot be + * made because the `fetch` calls throws a specific error. + * + * @param args - The arguments + * @param args.getClock - A function that returns the Sinon clock, set in + * `beforeEach`. + * @param args.producedError - The error produced when `fetch` is called. + * @param args.expectedError - The error that a call to the service's `request` + * method is expected to produce. + */ +function testsForRetriableFetchErrors({ + getClock, + producedError, + expectedError, +}: { + getClock: () => SinonFakeTimers; + producedError: Error; + expectedError: string | jest.Constructable | RegExp | Error; +}) { + describe('if there is no failover service provided', () => { + it('retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.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: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + expect(mockFetch).toHaveBeenCalledTimes(5); + }); + + it('still re-throws the error even after the circuit breaks', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.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: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + }); + + it('calls the onBreak callback once after the circuit breaks', async () => { + const clock = getClock(); + const mockFetch = jest.fn(() => { + throw producedError; + }); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch: mockFetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.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(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error: expectedError }); + }); + }); +} + +/** + * These are tests that exercise logic for cases in which the request returns a + * response that is retriable. + * + * @param args - The arguments + * @param args.getClock - A function that returns the Sinon clock, set in + * `beforeEach`. + * @param args.httpStatus - The HTTP status code that the response will have. + * @param args.responseBody - The body that the response will have. + * @param args.expectedError - The error that a call to the service's `request` + * method is expected to produce. + */ +function testsForRetriableResponses({ + getClock, + httpStatus, + responseBody = '', + expectedError, +}: { + getClock: () => SinonFakeTimers; + httpStatus: number; + responseBody?: string; + expectedError: string | jest.Constructable | RegExp | Error; +}) { + // This function is designed to be used inside of a describe, so this won't be + // a problem in practice. + /* eslint-disable jest/no-identical-title */ + + describe('if there is no failover service provided', () => { + it('retries a constantly failing request up to 4 more times before re-throwing the error, if `request` is only called once', async () => { + const clock = getClock(); + const scope = nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(5) + .reply(httpStatus, responseBody); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.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: [], + }; + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + expect(scope.isDone()).toBe(true); + }); + + it('still re-throws the error even after the circuit breaks', async () => { + const clock = getClock(); + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(httpStatus, responseBody); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.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: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await expect(service.request(jsonRpcRequest)).rejects.toThrow( + expectedError, + ); + }); + + it('calls the onBreak callback once after the circuit breaks', async () => { + const clock = getClock(); + nock('https://rpc.example.chain') + .post('/', { + id: 1, + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + }) + .times(15) + .reply(httpStatus, responseBody); + const onBreakListener = jest.fn(); + const service = new RpcService({ + fetch, + btoa, + endpointUrl: 'https://rpc.example.chain', + }); + service.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(); + }); + service.onBreak(onBreakListener); + + const jsonRpcRequest = { + id: 1, + jsonrpc: '2.0' as const, + method: 'eth_chainId', + params: [], + }; + await ignoreRejection(service.request(jsonRpcRequest)); + await ignoreRejection(service.request(jsonRpcRequest)); + // The last retry breaks the circuit + await ignoreRejection(service.request(jsonRpcRequest)); + + expect(onBreakListener).toHaveBeenCalledTimes(1); + expect(onBreakListener).toHaveBeenCalledWith({ error: expectedError }); + }); + }); + + /* eslint-enable jest/no-identical-title */ +} diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts new file mode 100644 index 0000000000..4640cb6abc --- /dev/null +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -0,0 +1,388 @@ +import type { ServicePolicy } from '@metamask/controller-utils'; +import { createServicePolicy, handleWhen } from '@metamask/controller-utils'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcRequest } from '@metamask/utils'; +import { + hasProperty, + type Json, + type JsonRpcParams, + type JsonRpcResponse, +} from '@metamask/utils'; +import deepmerge from 'deepmerge'; + +import type { AbstractRpcService } from './abstract-rpc-service'; + +/** + * Equivalent to the built-in `FetchOptions` type, but renamed for clarity. + */ +export type FetchOptions = RequestInit; + +/** + * The list of error messages that represent a failure to reach the network. + * + * This list was derived from Sindre Sorhus's package `is-network-error`: + * + */ +export const NETWORK_UNREACHABLE_ERRORS = new Set([ + 'network error', // Chrome + 'Failed to fetch', // Chrome + 'NetworkError when attempting to fetch resource.', // Firefox + 'The Internet connection appears to be offline.', // Safari 16 + 'Load failed', // Safari 17+ + 'Network request failed', // `cross-fetch` + 'fetch failed', // Undici (Node.js) + 'terminated', // Undici (Node.js) +]); + +/** + * Determines whether the given error represents a failure to reach the network + * after request parameters have been validated. + * + * This is somewhat difficult to verify because JavaScript engines (and in + * some cases libraries) produce slightly different error messages for this + * particular scenario, and we need to account for this. + * + * @param error - The error. + * @returns True if the error indicates that the network is unreachable, and + * false otherwise. + */ +export default function isNetworkUnreachableError(error: unknown) { + return ( + error instanceof TypeError && NETWORK_UNREACHABLE_ERRORS.has(error.message) + ); +} + +/** + * Guarantees a URL, even given a string. This is useful for checking components + * of that URL. + * + * @param endpointUrlOrUrlString - Either a URL object or a string that + * represents the URL of an endpoint. + * @returns A URL object. + */ +function getNormalizedEndpointUrl(endpointUrlOrUrlString: URL | string): URL { + return endpointUrlOrUrlString instanceof URL + ? endpointUrlOrUrlString + : new URL(endpointUrlOrUrlString); +} + +/** + * This class is responsible for making a request to an endpoint that implements + * the JSON-RPC protocol. It is designed to gracefully handle network and server + * failures, retrying requests using exponential backoff. It also offers a hook + * which can used to respond to slow requests. + */ +export class RpcService implements AbstractRpcService { + /** + * The function used to make an HTTP request. + */ + readonly #fetch: typeof fetch; + + /** + * The URL of the RPC endpoint. + */ + readonly #endpointUrl: URL; + + /** + * A common set of options that the request options will extend. + */ + readonly #fetchOptions: FetchOptions; + + /** + * The policy that wraps the request. + */ + readonly #policy: ServicePolicy; + + /** + * Constructs a new RpcService 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 encode a binary string + * into base 64. Used to encode authorization credentials. + * @param args.endpointUrl - The URL of the RPC endpoint. + * @param args.fetchOptions - A common set of options that will be used to + * make every request. Can be overridden on the request level (e.g. to add + * headers). + * @param args.failoverService - An RPC service that represents a failover + * endpoint which will be invoked while the circuit for _this_ service is + * open. + */ + constructor({ + fetch: givenFetch, + btoa: givenBtoa, + endpointUrl, + fetchOptions = {}, + }: { + fetch: typeof fetch; + btoa: typeof btoa; + endpointUrl: URL | string; + fetchOptions?: FetchOptions; + failoverService?: AbstractRpcService; + }) { + this.#fetch = givenFetch; + this.#endpointUrl = getNormalizedEndpointUrl(endpointUrl); + this.#fetchOptions = this.#getDefaultFetchOptions( + this.#endpointUrl, + fetchOptions, + givenBtoa, + ); + + const policy = createServicePolicy({ + maxRetries: 4, + maxConsecutiveFailures: 15, + retryFilterPolicy: handleWhen((error) => { + return ( + // Ignore errors where the request failed to establish + isNetworkUnreachableError(error) || + // Ignore server sent HTML error pages or truncated JSON responses + error.message.includes('not valid JSON') || + // Ignore server overload errors + error.message.includes('Gateway timeout') || + (hasProperty(error, 'code') && + (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET')) + ); + }), + }); + this.#policy = policy; + } + + /** + * Listens for when the retry policy underlying this RPC service retries the + * request. + * + * @param listener - The callback to be called when the retry occurs. + * @returns What {@link ServicePolicy.onRetry} returns. + * @see {@link createServicePolicy} + */ + onRetry(listener: Parameters[0]) { + return this.#policy.onRetry(listener); + } + + /** + * Listens for when the circuit breaker policy underlying this RPC service + * detects a broken circuit. + * + * @param listener - The callback to be called when the circuit is broken. + * @returns What {@link ServicePolicy.onBreak} returns. + * @see {@link createServicePolicy} + */ + onBreak(listener: Parameters[0]) { + return this.#policy.onBreak(listener); + } + + /** + * Listens for when the policy underlying this RPC service detects a slow + * request. + * + * @param listener - The callback to be called when the request is slow. + * @returns What {@link ServicePolicy.onDegraded} returns. + * @see {@link createServicePolicy} + */ + onDegraded(listener: Parameters[0]) { + return this.#policy.onDegraded(listener); + } + + /** + * Makes a request to the RPC endpoint. + * + * 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 RPC endpoint. + * + * 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> { + const completeFetchOptions = this.#getCompleteFetchOptions( + jsonRpcRequest, + fetchOptions, + ); + + return await this.#executePolicy( + jsonRpcRequest, + completeFetchOptions, + ); + } + + /** + * Constructs a default set of options to `fetch`. + * + * If a username and password are present in the URL, they are extracted to an + * Authorization header. + * + * @param endpointUrl - The endpoint URL. + * @param fetchOptions - The options to `fetch`. + * @param givenBtoa - An implementation of `btoa`. + * @returns The default fetch options. + */ + #getDefaultFetchOptions( + endpointUrl: URL, + fetchOptions: FetchOptions, + givenBtoa: (stringToEncode: string) => string, + ): FetchOptions { + if (endpointUrl.username && endpointUrl.password) { + const authString = `${endpointUrl.username}:${endpointUrl.password}`; + const encodedCredentials = givenBtoa(authString); + return deepmerge(fetchOptions, { + headers: { Authorization: `Basic ${encodedCredentials}` }, + }); + } + + return fetchOptions; + } + + /** + * Constructs a final set of options to pass to `fetch`. Note that the method + * defaults to `post`, and the JSON-RPC request is automatically JSON-encoded. + * + * @param jsonRpcRequest - The JSON-RPC request. + * @param fetchOptions - Custom `fetch` options. + * @returns The complete set of `fetch` options. + */ + #getCompleteFetchOptions( + jsonRpcRequest: JsonRpcRequest, + fetchOptions: FetchOptions, + ): FetchOptions { + const defaultOptions = { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }; + const mergedOptions = deepmerge( + defaultOptions, + deepmerge(this.#fetchOptions, fetchOptions), + ); + + const { id, jsonrpc, method, params } = jsonRpcRequest; + const body = JSON.stringify({ + id, + jsonrpc, + method, + params, + }); + + return { ...mergedOptions, body }; + } + + /** + * Makes the request using the Cockatiel policy that this service creates. + * + * @param jsonRpcRequest - The JSON-RPC request to send to the endpoint. + * @param fetchOptions - The options for `fetch`; will be combined with the + * fetch options passed to the constructor + * @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 #executePolicy< + Params extends JsonRpcParams, + Result extends Json, + Request extends JsonRpcRequest = JsonRpcRequest, + >( + jsonRpcRequest: Request, + fetchOptions: FetchOptions, + ): Promise | JsonRpcResponse> { + return await this.#policy.execute(async () => { + const response = await this.#fetch(this.#endpointUrl, fetchOptions); + + if (response.status === 405) { + throw rpcErrors.methodNotFound(); + } + + if (response.status === 429) { + throw rpcErrors.internal({ message: 'Request is being rate limited.' }); + } + + if (response.status === 503 || response.status === 504) { + throw rpcErrors.internal({ + message: + 'Gateway timeout. The request took too long to process. This can happen when querying logs over too wide a block range.', + }); + } + + const text = await response.text(); + + if ( + jsonRpcRequest.method === 'eth_getBlockByNumber' && + text === 'Not Found' + ) { + return { + id: jsonRpcRequest.id, + jsonrpc: jsonRpcRequest.jsonrpc, + result: null, + }; + } + + // Type annotation: We assume this if it's valid JSON, it's a valid + // JSON-RPC response. + let json: JsonRpcResponse; + try { + json = JSON.parse(text); + } catch (error) { + if (error instanceof SyntaxError) { + throw rpcErrors.internal({ + message: 'Could not parse response as it is not valid JSON', + data: text, + }); + } else { + throw error; + } + } + + if (!response.ok) { + throw rpcErrors.internal({ + message: `Non-200 status code: '${response.status}'`, + data: json, + }); + } + + return json; + }); + } +} From 1ee185d442766edd35a49c0eb199ed3c3b1a93a4 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 24 Jan 2025 09:47:45 -0700 Subject: [PATCH 2/4] Tweak JSDoc --- .../network-controller/src/rpc-service/rpc-service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index 4640cb6abc..019b612783 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -13,14 +13,14 @@ import deepmerge from 'deepmerge'; import type { AbstractRpcService } from './abstract-rpc-service'; /** - * Equivalent to the built-in `FetchOptions` type, but renamed for clarity. + * Equivalent to the built-in `RequestInit` type, but renamed for clarity. */ export type FetchOptions = RequestInit; /** * The list of error messages that represent a failure to reach the network. * - * This list was derived from Sindre Sorhus's package `is-network-error`: + * This list was derived from Sindre Sorhus's `is-network-error` package: * */ export const NETWORK_UNREACHABLE_ERRORS = new Set([ @@ -359,8 +359,8 @@ export class RpcService implements AbstractRpcService { }; } - // Type annotation: We assume this if it's valid JSON, it's a valid - // JSON-RPC response. + // Type annotation: We assume that if this response is valid JSON, it's a + // valid JSON-RPC response. let json: JsonRpcResponse; try { json = JSON.parse(text); From 2f177f5d538e5bdfb5dcffe91ee2caab4361da86 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 27 Jan 2025 10:36:30 -0700 Subject: [PATCH 3/4] Correct AbstractRpcService to match RpcService --- eslint-warning-thresholds.json | 2 +- .../src/rpc-service/abstract-rpc-service.ts | 14 ++++++++++++-- .../src/rpc-service/rpc-service.ts | 6 +----- .../network-controller/src/rpc-service/shared.ts | 4 ++++ 4 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 packages/network-controller/src/rpc-service/shared.ts diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index e1358ad4b7..f13eddff82 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -5,7 +5,7 @@ "@typescript-eslint/no-unsafe-enum-comparison": 34, "@typescript-eslint/no-unused-vars": 41, "@typescript-eslint/prefer-promise-reject-errors": 33, - "@typescript-eslint/prefer-readonly": 143, + "@typescript-eslint/prefer-readonly": 142, "import-x/namespace": 189, "import-x/no-named-as-default": 1, "import-x/no-named-as-default-member": 8, diff --git a/packages/network-controller/src/rpc-service/abstract-rpc-service.ts b/packages/network-controller/src/rpc-service/abstract-rpc-service.ts index 604c527a0f..bf7c3002ec 100644 --- a/packages/network-controller/src/rpc-service/abstract-rpc-service.ts +++ b/packages/network-controller/src/rpc-service/abstract-rpc-service.ts @@ -1,5 +1,12 @@ import type { ServicePolicy } from '@metamask/controller-utils'; -import type { Json } from '@metamask/utils'; +import type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; + +import type { FetchOptions } from './shared'; /** * The interface for a service class responsible for making a request to an RPC @@ -8,5 +15,8 @@ import type { Json } from '@metamask/utils'; export type AbstractRpcService = Partial< Pick > & { - request(options?: RequestInit): Promise; + request( + jsonRpcRequest: JsonRpcRequest, + fetchOptions?: FetchOptions, + ): Promise>; }; diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index 019b612783..db8a3aa0b8 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -11,11 +11,7 @@ import { import deepmerge from 'deepmerge'; import type { AbstractRpcService } from './abstract-rpc-service'; - -/** - * Equivalent to the built-in `RequestInit` type, but renamed for clarity. - */ -export type FetchOptions = RequestInit; +import type { FetchOptions } from './shared'; /** * The list of error messages that represent a failure to reach the network. diff --git a/packages/network-controller/src/rpc-service/shared.ts b/packages/network-controller/src/rpc-service/shared.ts new file mode 100644 index 0000000000..e45ec187e0 --- /dev/null +++ b/packages/network-controller/src/rpc-service/shared.ts @@ -0,0 +1,4 @@ +/** + * Equivalent to the built-in `FetchOptions` type, but renamed for clarity. + */ +export type FetchOptions = RequestInit; From 151f6689d55ff6295d085130affaf0c9ec7109a2 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 28 Jan 2025 15:38:15 -0700 Subject: [PATCH 4/4] Remove failoverService option Co-authored-by: cryptodev-2s <109512101+cryptodev-2s@users.noreply.github.com> --- packages/network-controller/src/rpc-service/rpc-service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/network-controller/src/rpc-service/rpc-service.ts b/packages/network-controller/src/rpc-service/rpc-service.ts index db8a3aa0b8..1ec7960e48 100644 --- a/packages/network-controller/src/rpc-service/rpc-service.ts +++ b/packages/network-controller/src/rpc-service/rpc-service.ts @@ -117,7 +117,6 @@ export class RpcService implements AbstractRpcService { btoa: typeof btoa; endpointUrl: URL | string; fetchOptions?: FetchOptions; - failoverService?: AbstractRpcService; }) { this.#fetch = givenFetch; this.#endpointUrl = getNormalizedEndpointUrl(endpointUrl);