Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
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
})
96 changes: 96 additions & 0 deletions src/interceptors/fetch/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { DeferredPromise } from '@open-draft/deferred-promise'

Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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.name).toEqual('AbortError')
expect(err.code).toEqual(20)
expect(err.message).toEqual('This operation was aborted')
requestAborted.resolve()
})

controller.abort()

await requestAborted
})

it('native', async () => {
interceptor.dispose();
const requestUrl = httpServer.http.url('/');
const controller = new AbortController();
const requestAborted = new DeferredPromise<void>();

const request = fetch(requestUrl, { signal: controller.signal });
request.catch((err) => {
expect(err.name).toEqual('AbortError')
expect(err.code).toEqual(20)
expect(err.message).toEqual('This operation was aborted')
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
})
16 changes: 11 additions & 5 deletions 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
Comment thread
kettanaito marked this conversation as resolved.
const rejectWhenRequestAborted = new DeferredPromise<string>()

signal.addEventListener('abort', () => rejectWhenRequestAborted.reject())

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 All @@ -62,10 +71,7 @@ export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
})

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I believe this is incorrect. I copied the previous behavior from Undici and we must comply by it. Note that the FetchInterceptor is primarily meant for Node, and I trust Undici implement the spec rather faithfully.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd suggest we revert this particular change for now because it's not related to the abort controller support. We can discuss it as a separate improvement point, what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I checked the Undici implementation and there are only two causes of error : abortion and network issue.
Since the abortion error is well defined (specific class and specific error code), we can check the error type and reject accordingly.
If we don't do that, we deviate from production behavior.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I agree, we should do it the same way: handle the two error scenarios separately:

  • Keep what you've introduced for abort errors.
  • Revert what was there previously (the TypeError) to handle all the other errors (effectively, network errors).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is what I've done in my latest commit ;)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks so much for addressing it so quickly! Will give it the last round of review and let's get this published.

}

const mockedResponse = resolverResult.data
Expand Down