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

HttpLink/BatchHttpLink: Abort AbortController signal more granularly. #11040

Merged
merged 14 commits into from
Jul 12, 2023
Merged
10 changes: 10 additions & 0 deletions .changeset/smooth-goats-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@apollo/client': minor
---

`HttpLink`/`BatchHttpLink`: Abort the `AbortController` signal more granularly.
Before this change, when `HttpLink`/`BatchHttpLink` created an `AbortController`
internally, the signal would always be `.abort`ed after the request was completed. This could cause issues with Sentry Session Replay and Next.js App Router Cache invalidations, which just replayed the fetch with the same options - including the cancelled `AbortSignal`.

With this change, the `AbortController` will only be `.abort()`ed by outside events,
not as a consequence of the request completing.
99 changes: 98 additions & 1 deletion src/link/batch-http/__tests__/batchHttpLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { ASTNode, print, stripIgnoredCharacters } from 'graphql';

import { ApolloLink } from '../../core/ApolloLink';
import { execute } from '../../core/execute';
import { Observable } from '../../../utilities/observables/Observable';
import { Observable, Observer } from '../../../utilities/observables/Observable';
import { BatchHttpLink } from '../batchHttpLink';
import { itAsync } from '../../../testing';
import { FetchResult } from '../../core';

const sampleQuery = gql`
query SampleQuery {
Expand Down Expand Up @@ -1029,4 +1030,100 @@ describe('SharedHttpTest', () => {
new Error('BatchHttpLink: Trying to send a client-only query to the server. To send to the server, ensure a non-client field is added to the query or enable the `transformOptions.removeClientFields` option.')
);
});

describe('AbortController', () => {
const originalAbortController = globalThis.AbortController;
afterEach(() => {
globalThis.AbortController = originalAbortController;
});

function trackGlobalAbortControllers() {
const instances: AbortController[] = []
class AbortControllerMock {
constructor() {
const instance = new originalAbortController()
instances.push(instance)
return instance
}
}

globalThis.AbortController = AbortControllerMock as any;
return instances;
}

const failingObserver: Observer<FetchResult> = {
next: () => {
fail('result should not have been called');
},
error: e => {
fail(e);
},
complete: () => {
fail('complete should not have been called');
},
}

function mockFetch() {
const text = jest.fn(async () => '{}');
const fetch = jest.fn(async (uri, options) => ({ text }));
return { text, fetch }
}

it("aborts the request when unsubscribing before the request has completed", () => {
const { fetch } = mockFetch();
const abortControllers = trackGlobalAbortControllers();

const link = createHttpLink({ uri: 'data', fetch: fetch as any });

const sub = execute(link, { query: sampleQuery }).subscribe(failingObserver);
sub.unsubscribe();

expect(abortControllers.length).toBe(1);
expect(abortControllers[0].signal.aborted).toBe(true);
});

it('a passed-in signal will be forwarded to the `fetch` call and not be overwritten by an internally-created one', () => {
const { fetch } = mockFetch();
const externalAbortController = new AbortController();

const link = createHttpLink({ uri: 'data', fetch: fetch as any, fetchOptions: { signal: externalAbortController.signal } });

const sub = execute(link, { query: sampleQuery } ).subscribe(failingObserver);
sub.unsubscribe();

expect(fetch.mock.calls.length).toBe(1);
expect(fetch.mock.calls[0][1]).toEqual(expect.objectContaining({ signal: externalAbortController.signal }))
});

it('resolving fetch does not cause the AbortController to be aborted', async () => {
const { text, fetch } = mockFetch();
const abortControllers = trackGlobalAbortControllers();
text.mockResolvedValueOnce('{ "data": { "hello": "world" } }');

// (the request is already finished at that point)
const link = createHttpLink({ uri: 'data', fetch: fetch as any });

await new Promise<void>(resolve => execute(link, { query: sampleQuery }).subscribe({
complete: resolve
}));

expect(abortControllers.length).toBe(1);
expect(abortControllers[0].signal.aborted).toBe(false);
});

it('an unsuccessful fetch does not cause the AbortController to be aborted', async () => {
const { fetch } = mockFetch();
const abortControllers = trackGlobalAbortControllers();
fetch.mockRejectedValueOnce("This is an error!")
// the request would be closed by the browser in the case of an error anyways
const link = createHttpLink({ uri: 'data', fetch: fetch as any });

await new Promise<void>(resolve => execute(link, { query: sampleQuery }).subscribe({
error: resolve
}));

expect(abortControllers.length).toBe(1);
expect(abortControllers[0].signal.aborted).toBe(false);
});
});
});
12 changes: 6 additions & 6 deletions src/link/batch-http/batchHttpLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
selectHttpOptionsAndBodyInternal,
defaultPrinter,
fallbackHttpConfig,
createSignalIfSupported,
} from '../http/index.js';
import { BatchLink } from '../batch/index.js';
import { filterOperationVariables } from "../utils/filterOperationVariables.js";
Expand Down Expand Up @@ -157,11 +156,10 @@ export class BatchHttpLink extends ApolloLink {
return fromError<FetchResult[]>(parseError);
}

let controller: any;
if (!(options as any).signal) {
const { controller: _controller, signal } = createSignalIfSupported();
controller = _controller;
if (controller) (options as any).signal = signal;
let controller: AbortController | undefined;
Copy link
Member

Choose a reason for hiding this comment

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

🎉 Love this change. Love seeing any start to disappear!

if (!options.signal && typeof AbortController !== 'undefined') {
controller = new AbortController();
options.signal = controller.signal;
}

return new Observable<FetchResult[]>(observer => {
Expand All @@ -173,12 +171,14 @@ export class BatchHttpLink extends ApolloLink {
})
.then(parseAndCheckHttpResponse(operations))
.then(result => {
controller = undefined;
// we have data and can send it to back up the link chain
observer.next(result);
observer.complete();
return result;
})
.catch(err => {
controller = undefined;
// fetch was cancelled so its already been cleaned up in the unsubscribe
if (err.name === 'AbortError') return;
// if it is a network error, BUT there is graphql result info
Expand Down
115 changes: 85 additions & 30 deletions src/link/http/__tests__/HttpLink.ts
Copy link
Member

Choose a reason for hiding this comment

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

Any chance you could add some of these tests for batch http link? Since the batch link doesn't use HttpLink under the hood, this should give us confidence that implementation works as expected (plus it provides documentation that the batch link also works this way)

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure thing.

Copy link
Member Author

Choose a reason for hiding this comment

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

Once you're happy with how the tests are written for the HttpLink, I'll recreate them over there :)

Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { HttpLink } from '../HttpLink';
import { createHttpLink } from '../createHttpLink';
import { ClientParseError } from '../serializeFetchParameter';
import { ServerParseError } from '../parseAndCheckHttpResponse';
import { ServerError } from '../../..';
import { FetchResult, ServerError } from '../../..';
import { voidFetchDuringEachTest } from './helpers';
import { itAsync } from '../../../testing';

Expand Down Expand Up @@ -1325,46 +1325,101 @@ describe('HttpLink', () => {
}),
);
});
itAsync('supports being cancelled and does not throw', (resolve, reject) => {
let called = false;
class AbortController {
signal: {};
abort = () => {
called = true;
};
}

(global as any).AbortController = AbortController;
describe('AbortController', () => {
const originalAbortController = globalThis.AbortController;
afterEach(() => {
globalThis.AbortController = originalAbortController;
});

fetch.mockReturnValueOnce(Promise.resolve({ text }));
text.mockReturnValueOnce(
Promise.resolve('{ "data": { "hello": "world" } }'),
);
function trackGlobalAbortControllers() {
const instances: AbortController[] = []
class AbortControllerMock {
constructor() {
const instance = new originalAbortController()
instances.push(instance)
return instance
}
}

const link = createHttpLink({ uri: 'data', fetch: fetch as any });
globalThis.AbortController = AbortControllerMock as any;
return instances;
}

const sub = execute(link, { query: sampleQuery }).subscribe({
next: result => {
reject('result should not have been called');
const failingObserver: Observer<FetchResult> = {
next: () => {
fail('result should not have been called');
},
error: e => {
reject(e);
fail(e);
},
complete: () => {
reject('complete should not have been called');
fail('complete should not have been called');
},
}

function mockFetch() {
const text = jest.fn(async () => '{}');
const fetch = jest.fn(async (uri, options) => ({ text }));
return { text, fetch }
}
Comment on lines +1361 to +1365
Copy link
Member Author

Choose a reason for hiding this comment

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

The global text and fetch mocks in this file are... weird. They do not properly reset in-between tests, and I'm too afraid to touch that, so here's this new helper for these new tests instead, which creates non-global helpers and doesn't influence other types.


it("aborts the request when unsubscribing before the request has completed", () => {
const { fetch } = mockFetch();
const abortControllers = trackGlobalAbortControllers();

const link = createHttpLink({ uri: 'data', fetch: fetch as any });

const sub = execute(link, { query: sampleQuery }).subscribe(failingObserver);
sub.unsubscribe();

expect(abortControllers.length).toBe(1);
expect(abortControllers[0].signal.aborted).toBe(true);
});
sub.unsubscribe();

setTimeout(
makeCallback(resolve, reject, () => {
delete (global as any).AbortController;
expect(called).toBe(true);
fetch.mockReset();
text.mockReset();
}),
150,
);
it('a passed-in signal will be forwarded to the `fetch` call and not be overwritten by an internally-created one', () => {
const { fetch } = mockFetch();
const externalAbortController = new AbortController();

const link = createHttpLink({ uri: 'data', fetch: fetch as any, fetchOptions: { signal: externalAbortController.signal } });

const sub = execute(link, { query: sampleQuery } ).subscribe(failingObserver);
sub.unsubscribe();

expect(fetch.mock.calls.length).toBe(1);
expect(fetch.mock.calls[0][1]).toEqual(expect.objectContaining({ signal: externalAbortController.signal }))
});

it('resolving fetch does not cause the AbortController to be aborted', async () => {
const { text, fetch } = mockFetch();
const abortControllers = trackGlobalAbortControllers();
text.mockResolvedValueOnce('{ "data": { "hello": "world" } }');

// (the request is already finished at that point)
const link = createHttpLink({ uri: 'data', fetch: fetch as any });

await new Promise<void>(resolve => execute(link, { query: sampleQuery }).subscribe({
complete: resolve
}));

expect(abortControllers.length).toBe(1);
expect(abortControllers[0].signal.aborted).toBe(false);
});

it('an unsuccessful fetch does not cause the AbortController to be aborted', async () => {
const { fetch } = mockFetch();
const abortControllers = trackGlobalAbortControllers();
fetch.mockRejectedValueOnce("This is an error!")
// the request would be closed by the browser in the case of an error anyways
const link = createHttpLink({ uri: 'data', fetch: fetch as any });

await new Promise<void>(resolve => execute(link, { query: sampleQuery }).subscribe({
error: resolve
}));

expect(abortControllers.length).toBe(1);
expect(abortControllers[0].signal.aborted).toBe(false);
});
});

const body = '{';
Expand Down
26 changes: 16 additions & 10 deletions src/link/http/createHttpLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { selectURI } from './selectURI.js';
import {
handleError,
readMultipartBody,
readJsonBody
parseAndCheckHttpResponse
} from './parseAndCheckHttpResponse.js';
import { checkFetcher } from './checkFetcher.js';
import type {
Expand All @@ -20,7 +20,6 @@ import {
defaultPrinter,
fallbackHttpConfig
} from './selectHttpOptionsAndBody.js';
import { createSignalIfSupported } from './createSignalIfSupported.js';
import { rewriteURIForGET } from './rewriteURIForGET.js';
import { fromError, filterOperationVariables } from '../utils/index.js';
import {
Expand Down Expand Up @@ -119,11 +118,10 @@ export const createHttpLink = (linkOptions: HttpOptions = {}) => {
body.variables = filterOperationVariables(body.variables, operation.query);
}

let controller: any;
if (!(options as any).signal) {
const { controller: _controller, signal } = createSignalIfSupported();
controller = _controller;
if (controller) (options as any).signal = signal;
let controller: AbortController | undefined;
if (!options.signal && typeof AbortController !== 'undefined') {
controller = new AbortController();
options.signal = controller.signal;
}

// If requested, set method to GET if there are no mutations.
Expand Down Expand Up @@ -182,18 +180,26 @@ export const createHttpLink = (linkOptions: HttpOptions = {}) => {
// removal of window.fetch, which is unlikely but not impossible.
const currentFetch = preferredFetch || maybe(() => fetch) || backupFetch;

const observerNext = observer.next.bind(observer);
currentFetch!(chosenURI, options)
.then(response => {
operation.setContext({ response });
const ctype = response.headers?.get('content-type');

if (ctype !== null && /^multipart\/mixed/i.test(ctype)) {
return readMultipartBody(response, observer);
return readMultipartBody(response, observerNext);
} else {
return readJsonBody(response, operation, observer);
return parseAndCheckHttpResponse(operation)(response).then(observerNext);
}
})
.catch(err => handleError(err, observer));
.then(() => {
controller = undefined;
observer.complete();
phryneas marked this conversation as resolved.
Show resolved Hide resolved
})
.catch(err => {
controller = undefined;
handleError(err, observer)
});

return () => {
// XXX support canceling this request
Expand Down
5 changes: 5 additions & 0 deletions src/link/http/createSignalIfSupported.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* @deprecated
* This is not used internally any more and will be removed in
* the next major version of Apollo Client.
*/
export const createSignalIfSupported = () => {
if (typeof AbortController === 'undefined')
return { controller: false, signal: false };
Expand Down
Loading