diff --git a/src/providers/HTTPProvider.ts b/src/providers/HTTPProvider.ts index e6e6a3c..3472f1c 100644 --- a/src/providers/HTTPProvider.ts +++ b/src/providers/HTTPProvider.ts @@ -72,9 +72,17 @@ export class HTTPProvider { fetch(this.host, params).then( async ($) => { if (!$.ok) { + // Drain the error response body so the underlying connection is released: native + // fetch (undici) and browser fetch keep the socket checked out until the body is + // read or cancelled. Reading it also lets us surface the server's error detail, + // which is far more useful than a bare status code when debugging RPC failures. + // Best-effort and bounded: ignore read failures and cap the detail length. + const errorBody = typeof $.text === 'function' ? await $.text().catch(() => '') : '' + const detail = errorBody.trim().slice(0, 512) + const message = 'External error. response code: ' + $.status + (detail ? ' — ' + detail : '') /* istanbul ignore if */ - if (this.debug) console.log('ERR << ' + JSON.stringify($)) - callback(new Error('External error. response code: ' + $.status)) + if (this.debug) console.log('ERR << ' + message) + callback(new Error(message)) } else { const json = await $.json() /* istanbul ignore if */ diff --git a/test/HTTPProvider.spec.ts b/test/HTTPProvider.spec.ts new file mode 100644 index 0000000..e5cf52b --- /dev/null +++ b/test/HTTPProvider.spec.ts @@ -0,0 +1,112 @@ +import { HTTPProvider } from '../src/providers/HTTPProvider' + +type SendResult = { err: Error | null; result: any } + +function sendAsync(provider: HTTPProvider, payload: any): Promise { + return new Promise((resolve) => { + provider.sendAsync(payload, (err, result) => resolve({ err: err ?? null, result })) + }) +} + +describe('when sending an async request through the HTTPProvider', () => { + let fetchMock: jest.Mock + let provider: HTTPProvider + let payload: any + + beforeEach(() => { + payload = { id: 1, method: 'eth_chainId', params: [] } + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('and the response is not ok', () => { + describe('and the body can be read', () => { + let text: jest.Mock + + beforeEach(() => { + text = jest.fn().mockResolvedValue('{"error":"rate limited"}') + fetchMock = jest.fn().mockResolvedValue({ ok: false, status: 429, text }) + provider = new HTTPProvider('http://localhost:8545', { fetch: fetchMock }) + }) + + it('should drain the response body to release the connection', async () => { + await sendAsync(provider, payload) + + expect(text).toHaveBeenCalledTimes(1) + }) + + it('should call back with an error carrying the response status', async () => { + const { err } = await sendAsync(provider, payload) + + expect(err?.message).toContain('429') + }) + + it('should surface the server error detail in the error message', async () => { + const { err } = await sendAsync(provider, payload) + + expect(err?.message).toContain('rate limited') + }) + }) + + describe('and reading the body fails', () => { + let text: jest.Mock + + beforeEach(() => { + text = jest.fn().mockRejectedValue(new Error('connection reset')) + fetchMock = jest.fn().mockResolvedValue({ ok: false, status: 500, text }) + provider = new HTTPProvider('http://localhost:8545', { fetch: fetchMock }) + }) + + it('should still call back with an error carrying the status', async () => { + const { err } = await sendAsync(provider, payload) + + expect(err?.message).toContain('500') + }) + }) + + describe('and the fetch response has no text method', () => { + beforeEach(() => { + fetchMock = jest.fn().mockResolvedValue({ ok: false, status: 503 }) + provider = new HTTPProvider('http://localhost:8545', { fetch: fetchMock }) + }) + + it('should still call back with an error carrying the status', async () => { + const { err } = await sendAsync(provider, payload) + + expect(err?.message).toContain('503') + }) + }) + }) + + describe('and the response is ok', () => { + let json: jest.Mock + let text: jest.Mock + + beforeEach(() => { + json = jest.fn().mockResolvedValue({ jsonrpc: '2.0', id: 1, result: '0x1' }) + text = jest.fn() + fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 200, json, text }) + provider = new HTTPProvider('http://localhost:8545', { fetch: fetchMock }) + }) + + it('should consume the body via json', async () => { + await sendAsync(provider, payload) + + expect(json).toHaveBeenCalledTimes(1) + }) + + it('should not read the body as text', async () => { + await sendAsync(provider, payload) + + expect(text).not.toHaveBeenCalled() + }) + + it('should call back with the parsed result', async () => { + const { result } = await sendAsync(provider, payload) + + expect(result.result).toBe('0x1') + }) + }) +})