Skip to content

Commit

Permalink
fix: Improve abort handling (#320)
Browse files Browse the repository at this point in the history
  • Loading branch information
offirgolan committed Mar 31, 2020
1 parent c2e9744 commit cc46bb4
Show file tree
Hide file tree
Showing 12 changed files with 278 additions and 20 deletions.
18 changes: 18 additions & 0 deletions docs/server/events-and-middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,24 @@ server.any().on('error', (req, error) => {
});
```

### abort

Fires when a request is aborted.

| Param | Type | Description |
| ----- | ------------------------- | -------------------- |
| req | [Request](server/request) | The request instance |
| event | [Event](server/event) | The event instance |

**Example**

```js
server.any().on('abort', req => {
console.error('Request aborted.');
process.exit(1);
});
```

## Middleware

Middleware can be added via the `.any()` method.
Expand Down
33 changes: 32 additions & 1 deletion packages/@pollyjs/adapter-fetch/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import serializeHeaders from './utils/serializer-headers';

const { defineProperty } = Object;
const IS_STUBBED = Symbol();
const ABORT_HANDLER = Symbol();
const REQUEST_ARGUMENTS = Symbol();

export default class FetchAdapter extends Adapter {
Expand Down Expand Up @@ -134,6 +135,21 @@ export default class FetchAdapter extends Adapter {
this.NativeRequest = null;
}

onRequest(pollyRequest) {
const {
options: { signal }
} = pollyRequest.requestArguments;

if (signal) {
if (signal.aborted) {
pollyRequest.abort();
} else {
pollyRequest[ABORT_HANDLER] = () => pollyRequest.abort();
signal.addEventListener('abort', pollyRequest[ABORT_HANDLER]);
}
}
}

async passthroughRequest(pollyRequest) {
const { context } = this.options;
const { options } = pollyRequest.requestArguments;
Expand All @@ -159,7 +175,22 @@ export default class FetchAdapter extends Adapter {
const {
context: { Response }
} = this.options;
const { respond } = pollyRequest.requestArguments;
const {
respond,
options: { signal }
} = pollyRequest.requestArguments;

if (signal && pollyRequest[ABORT_HANDLER]) {
signal.removeEventListener('abort', pollyRequest[ABORT_HANDLER]);
}

if (pollyRequest.aborted) {
respond({
error: new DOMException('The user aborted a request.', 'AbortError')
});

return;
}

if (error) {
respond({ error });
Expand Down
50 changes: 50 additions & 0 deletions packages/@pollyjs/adapter-fetch/tests/integration/adapter-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,56 @@ describe('Integration | Fetch Adapter', function() {
expect(headers).to.deep.equal({ 'content-type': 'application/json' });
});

it('should handle aborting a request', async function() {
const { server } = this.polly;
const controller = new AbortController();
let abortEventCalled = false;
let error;

server
.any(this.recordUrl())
.on('request', () => controller.abort())
.on('abort', () => (abortEventCalled = true))
.intercept((_, res) => {
res.sendStatus(200);
});

try {
await this.fetchRecord({ signal: controller.signal });
} catch (e) {
error = e;
}

expect(abortEventCalled).to.equal(true);
expect(error.message).to.contain('The user aborted a request.');
});

it('should handle immediately aborting a request', async function() {
const { server } = this.polly;
const controller = new AbortController();
let abortEventCalled = false;
let error;

server
.any(this.recordUrl())
.on('abort', () => (abortEventCalled = true))
.intercept((_, res) => {
res.sendStatus(200);
});

try {
const promise = this.fetchRecord({ signal: controller.signal });

controller.abort();
await promise;
} catch (e) {
error = e;
}

expect(abortEventCalled).to.equal(true);
expect(error.message).to.contain('The user aborted a request.');
});

describe('Request', function() {
it('should support Request objects', async function() {
const { server } = this.polly;
Expand Down
26 changes: 25 additions & 1 deletion packages/@pollyjs/adapter-node-http/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import mergeChunks from './utils/merge-chunks';
import urlToOptions from './utils/url-to-options';

const IS_STUBBED = Symbol();
const ABORT_HANDLER = Symbol();
const REQUEST_ARGUMENTS = new WeakMap();

// nock begins to intercept network requests on import which is not the
Expand Down Expand Up @@ -151,6 +152,17 @@ export default class HttpAdapter extends Adapter {
});
}

onRequest(pollyRequest) {
const { req } = pollyRequest.requestArguments;

if (req.aborted) {
pollyRequest.abort();
} else {
pollyRequest[ABORT_HANDLER] = () => pollyRequest.abort();
req.once('abort', pollyRequest[ABORT_HANDLER]);
}
}

async passthroughRequest(pollyRequest) {
const { parsedArguments } = pollyRequest.requestArguments;
const { method, headers, body } = pollyRequest;
Expand Down Expand Up @@ -195,6 +207,19 @@ export default class HttpAdapter extends Adapter {

async respondToRequest(pollyRequest, error) {
const { req, respond } = pollyRequest.requestArguments;
const { statusCode, body, headers } = pollyRequest.response;

if (pollyRequest[ABORT_HANDLER]) {
req.off('abort', pollyRequest[ABORT_HANDLER]);
}

if (pollyRequest.aborted) {
// Even if the request has been aborted, we need to respond to the nock
// request in order to resolve its awaiting promise.
respond(null, [0, undefined, {}]);

return;
}

if (error) {
// If an error was received then forward it over to nock so it can
Expand All @@ -204,7 +229,6 @@ export default class HttpAdapter extends Adapter {
return;
}

const { statusCode, body, headers } = pollyRequest.response;
const chunks = this.getChunksFromBody(body, headers);
const stream = new ReadableStream();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,55 @@ function commonTests(transport) {

expect(nativeHash).to.equal(recordedHash);
});

it('should handle aborting a request', async function() {
const { server } = this.polly;
const url = `${protocol}//example.com`;
const req = transport.request(url);
let abortEventCalled = false;

server
.any(url)
.on('request', () => req.abort())
.on('abort', () => (abortEventCalled = true))
.intercept((_, res) => {
res.sendStatus(200);
});

try {
await getResponseFromRequest(req);
} catch (e) {
// no-op
}

await this.polly.flush();
expect(abortEventCalled).to.equal(true);
});

it('should handle immediately aborting a request', async function() {
const { server } = this.polly;
const url = `${protocol}//example.com`;
const req = transport.request(url);
let abortEventCalled = false;

server
.any(url)
.on('abort', () => (abortEventCalled = true))
.intercept((_, res) => {
res.sendStatus(200);
});

const promise = getResponseFromRequest(req);

req.abort();

try {
await promise;
} catch (e) {
// no-op
}

await this.polly.flush();
expect(abortEventCalled).to.equal(true);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export default function getResponseFromRequest(req, data) {
return new Promise((resolve, reject) => {
req.once('response', resolve);
req.once('error', reject);
req.once('abort', reject);

req.end(data);
});
Expand Down
22 changes: 20 additions & 2 deletions packages/@pollyjs/adapter-xhr/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import resolveXhr from './utils/resolve-xhr';
import serializeResponseHeaders from './utils/serialize-response-headers';

const SEND = Symbol();
const ABORT_HANDLER = Symbol();
const stubbedXhrs = new WeakSet();

export default class XHRAdapter extends Adapter {
Expand Down Expand Up @@ -61,10 +62,27 @@ export default class XHRAdapter extends Adapter {
this.xhr.restore();
}

onRequest(pollyRequest) {
const { xhr } = pollyRequest.requestArguments;

if (xhr.aborted) {
pollyRequest.abort();
} else {
pollyRequest[ABORT_HANDLER] = () => pollyRequest.abort();
xhr.addEventListener('abort', pollyRequest[ABORT_HANDLER]);
}
}

respondToRequest(pollyRequest, error) {
const { xhr } = pollyRequest.requestArguments;

if (error) {
if (pollyRequest[ABORT_HANDLER]) {
xhr.removeEventListener('abort', pollyRequest[ABORT_HANDLER]);
}

if (pollyRequest.aborted) {
return;
} else if (error) {
// If an error was received then call the `error` method on the fake XHR
// request provided by nise which will simulate a network error on the request.
// The onerror handler will be called and the status will be 0.
Expand All @@ -78,7 +96,7 @@ export default class XHRAdapter extends Adapter {
}

async passthroughRequest(pollyRequest) {
const fakeXhr = pollyRequest.requestArguments.xhr;
const { xhr: fakeXhr } = pollyRequest.requestArguments;
const xhr = new this.NativeXMLHttpRequest();

xhr.open(
Expand Down
46 changes: 41 additions & 5 deletions packages/@pollyjs/adapter-xhr/tests/integration/adapter-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,52 @@ describe('Integration | XHR Adapter', function() {
persister: InMemoryPersister
});

setupFetchRecord({
fetch() {
return xhrRequest(...arguments);
}
});
setupFetchRecord({ fetch: xhrRequest });
setupPolly.afterEach();

adapterTests();
adapterBrowserTests();
adapterIdentifierTests();

it('should handle aborting a request', async function() {
const { server } = this.polly;
const xhr = new XMLHttpRequest();
let abortEventCalled;

server
.any(this.recordUrl())
.on('request', () => xhr.abort())
.on('abort', () => (abortEventCalled = true))
.intercept((_, res) => {
res.sendStatus(200);
});

await this.fetchRecord({ xhr });
await this.polly.flush();

expect(abortEventCalled).to.equal(true);
});

it('should handle immediately aborting a request', async function() {
const { server } = this.polly;
const xhr = new XMLHttpRequest();
let abortEventCalled;

server
.any(this.recordUrl())
.on('abort', () => (abortEventCalled = true))
.intercept((_, res) => {
res.sendStatus(200);
});

const promise = this.fetchRecord({ xhr });

xhr.abort();
await promise;
await this.polly.flush();

expect(abortEventCalled).to.equal(true);
});
});

describe('Integration | XHR Adapter | Init', function() {
Expand Down
2 changes: 1 addition & 1 deletion packages/@pollyjs/adapter-xhr/tests/utils/xhr-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import serializeResponseHeaders from '../../src/utils/serialize-response-headers

export default function request(url, obj = {}) {
return new Promise(resolve => {
const xhr = new XMLHttpRequest();
const xhr = obj.xhr || new XMLHttpRequest();

xhr.open(obj.method || 'GET', url);

Expand Down
Loading

0 comments on commit cc46bb4

Please sign in to comment.