Skip to content

Commit

Permalink
fix(fetch): respect "abort" event on the request signal (#394)
Browse files Browse the repository at this point in the history
Co-authored-by: Antonio Cheong <[email protected]>
Co-authored-by: Artem Zakharchenko <[email protected]>
Co-authored-by: Aleksey Ivasyuta <[email protected]>
Co-authored-by: avivasyuta <[email protected]>
  • Loading branch information
5 people authored Sep 2, 2023
1 parent d4257a5 commit 33dcbe0
Show file tree
Hide file tree
Showing 3 changed files with 180 additions and 1 deletion.
28 changes: 28 additions & 0 deletions src/interceptors/ClientRequest/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import http from 'http'
import { HttpServer } from '@open-draft/test-server/http'
import { DeferredPromise } from '@open-draft/deferred-promise'
import { ClientRequestInterceptor } from '.'
import { sleep } from '../../../test/helpers'

const httpServer = new HttpServer((app) => {
app.get('/', (_req, res) => {
Expand Down Expand Up @@ -55,3 +56,30 @@ it('forbids calling "respondWith" multiple times for the same request', async ()
expect(response.statusCode).toBe(200)
expect(response.statusMessage).toBe('')
})


it('abort the request if the abort signal is emitted', async () => {
const requestUrl = httpServer.http.url('/')

const requestEmitted = new DeferredPromise<void>()
interceptor.on('request', async function delayedResponse({ request }) {
requestEmitted.resolve()
await sleep(10000)
request.respondWith(new Response())
})

const abortController = new AbortController()
const request = http.get(requestUrl, { signal: abortController.signal })

await requestEmitted

abortController.abort()

const requestAborted = new DeferredPromise<void>()
request.on('error', function(err) {
expect(err.name).toEqual('AbortError')
requestAborted.resolve()
})

await requestAborted
})
22 changes: 21 additions & 1 deletion src/interceptors/fetch/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DeferredPromise } from '@open-draft/deferred-promise'
import { invariant } from 'outvariant'
import { until } from '@open-draft/until'
import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary'
Expand Down Expand Up @@ -46,13 +47,27 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {

this.logger.info('awaiting for the mocked response...')

const signal = interactiveRequest.signal
const requestAborted = new DeferredPromise()

signal.addEventListener(
'abort',
() => {
requestAborted.reject(signal.reason)
},
{ once: true }
)

const resolverResult = await until(async () => {
await this.emitter.untilIdle(
const allListenersResolved = this.emitter.untilIdle(
'request',
({ args: [{ requestId: pendingRequestId }] }) => {
return pendingRequestId === requestId
}
)

await Promise.race([requestAborted, allListenersResolved])

this.logger.info('all request listeners have been resolved!')

const [mockedResponse] = await interactiveRequest.respondWith.invoked()
Expand All @@ -61,10 +76,15 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
return mockedResponse
})

if (requestAborted.state === 'rejected') {
return Promise.reject(requestAborted.rejectionReason)
}

if (resolverResult.error) {
const error = Object.assign(new TypeError('Failed to fetch'), {
cause: resolverResult.error,
})

return Promise.reject(error)
}

Expand Down
131 changes: 131 additions & 0 deletions test/modules/fetch/compliance/abort-conrtoller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// @vitest-environment node
import { afterAll, beforeAll, expect, it } from 'vitest'
import { DeferredPromise } from '@open-draft/deferred-promise'
import { HttpServer } from '@open-draft/test-server/http'
import { FetchInterceptor } from '../../../../src/interceptors/fetch'
import { sleep } from '../../../helpers'

const httpServer = new HttpServer((app) => {
app.get('/', (_req, res) => {
res.status(200).send('/')
})
app.get('/get', (_req, res) => {
res.status(200).send('/get')
})
app.get('/delayed', (_req, res) => {
setTimeout(() => {
res.status(200).send('/delayed')
}, 1000)
})
})

const interceptor = new FetchInterceptor()

beforeAll(async () => {
interceptor.apply()
await httpServer.listen()
})

afterAll(async () => {
interceptor.dispose()
await httpServer.close()
})

it('aborts unsent request when the original request is aborted', async () => {
interceptor.on('request', () => {
expect.fail('must not sent the request')
})

const controller = new AbortController()
const request = fetch(httpServer.http.url('/'), {
signal: controller.signal,
})

const requestAborted = new DeferredPromise<NodeJS.ErrnoException>()
request.catch(requestAborted.resolve)

controller.abort()

const abortError = await requestAborted

expect(abortError.name).toBe('AbortError')
expect(abortError.code).toBe(20)
expect(abortError.message).toBe('This operation was aborted')
})

it('aborts a pending request when the original request is aborted', async () => {
const requestListenerCalled = new DeferredPromise<void>()
const requestAborted = new DeferredPromise<Error>()

interceptor.on('request', async ({ request }) => {
requestListenerCalled.resolve()
await sleep(1_000)
request.respondWith(new Response())
})

const controller = new AbortController()
const request = fetch(httpServer.http.url('/delayed'), {
signal: controller.signal,
}).then(() => {
expect.fail('must not return any response')
})

request.catch(requestAborted.resolve)
await requestListenerCalled

controller.abort()

const abortError = await requestAborted
expect(abortError.name).toBe('AbortError')
expect(abortError.message).toBe('This operation was aborted')
})

it('forwards custom abort reason to the request if aborted before it starts', async () => {
interceptor.on('request', () => {
expect.fail('must not sent the request')
})

const controller = new AbortController()
const request = fetch(httpServer.http.url('/'), {
signal: controller.signal,
})

const requestAborted = new DeferredPromise<NodeJS.ErrnoException>()
request.catch(requestAborted.resolve)

controller.abort(new Error('Custom abort reason'))

const abortError = await requestAborted
console.log({ abortError })

expect(abortError.name).toBe('Error')
expect(abortError.code).toBeUndefined()
expect(abortError.message).toBe('Custom abort reason')
})

it('forwards custom abort reason to the request if pending', async () => {
const requestListenerCalled = new DeferredPromise<void>()
const requestAborted = new DeferredPromise<Error>()

interceptor.on('request', async ({ request }) => {
requestListenerCalled.resolve()
await sleep(1_000)
request.respondWith(new Response())
})

const controller = new AbortController()
const request = fetch(httpServer.http.url('/delayed'), {
signal: controller.signal,
}).then(() => {
expect.fail('must not return any response')
})

request.catch(requestAborted.resolve)
await requestListenerCalled

controller.abort(new Error('Custom abort reason'))

const abortError = await requestAborted
expect(abortError.name).toBe('Error')
expect(abortError.message).toEqual('Custom abort reason')
})

0 comments on commit 33dcbe0

Please sign in to comment.