Skip to content

Commit

Permalink
Allow createInfuraMiddleware to take an RPC service
Browse files Browse the repository at this point in the history
`@metamask/network-controller` now contains an `RpcService` class. Using
this class not only allows us to remove a lot of code from this package,
as it incorporates the vast majority of logic contained in
`createInfuraMiddleware`, including the retry logic, but it also allows
us to use the circuit breaker pattern and the exponential backoff
pattern to prevent too many retries if the network is unreliable, and
then automatically cut over to a failover node when the network truly
goes down.
  • Loading branch information
mcmire committed Feb 4, 2025
1 parent 12b2574 commit d12e9a4
Show file tree
Hide file tree
Showing 9 changed files with 1,199 additions and 23 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Changed
- Deprecate passing an RPC endpoint to `createInfuraMiddleware`, and add a way to pass an RPC service instead ([#116](https://github.com/MetaMask/eth-json-rpc-infura/pull/116))
- The new, recommended method signature is now `createInfuraMiddleware({ rpcService: AbstractRpcService; options?: { source?: string; headers?: HeadersInit } })`, where `AbstractRpcService` matches the same interface from `@metamask/network-controller`
- This allows us to support automatic failover to a secondary node when the network goes down
- The existing method signature `createInfuraMiddleware({ network?: InfuraJsonRpcSupportedNetwork; maxAttempts?: number; source?: string; projectId: string; headers?: Record<string, string>; })` will be removed in a future major version

## [10.0.0]
### Changed
Expand Down
2 changes: 1 addition & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const config: Config.InitialOptions = {
collectCoverage: true,

// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: ['./src/**/*.ts'],
collectCoverageFrom: ['./src/**/*.ts', '!./src/**/*.test-d.ts'],

// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern",
"prepublishOnly": "yarn build:clean && yarn lint && yarn test",
"setup": "yarn install && yarn allow-scripts",
"test": "jest",
"test": "jest && yarn build:clean && yarn test:types",
"test:types": "tsd --files 'src/**/*.test-d.ts'",
"test:watch": "jest --watch"
},
"dependencies": {
Expand All @@ -31,12 +32,14 @@
"@metamask/utils": "^11.0.1"
},
"devDependencies": {
"@babel/runtime": "^7.0.0",
"@lavamoat/allow-scripts": "^2.3.1",
"@metamask/auto-changelog": "^2.5.0",
"@metamask/eslint-config": "^12.2.0",
"@metamask/eslint-config-jest": "^12.1.0",
"@metamask/eslint-config-nodejs": "^12.1.0",
"@metamask/eslint-config-typescript": "^12.1.0",
"@metamask/network-controller": "22.2.0",
"@types/jest": "^26.0.13",
"@types/node": "^18.18.14",
"@typescript-eslint/eslint-plugin": "^5.42.1",
Expand All @@ -56,8 +59,10 @@
"rimraf": "^3.0.2",
"ts-jest": "^28.0.8",
"ts-node": "^10.7.0",
"tsd": "^0.31.2",
"typescript": "~4.8.4"
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"engines": {
"node": "^18.18 || ^20.14 || >=22"
},
Expand Down
257 changes: 256 additions & 1 deletion src/create-infura-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,221 @@
import { JsonRpcEngine } from '@metamask/json-rpc-engine';
import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils';

import { createInfuraMiddleware } from '.';
import type { AbstractRpcService } from './types';

describe('createInfuraMiddleware (given an RPC service)', () => {
it('calls the RPC service with the correct request headers and body when no `source` option given', async () => {
const rpcService = buildRpcService();
const requestSpy = jest.spyOn(rpcService, 'request');
const middleware = createInfuraMiddleware({
rpcService,
});

const engine = new JsonRpcEngine();
engine.push(middleware);
await engine.handle({
id: 1,
jsonrpc: '2.0',
method: 'eth_chainId',
params: [],
});

expect(requestSpy).toHaveBeenCalledWith(
{
id: 1,
jsonrpc: '2.0',
method: 'eth_chainId',
params: [],
},
{
headers: {},
},
);
});

it('includes the `origin` from the given request in the request headers under the given `source`', async () => {
const rpcService = buildRpcService();
const requestSpy = jest.spyOn(rpcService, 'request');
const middleware = createInfuraMiddleware({
rpcService,
options: {
source: 'metamask',
},
});

const engine = new JsonRpcEngine();
engine.push(middleware);
// @ts-expect-error This isn't a "proper" request as it includes `origin`,
// but that's intentional.
// eslint-disable-next-line @typescript-eslint/await-thenable
await engine.handle({
id: 1,
jsonrpc: '2.0',
method: 'eth_chainId',
params: [],
origin: 'somedapp.com',
});

expect(requestSpy).toHaveBeenCalledWith(
{
id: 1,
jsonrpc: '2.0',
method: 'eth_chainId',
params: [],
},
{
headers: {
'Infura-Source': 'metamask/somedapp.com',
},
},
);
});

it('includes provided extra request headers in the request, not allowing Infura-Source to be overwritten', async () => {
const rpcService = buildRpcService();
const requestSpy = jest.spyOn(rpcService, 'request');
const middleware = createInfuraMiddleware({
rpcService,
options: {
source: 'metamask',
headers: {
'X-Foo': 'Bar',
'X-Baz': 'Qux',
'Infura-Source': 'whatever',
},
},
});

const engine = new JsonRpcEngine();
engine.push(middleware);
// @ts-expect-error This isn't a "proper" request as it includes `origin`,
// but that's intentional.
// eslint-disable-next-line @typescript-eslint/await-thenable
await engine.handle({
id: 1,
jsonrpc: '2.0',
method: 'eth_chainId',
params: [],
origin: 'somedapp.com',
});

expect(requestSpy).toHaveBeenCalledWith(
{
id: 1,
jsonrpc: '2.0',
method: 'eth_chainId',
params: [],
},
{
headers: {
'X-Foo': 'Bar',
'X-Baz': 'Qux',
'Infura-Source': 'metamask/somedapp.com',
},
},
);
});

describe('if the request to the service returns a successful JSON-RPC response', () => {
it('includes the `result` field from the service in the middleware response', async () => {
const rpcService = buildRpcService();
jest.spyOn(rpcService, 'request').mockResolvedValue({
id: 1,
jsonrpc: '2.0',
result: 'the result',
});
const middleware = createInfuraMiddleware({
rpcService,
});

const engine = new JsonRpcEngine();
engine.push(middleware);
const result = await engine.handle({
id: 1,
jsonrpc: '2.0',
method: 'eth_chainId',
params: [],
});

expect(result).toStrictEqual({
id: 1,
jsonrpc: '2.0',
result: 'the result',
});
});
});

describe('if the request to the service returns a unsuccessful JSON-RPC response', () => {
it('includes the `error` field from the service in the middleware response', async () => {
const rpcService = buildRpcService();
jest.spyOn(rpcService, 'request').mockResolvedValue({
id: 1,
jsonrpc: '2.0',
error: {
code: -1000,
message: 'oops',
},
});
const middleware = createInfuraMiddleware({
rpcService,
});

describe('createInfuraMiddleware', () => {
const engine = new JsonRpcEngine();
engine.push(middleware);
const result = await engine.handle({
id: 1,
jsonrpc: '2.0',
method: 'eth_chainId',
params: [],
});

expect(result).toStrictEqual({
id: 1,
jsonrpc: '2.0',
error: {
code: -1000,
message: 'oops',
},
});
});
});

describe('if the request to the service throws', () => {
it('includes the message and stack of the error in a new JSON-RPC error', async () => {
const rpcService = buildRpcService();
jest.spyOn(rpcService, 'request').mockRejectedValue(new Error('oops'));
const middleware = createInfuraMiddleware({
rpcService,
});

const engine = new JsonRpcEngine();
engine.push(middleware);
const result = await engine.handle({
id: 1,
jsonrpc: '2.0',
method: 'eth_chainId',
params: [],
});

expect(result).toMatchObject({
id: 1,
jsonrpc: '2.0',
error: {
code: -32603,
data: {
cause: {
message: 'oops',
stack: expect.stringContaining('Error: oops'),
},
},
},
});
});
});
});

describe('createInfuraMiddleware (given an RPC endpoint)', () => {
it('throws when an empty set of options is given', () => {
expect(() => createInfuraMiddleware({} as any)).toThrow(
/Invalid value for 'projectId'/u,
Expand Down Expand Up @@ -49,3 +264,43 @@ describe('createInfuraMiddleware', () => {
).toThrow(/Invalid value for 'headers'/u);
});
});

/**
* Constructs a fake RPC service for use as a failover in tests.
* @returns The fake failover service.
*/
function buildRpcService(): AbstractRpcService {
return {
async request<Params extends JsonRpcParams, Result extends Json>(
jsonRpcRequest: JsonRpcRequest<Params>,
_fetchOptions?: RequestInit,
) {
return {
id: jsonRpcRequest.id,
jsonrpc: jsonRpcRequest.jsonrpc,
result: 'ok' as Result,
};
},
onRetry() {
return {
dispose() {
// do nothing
},
};
},
onBreak() {
return {
dispose() {
// do nothing
},
};
},
onDegraded() {
return {
dispose() {
// do nothing
},
};
},
};
}
Loading

0 comments on commit d12e9a4

Please sign in to comment.