Skip to content

Commit 1601e09

Browse files
committed
fix(rpc-service): handle 405 and 429 status codes without triggering circuit breaker
1 parent 8dd27f0 commit 1601e09

File tree

3 files changed

+117
-39
lines changed

3 files changed

+117
-39
lines changed

packages/network-controller/CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Improved handling of HTTP status codes to prevent unnecessary circuit breaker triggers ([#5767](https://github.com/MetaMask/core/pull/5767))
13+
- 405 (Method Not Allowed) now returns a JSON-RPC error with code -32601 (Method not found)
14+
- 429 (Too Many Requests) now returns a JSON-RPC error with code -32005 (Request rate limit exceeded)
15+
- Added retry delay information in 429 error responses when available via Retry-After header
16+
- These status codes no longer trigger the circuit breaker, preventing unnecessary failover to backup endpoints
17+
1018
## [23.4.0]
1119

1220
### Added

packages/network-controller/src/rpc-service/rpc-service.test.ts

+78-22
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ describe('RpcService', () => {
214214
method: 'eth_chainId',
215215
params: [],
216216
};
217-
await ignoreRejection(service.request(jsonRpcRequest));
217+
await expect(service.request(jsonRpcRequest)).rejects.toThrow(
218+
'Nock: No match for request',
219+
);
218220
expect(failoverService.request).not.toHaveBeenCalled();
219221
});
220222

@@ -236,13 +238,14 @@ describe('RpcService', () => {
236238
});
237239
service.onBreak(onBreakListener);
238240

239-
const promise = service.request({
240-
id: 1,
241-
jsonrpc: '2.0',
242-
method: 'eth_chainId',
243-
params: [],
244-
});
245-
await ignoreRejection(promise);
241+
await expect(
242+
service.request({
243+
id: 1,
244+
jsonrpc: '2.0',
245+
method: 'eth_chainId',
246+
params: [],
247+
}),
248+
).rejects.toThrow('Nock: No match for request');
246249
expect(onBreakListener).not.toHaveBeenCalled();
247250
});
248251
});
@@ -331,7 +334,7 @@ describe('RpcService', () => {
331334
);
332335

333336
describe('if the endpoint has a 405 response', () => {
334-
it('throws a non-existent method error without retrying the request', async () => {
337+
it('returns a method not supported error response for 405 status', async () => {
335338
const endpointUrl = 'https://rpc.example.chain';
336339
nock(endpointUrl)
337340
.post('/', {
@@ -347,15 +350,21 @@ describe('RpcService', () => {
347350
endpointUrl,
348351
});
349352

350-
const promise = service.request({
353+
const response = await service.request({
351354
id: 1,
352355
jsonrpc: '2.0',
353356
method: 'eth_unknownMethod',
354357
params: [],
355358
});
356-
await expect(promise).rejects.toThrow(
357-
'The method does not exist / is not available.',
358-
);
359+
360+
expect(response).toStrictEqual({
361+
id: 1,
362+
jsonrpc: '2.0',
363+
error: {
364+
code: -32601,
365+
message: 'The method does not exist / is not available.',
366+
},
367+
});
359368
});
360369

361370
it('does not forward the request to a failover service if given one', async () => {
@@ -382,7 +391,7 @@ describe('RpcService', () => {
382391
method: 'eth_unknownMethod',
383392
params: [],
384393
};
385-
await ignoreRejection(service.request(jsonRpcRequest));
394+
await service.request(jsonRpcRequest);
386395
expect(failoverService.request).not.toHaveBeenCalled();
387396
});
388397

@@ -404,19 +413,18 @@ describe('RpcService', () => {
404413
});
405414
service.onBreak(onBreakListener);
406415

407-
const promise = service.request({
416+
await service.request({
408417
id: 1,
409418
jsonrpc: '2.0',
410419
method: 'eth_unknownMethod',
411420
params: [],
412421
});
413-
await ignoreRejection(promise);
414422
expect(onBreakListener).not.toHaveBeenCalled();
415423
});
416424
});
417425

418426
describe('if the endpoint has a 429 response', () => {
419-
it('throws a rate-limiting error without retrying the request', async () => {
427+
it('returns a rate limit error response for 429 status', async () => {
420428
const endpointUrl = 'https://rpc.example.chain';
421429
nock(endpointUrl)
422430
.post('/', {
@@ -432,13 +440,62 @@ describe('RpcService', () => {
432440
endpointUrl,
433441
});
434442

435-
const promise = service.request({
443+
const response = await service.request({
444+
id: 1,
445+
jsonrpc: '2.0',
446+
method: 'eth_chainId',
447+
params: [],
448+
});
449+
450+
expect(response).toStrictEqual({
451+
id: 1,
452+
jsonrpc: '2.0',
453+
error: {
454+
code: -32005,
455+
message: 'Request is being rate limited.',
456+
data: {
457+
retryAfter: 1000,
458+
},
459+
},
460+
});
461+
});
462+
463+
it('includes retry delay from Retry-After header when available', async () => {
464+
const endpointUrl = 'https://rpc.example.chain';
465+
nock(endpointUrl)
466+
.post('/', {
467+
id: 1,
468+
jsonrpc: '2.0',
469+
method: 'eth_chainId',
470+
params: [],
471+
})
472+
.reply(429, '', {
473+
'Retry-After': '5',
474+
});
475+
const service = new RpcService({
476+
fetch,
477+
btoa,
478+
endpointUrl,
479+
});
480+
481+
const response = await service.request({
436482
id: 1,
437483
jsonrpc: '2.0',
438484
method: 'eth_chainId',
439485
params: [],
440486
});
441-
await expect(promise).rejects.toThrow('Request is being rate limited.');
487+
488+
expect(response).toStrictEqual({
489+
id: 1,
490+
jsonrpc: '2.0',
491+
error: {
492+
code: -32005,
493+
message: 'Request is being rate limited.',
494+
data: {
495+
retryAfter: 5000,
496+
},
497+
},
498+
});
442499
});
443500

444501
it('does not forward the request to a failover service if given one', async () => {
@@ -465,7 +522,7 @@ describe('RpcService', () => {
465522
method: 'eth_chainId',
466523
params: [],
467524
};
468-
await ignoreRejection(service.request(jsonRpcRequest));
525+
await service.request(jsonRpcRequest);
469526
expect(failoverService.request).not.toHaveBeenCalled();
470527
});
471528

@@ -487,13 +544,12 @@ describe('RpcService', () => {
487544
});
488545
service.onBreak(onBreakListener);
489546

490-
const promise = service.request({
547+
await service.request({
491548
id: 1,
492549
jsonrpc: '2.0',
493550
method: 'eth_chainId',
494551
params: [],
495552
});
496-
await ignoreRejection(promise);
497553
expect(onBreakListener).not.toHaveBeenCalled();
498554
});
499555
});

packages/network-controller/src/rpc-service/rpc-service.ts

+31-17
Original file line numberDiff line numberDiff line change
@@ -333,12 +333,10 @@ export class RpcService implements AbstractRpcService {
333333
* @param jsonRpcRequest - The JSON-RPC request to send to the endpoint.
334334
* @param fetchOptions - An options bag for {@link fetch} which further
335335
* specifies the request.
336-
* @returns The decoded JSON-RPC response from the endpoint.
337-
* @throws A "method not found" error if the response status is 405.
338-
* @throws A rate limiting error if the response HTTP status is 429.
336+
* @returns The decoded JSON-RPC response from the endpoint. For 405 status, returns a JSON-RPC error response with code -32601.
337+
* For 429 status, returns a JSON-RPC error response with code -32005.
339338
* @throws A timeout error if the response HTTP status is 503 or 504.
340-
* @throws A generic error if the response HTTP status is not 2xx but also not
341-
* 405, 429, 503, or 504.
339+
* @throws A generic error if the response HTTP status is not 2xx and not one of the specifically handled status codes (405, 429, 503, 504).
342340
*/
343341
async request<Params extends JsonRpcParams, Result extends Json>(
344342
jsonRpcRequest: JsonRpcRequest<Params> & { method: 'eth_getBlockByNumber' },
@@ -357,12 +355,10 @@ export class RpcService implements AbstractRpcService {
357355
* @param jsonRpcRequest - The JSON-RPC request to send to the endpoint.
358356
* @param fetchOptions - An options bag for {@link fetch} which further
359357
* specifies the request.
360-
* @returns The decoded JSON-RPC response from the endpoint.
361-
* @throws A "method not found" error if the response status is 405.
362-
* @throws A rate limiting error if the response HTTP status is 429.
358+
* @returns The decoded JSON-RPC response from the endpoint. For 405 status, returns a JSON-RPC error response with code -32601.
359+
* For 429 status, returns a JSON-RPC error response with code -32005.
363360
* @throws A timeout error if the response HTTP status is 503 or 504.
364-
* @throws A generic error if the response HTTP status is not 2xx but also not
365-
* 405, 429, 503, or 504.
361+
* @throws A generic error if the response HTTP status is not 2xx and not one of the specifically handled status codes (405, 429, 503, 504).
366362
*/
367363
async request<Params extends JsonRpcParams, Result extends Json>(
368364
jsonRpcRequest: JsonRpcRequest<Params>,
@@ -465,12 +461,10 @@ export class RpcService implements AbstractRpcService {
465461
* @param jsonRpcRequest - The JSON-RPC request to send to the endpoint.
466462
* @param fetchOptions - The options for `fetch`; will be combined with the
467463
* fetch options passed to the constructor
468-
* @returns The decoded JSON-RPC response from the endpoint.
469-
* @throws A "method not found" error if the response status is 405.
470-
* @throws A rate limiting error if the response HTTP status is 429.
464+
* @returns The decoded JSON-RPC response from the endpoint. For 405 status, returns a JSON-RPC error response with code -32601.
465+
* For 429 status, returns a JSON-RPC error response with code -32005.
471466
* @throws A timeout error if the response HTTP status is 503 or 504.
472-
* @throws A generic error if the response HTTP status is not 2xx but also not
473-
* 405, 429, 503, or 504.
467+
* @throws A generic error if the response HTTP status is not 2xx and not one of the specifically handled status codes (405, 429, 503, 504).
474468
*/
475469
async #executePolicy<
476470
Params extends JsonRpcParams,
@@ -484,11 +478,31 @@ export class RpcService implements AbstractRpcService {
484478
const response = await this.#fetch(this.endpointUrl, fetchOptions);
485479

486480
if (response.status === 405) {
487-
throw rpcErrors.methodNotFound();
481+
return {
482+
id: jsonRpcRequest.id,
483+
jsonrpc: jsonRpcRequest.jsonrpc,
484+
error: {
485+
code: -32601,
486+
message: 'The method does not exist / is not available.',
487+
},
488+
};
488489
}
489490

490491
if (response.status === 429) {
491-
throw rpcErrors.internal({ message: 'Request is being rate limited.' });
492+
const retryAfter = response.headers.get('Retry-After');
493+
const retryDelay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000;
494+
495+
return {
496+
id: jsonRpcRequest.id,
497+
jsonrpc: jsonRpcRequest.jsonrpc,
498+
error: {
499+
code: -32005,
500+
message: 'Request is being rate limited.',
501+
data: {
502+
retryAfter: retryDelay,
503+
},
504+
},
505+
};
492506
}
493507

494508
if (response.status === 503 || response.status === 504) {

0 commit comments

Comments
 (0)