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

fix(fetch): respect "abort" event on the request signal #394

Merged
merged 15 commits into from
Sep 2, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
})
75 changes: 75 additions & 0 deletions src/interceptors/fetch/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { DeferredPromise } from '@open-draft/deferred-promise'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should rename this test file to abort-controller.test.ts and move it under test/modules/fetch/compliance where we store all integration tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same goes for the test added inside src/interceptors/ClientRequest/index.test.ts I assume ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can leave that one be, it doesn't concern itself with request handling but focuses on how the .respondWith() works in the context of the ClientRequest. I think it's fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean I've added a test abort abortion in this file

import { HttpServer } from '@open-draft/test-server/http'
import { afterAll, beforeAll, expect, it } from 'vitest'
import { FetchInterceptor } from '.'
import { sleep } from '../../../test/helpers'

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

const interceptor = new FetchInterceptor()

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

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


it('abort pending requests when manually aborted', async () => {
const requestUrl = httpServer.http.url('/')

interceptor.on('request', async function requestListener() {
expect.fail('request should never be received')
})

const controller = new AbortController()
const requestAborted = new DeferredPromise<void>()

const request = fetch(requestUrl, { signal: controller.signal })
request.catch((err) => {
expect(err.cause.name).toEqual('AbortError')
requestAborted.resolve()
})

controller.abort()

await requestAborted
})

it('abort ongoing requests when manually aborted', async () => {
const requestUrl = httpServer.http.url('/')

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

const controller = new AbortController()
const request = fetch(requestUrl, { signal: controller.signal })

const requestAborted = new DeferredPromise<void>()

request.catch((err) => {
expect(err.cause.name).toEqual('AbortError')
requestAborted.resolve()
})

await requestEmitted

controller.abort()

await requestAborted
})
11 changes: 10 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,21 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {

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

const signal = interactiveRequest.signal
kettanaito marked this conversation as resolved.
Show resolved Hide resolved
const rejectWhenRequestAborted = new DeferredPromise<string>()

signal.addEventListener('abort', () => rejectWhenRequestAborted.reject(signal.reason))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should implement it the way that user-caused aborts are treated as AbortError, as per spec.

If I'm reading this right, if the user aborts the request, we don't have to do anything about that except stop the request listener promise. If that's the case, then we should instead have a race between two promises that resolve.

But this may be more complicated than that. I'm trying to account for user-caused abort events while working on #398 and I could certainly use your opinion on what's the best way to approach this.

Copy link
Member

@kettanaito kettanaito Aug 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also use a built-in rejection mechanism .throwIfAborted on the signal (spec).

Perhaps it'd be more semantically correct to do that and handle the thrown AbortError as a special case below.

const resolverResult = await until(async () => {
  request.signal.throwIfAborted()

  // the rest of the request listener promises.
})

if (resolverResult.error) {
  if (resolverResult.error instanceof AbortError) {
    // We know request listener lookup halted due to
    // the operation being aborted.
    handleIfNeeded()
    return
  }

  // Handle other exceptions from the request listeners.
}

One concern here, as far as I understand, .throwIfAborted() is a one-time action. If the request wasn't aborted by the time this method is called, it will never throw even if the request will be aborted in the future.

Copy link
Contributor Author

@JesusTheHun JesusTheHun Aug 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will address your points in the near future but I wanted to let you know that I already have submitted a PR for #398

Edit : see PR #393

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kettanaito Outside of msw realm, when a request is aborted, the request/promise is rejected with a DOMException that have reason set to AbortError. Is there any reason to change this behavior ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have updated the PR to use the native error instead of a wrapped error.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outside of msw realm, when a request is aborted, the request/promise is rejected with a DOMException that have reason set to AbortError. Is there any reason to change this behavior ?

We'd have to look how Undici does this. I doubt they implement a DOMException just for this sake. I'm tempted to say they rely on a regular error with the cause set. I'm not sure where I'm proposing something related to that, please point me to that.

Thanks for championing this further! I will take a look once I have a minute.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line of code seems to break for me when I'm not setting an AbortController because signal is null in that case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line of code seems to break for me when I'm not setting an AbortController because signal is null in that case.

When no abort signal is has been provided to the intercepted request, one is injected automatically, so the signal cannot be null.
If you are facing any issue, please open one.


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

await Promise.race([rejectWhenRequestAborted, allListenerResolved])

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

const [mockedResponse] = await interactiveRequest.respondWith.invoked()
Expand Down