Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support using AbortSignal to abort requests #2479

Closed
Closed
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
26 changes: 25 additions & 1 deletion lib/intercepted_request_router.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ class InterceptedRequestRouter {
// Emit a fake socket event on the next tick to mimic what would happen on a real request.
// Some clients listen for a 'socket' event to be emitted before calling end(),
// which causes Nock to hang.
process.nextTick(() => this.connectSocket())
process.nextTick(() => {
this.connectSocket()
this.addAbortSignal()
})
}

attachToReq() {
Expand Down Expand Up @@ -109,6 +112,27 @@ class InterceptedRequestRouter {
}
}

addAbortSignal() {
const { signal } = this.options

if (signal && typeof signal === 'object' && 'aborted' in signal) {
const onAbort = () => {
const error = new Error('The operation was aborted', {
cause: signal.reason,
})
error.code = 'ABORT_ERR'
error.name = 'AbortError'
this.req.destroy(error)
}

if (signal.aborted) {
onAbort()
} else {
signal.addEventListener('abort', onAbort, { once: true })
}
}
}

connectSocket() {
const { req, socket } = this

Expand Down
212 changes: 212 additions & 0 deletions tests/test_abort_signal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
'use strict'

const { expect } = require('chai')
const http = require('http')
const nock = require('..')

// These tests use `AbortSignal` to abort HTTP requests

const makeRequest = async (url, options = {}) => {
const { statusCode } = await new Promise((resolve, reject) => {
http
.request(url, options)
.on('response', res => {
res
.on('data', () => {})
.on('error', reject)
.on('end', () => resolve({ statusCode: res.statusCode }))
})
.on('error', reject)
.end()
})

return { statusCode }
}

describe('When `AbortSignal` is used', () => {
it('does not abort a request if the signal is an empty object', async () => {
const scope = nock('http://example.test').post('/form').reply(201, 'OK!')

const { statusCode } = await makeRequest('http://example.test/form', {
method: 'POST',
})

expect(statusCode).to.equal(201)
scope.done()
})

it('does not abort a request if the signal is an empty object', async () => {
const scope = nock('http://example.test').post('/form').reply(201, 'OK!')

const { statusCode } = await makeRequest('http://example.test/form', {
method: 'POST',
signal: {},
})

expect(statusCode).to.equal(201)
scope.done()
})

it('does not abort a request if the signal is not an object', async () => {
const scope = nock('http://example.test').post('/form').reply(201, 'OK!')

const { statusCode } = await makeRequest('http://example.test/form', {
method: 'POST',
signal: 'Not an object',
})

expect(statusCode).to.equal(201)
scope.done()
})

it('does not abort a request if the signal is not aborted', async () => {
const abortController = new AbortController()

const scope = nock('http://example.test').post('/form').reply(201, 'OK!')

const { statusCode } = await makeRequest('http://example.test/form', {
method: 'POST',
signal: abortController.signal,
})

expect(statusCode).to.equal(201)
scope.done()
})

it('aborts a request if the signal is aborted before the request is made', async () => {
const abortController = new AbortController()
abortController.abort()

const scope = nock('http://example.test').post('/form').reply(201, 'OK!')

const error = await makeRequest('http://example.test/form', {
method: 'POST',
signal: abortController.signal,
}).catch(error => error)

expect(error).to.have.property('message', 'The operation was aborted')
expect(error).to.have.property('name', 'AbortError')
expect(error).to.have.property('code', 'ABORT_ERR')
expect(error.cause).to.have.property(
'message',
'This operation was aborted'
)
scope.done()
})

it('sets the reason correctly for an aborted request', async () => {
const abortController = new AbortController()
const cause = new Error('A very good reason')
abortController.abort(cause)

const scope = nock('http://example.test').post('/form').reply(201, 'OK!')

const error = await makeRequest('http://example.test/form', {
method: 'POST',
signal: abortController.signal,
}).catch(error => error)

expect(error).to.have.property('message', 'The operation was aborted')
expect(error).to.have.property('name', 'AbortError')
expect(error).to.have.property('code', 'ABORT_ERR')
expect(error.cause).to.eql(cause)
scope.done()
})

it('aborts a request if the signal is aborted after the response headers have been read', async () => {
const abortController = new AbortController()
const scope = nock('http://example.test').post('/form').reply(201, 'OK!');

const makeRequest = () =>
new Promise((resolve, reject) => {
http
.request('http://example.test/form', {
signal: abortController.signal,
method: 'POST',
})
.on('response', res => {
abortController.abort()
res
.on('data', () => {})
.on('error', error => {
reject(error)
})
.on('end', () =>
resolve({
statusCode: res.statusCode,
})
)
})
.on('error', error => {
reject(error)
})
.end()
})

const error = await makeRequest().catch(error => error)
expect(error).to.have.property('message', 'The operation was aborted')
expect(error).to.have.property('name', 'AbortError')
expect(error).to.have.property('code', 'ABORT_ERR')
expect(error.cause).to.have.property(
'message',
'This operation was aborted'
)
scope.done()
})

it('aborts a request if the signal is aborted before the connection is made', async () => {
const signal = AbortSignal.timeout(10)
const scope = nock('http://example.test')
.post('/form')
.delayConnection(10)
.reply(201, 'OK!')

const error = await makeRequest('http://example.test/form', {
signal,
method: 'POST',
}).catch(error => error)

expect(error).to.have.property('message', 'The operation was aborted')
expect(error).to.have.property('name', 'AbortError')
expect(error).to.have.property('code', 'ABORT_ERR')
expect(error.cause).to.have.property('name', 'TimeoutError')
scope.done()
})

it('aborts a request if the signal is aborted before the body is returned', async () => {
const signal = AbortSignal.timeout(10)
const scope = nock('http://example.test')
.post('/form')
.delay(10)
.reply(201, 'OK!')

const error = await makeRequest('http://example.test/form', {
signal,
method: 'POST',
}).catch(error => error)

expect(error).to.have.property('message', 'The operation was aborted')
expect(error).to.have.property('name', 'AbortError')
expect(error).to.have.property('code', 'ABORT_ERR')
expect(error.cause).to.have.property('name', 'TimeoutError')

scope.done()
})

it('does not abort a request if the signal is aborted after the request has been completed', done => {
const signal = AbortSignal.timeout(30)
signal.addEventListener('abort', () => done())

const scope = nock('http://example.test').post('/form').reply(201, 'OK!')

makeRequest('http://example.test/form', {
signal,
method: 'POST',
})
.then(({ statusCode }) => {
expect(statusCode).to.equal(201)
scope.done()
})
.catch(error => done(error))
})
})
Loading