Skip to content

Commit 33dcbe0

Browse files
JesusTheHunAntonio Cheongkettanaitoavivasyutaavivasyuta
authored
fix(fetch): respect "abort" event on the request signal (#394)
Co-authored-by: Antonio Cheong <acheong@student.dalat.org> Co-authored-by: Artem Zakharchenko <kettanaito@gmail.com> Co-authored-by: Aleksey Ivasyuta <avivasyuta@gmail.com> Co-authored-by: avivasyuta <avivasyuta@avito.ru>
1 parent d4257a5 commit 33dcbe0

3 files changed

Lines changed: 180 additions & 1 deletion

File tree

src/interceptors/ClientRequest/index.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import http from 'http'
33
import { HttpServer } from '@open-draft/test-server/http'
44
import { DeferredPromise } from '@open-draft/deferred-promise'
55
import { ClientRequestInterceptor } from '.'
6+
import { sleep } from '../../../test/helpers'
67

78
const httpServer = new HttpServer((app) => {
89
app.get('/', (_req, res) => {
@@ -55,3 +56,30 @@ it('forbids calling "respondWith" multiple times for the same request', async ()
5556
expect(response.statusCode).toBe(200)
5657
expect(response.statusMessage).toBe('')
5758
})
59+
60+
61+
it('abort the request if the abort signal is emitted', async () => {
62+
const requestUrl = httpServer.http.url('/')
63+
64+
const requestEmitted = new DeferredPromise<void>()
65+
interceptor.on('request', async function delayedResponse({ request }) {
66+
requestEmitted.resolve()
67+
await sleep(10000)
68+
request.respondWith(new Response())
69+
})
70+
71+
const abortController = new AbortController()
72+
const request = http.get(requestUrl, { signal: abortController.signal })
73+
74+
await requestEmitted
75+
76+
abortController.abort()
77+
78+
const requestAborted = new DeferredPromise<void>()
79+
request.on('error', function(err) {
80+
expect(err.name).toEqual('AbortError')
81+
requestAborted.resolve()
82+
})
83+
84+
await requestAborted
85+
})

src/interceptors/fetch/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DeferredPromise } from '@open-draft/deferred-promise'
12
import { invariant } from 'outvariant'
23
import { until } from '@open-draft/until'
34
import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary'
@@ -46,13 +47,27 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
4647

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

50+
const signal = interactiveRequest.signal
51+
const requestAborted = new DeferredPromise()
52+
53+
signal.addEventListener(
54+
'abort',
55+
() => {
56+
requestAborted.reject(signal.reason)
57+
},
58+
{ once: true }
59+
)
60+
4961
const resolverResult = await until(async () => {
50-
await this.emitter.untilIdle(
62+
const allListenersResolved = this.emitter.untilIdle(
5163
'request',
5264
({ args: [{ requestId: pendingRequestId }] }) => {
5365
return pendingRequestId === requestId
5466
}
5567
)
68+
69+
await Promise.race([requestAborted, allListenersResolved])
70+
5671
this.logger.info('all request listeners have been resolved!')
5772

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

79+
if (requestAborted.state === 'rejected') {
80+
return Promise.reject(requestAborted.rejectionReason)
81+
}
82+
6483
if (resolverResult.error) {
6584
const error = Object.assign(new TypeError('Failed to fetch'), {
6685
cause: resolverResult.error,
6786
})
87+
6888
return Promise.reject(error)
6989
}
7090

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// @vitest-environment node
2+
import { afterAll, beforeAll, expect, it } from 'vitest'
3+
import { DeferredPromise } from '@open-draft/deferred-promise'
4+
import { HttpServer } from '@open-draft/test-server/http'
5+
import { FetchInterceptor } from '../../../../src/interceptors/fetch'
6+
import { sleep } from '../../../helpers'
7+
8+
const httpServer = new HttpServer((app) => {
9+
app.get('/', (_req, res) => {
10+
res.status(200).send('/')
11+
})
12+
app.get('/get', (_req, res) => {
13+
res.status(200).send('/get')
14+
})
15+
app.get('/delayed', (_req, res) => {
16+
setTimeout(() => {
17+
res.status(200).send('/delayed')
18+
}, 1000)
19+
})
20+
})
21+
22+
const interceptor = new FetchInterceptor()
23+
24+
beforeAll(async () => {
25+
interceptor.apply()
26+
await httpServer.listen()
27+
})
28+
29+
afterAll(async () => {
30+
interceptor.dispose()
31+
await httpServer.close()
32+
})
33+
34+
it('aborts unsent request when the original request is aborted', async () => {
35+
interceptor.on('request', () => {
36+
expect.fail('must not sent the request')
37+
})
38+
39+
const controller = new AbortController()
40+
const request = fetch(httpServer.http.url('/'), {
41+
signal: controller.signal,
42+
})
43+
44+
const requestAborted = new DeferredPromise<NodeJS.ErrnoException>()
45+
request.catch(requestAborted.resolve)
46+
47+
controller.abort()
48+
49+
const abortError = await requestAborted
50+
51+
expect(abortError.name).toBe('AbortError')
52+
expect(abortError.code).toBe(20)
53+
expect(abortError.message).toBe('This operation was aborted')
54+
})
55+
56+
it('aborts a pending request when the original request is aborted', async () => {
57+
const requestListenerCalled = new DeferredPromise<void>()
58+
const requestAborted = new DeferredPromise<Error>()
59+
60+
interceptor.on('request', async ({ request }) => {
61+
requestListenerCalled.resolve()
62+
await sleep(1_000)
63+
request.respondWith(new Response())
64+
})
65+
66+
const controller = new AbortController()
67+
const request = fetch(httpServer.http.url('/delayed'), {
68+
signal: controller.signal,
69+
}).then(() => {
70+
expect.fail('must not return any response')
71+
})
72+
73+
request.catch(requestAborted.resolve)
74+
await requestListenerCalled
75+
76+
controller.abort()
77+
78+
const abortError = await requestAborted
79+
expect(abortError.name).toBe('AbortError')
80+
expect(abortError.message).toBe('This operation was aborted')
81+
})
82+
83+
it('forwards custom abort reason to the request if aborted before it starts', async () => {
84+
interceptor.on('request', () => {
85+
expect.fail('must not sent the request')
86+
})
87+
88+
const controller = new AbortController()
89+
const request = fetch(httpServer.http.url('/'), {
90+
signal: controller.signal,
91+
})
92+
93+
const requestAborted = new DeferredPromise<NodeJS.ErrnoException>()
94+
request.catch(requestAborted.resolve)
95+
96+
controller.abort(new Error('Custom abort reason'))
97+
98+
const abortError = await requestAborted
99+
console.log({ abortError })
100+
101+
expect(abortError.name).toBe('Error')
102+
expect(abortError.code).toBeUndefined()
103+
expect(abortError.message).toBe('Custom abort reason')
104+
})
105+
106+
it('forwards custom abort reason to the request if pending', async () => {
107+
const requestListenerCalled = new DeferredPromise<void>()
108+
const requestAborted = new DeferredPromise<Error>()
109+
110+
interceptor.on('request', async ({ request }) => {
111+
requestListenerCalled.resolve()
112+
await sleep(1_000)
113+
request.respondWith(new Response())
114+
})
115+
116+
const controller = new AbortController()
117+
const request = fetch(httpServer.http.url('/delayed'), {
118+
signal: controller.signal,
119+
}).then(() => {
120+
expect.fail('must not return any response')
121+
})
122+
123+
request.catch(requestAborted.resolve)
124+
await requestListenerCalled
125+
126+
controller.abort(new Error('Custom abort reason'))
127+
128+
const abortError = await requestAborted
129+
expect(abortError.name).toBe('Error')
130+
expect(abortError.message).toEqual('Custom abort reason')
131+
})

0 commit comments

Comments
 (0)