Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/providers/HTTPProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
112 changes: 112 additions & 0 deletions test/HTTPProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { HTTPProvider } from '../src/providers/HTTPProvider'

type SendResult = { err: Error | null; result: any }

function sendAsync(provider: HTTPProvider, payload: any): Promise<SendResult> {
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')
})
})
})
Loading