From 33dcbe0d89c180010b1e6e997a9013cfdca72fa2 Mon Sep 17 00:00:00 2001 From: Jesus The Hun Date: Sat, 2 Sep 2023 12:18:24 +0200 Subject: [PATCH] fix(fetch): respect "abort" event on the request signal (#394) Co-authored-by: Antonio Cheong Co-authored-by: Artem Zakharchenko Co-authored-by: Aleksey Ivasyuta Co-authored-by: avivasyuta --- src/interceptors/ClientRequest/index.test.ts | 28 ++++ src/interceptors/fetch/index.ts | 22 ++- .../fetch/compliance/abort-conrtoller.test.ts | 131 ++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 test/modules/fetch/compliance/abort-conrtoller.test.ts diff --git a/src/interceptors/ClientRequest/index.test.ts b/src/interceptors/ClientRequest/index.test.ts index 1df8c559..e54596c2 100644 --- a/src/interceptors/ClientRequest/index.test.ts +++ b/src/interceptors/ClientRequest/index.test.ts @@ -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) => { @@ -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() + 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() + request.on('error', function(err) { + expect(err.name).toEqual('AbortError') + requestAborted.resolve() + }) + + await requestAborted +}) diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index d8166463..3e192b76 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -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' @@ -46,13 +47,27 @@ export class FetchInterceptor extends Interceptor { 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() @@ -61,10 +76,15 @@ export class FetchInterceptor extends Interceptor { 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) } diff --git a/test/modules/fetch/compliance/abort-conrtoller.test.ts b/test/modules/fetch/compliance/abort-conrtoller.test.ts new file mode 100644 index 00000000..b690934d --- /dev/null +++ b/test/modules/fetch/compliance/abort-conrtoller.test.ts @@ -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() + 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() + const requestAborted = new DeferredPromise() + + 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() + 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() + const requestAborted = new DeferredPromise() + + 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') +})