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

Open
wants to merge 4 commits into
base: beta
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
26 changes: 25 additions & 1 deletion lib/intercepted_request_router.js
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
@@ -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))
})
})