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
13 changes: 13 additions & 0 deletions .changeset/smooth-goats-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@apollo/client': minor
---

`HttpLink`/`BatchHttpLink`: Abort AbortController signal more granularly.
Before this change, when `HttpLink`/`BatchHttpLink` created an `AbortController`
internally, that would always be `.abort`ed after the request was finished in any
way.
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 finishing or erroring.
phryneas marked this conversation as resolved.
Show resolved Hide resolved
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';
import { BatchLink } from '../batch';
import { filterOperationVariables } from "../utils/filterOperationVariables";
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
122 changes: 91 additions & 31 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,106 @@ 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;

fetch.mockReturnValueOnce(Promise.resolve({ text }));
text.mockReturnValueOnce(
Promise.resolve('{ "data": { "hello": "world" } }'),
);
describe.only('AbortController', () => {
phryneas marked this conversation as resolved.
Show resolved Hide resolved
function mockGlobalAbortController() {
const mockStatus = {
aborted: false,
created: false
}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const mockStatus = {
aborted: false,
created: false
}
const mockStatus: 'unused' | 'created' | 'aborted' = 'unused';

Would something like this be easier to work with than 2 booleans? Its impossible to abort an AbortController that wasn't created, so this should encapsulate the behavior nicely. Feel free to use undefined in place of unused here if you prefer that to mean "never instantiated".

Its just a test though, so I'm not hung up on this, just figured the single value might be a bit nicer to work with than multiple flags.

Copy link
Member Author

Choose a reason for hiding this comment

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

I've given that another refactor - this is now all gone and we're working with a wrapped version of the original AbortController.

class AbortControllerMock {
constructor() {
mockStatus.created = true;
}
signal: {};
abort = () => {
mockStatus.aborted = true;
};
}

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

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');
},
}

// clean up between tests
const originalAbortController = globalThis.AbortController;
afterEach(() => {
globalThis.AbortController = originalAbortController;
fetch.mockReset();
text.mockReset();
})


it('unsubscribing cancels internally created AbortController when request hasn\'t completed', () => {
phryneas marked this conversation as resolved.
Show resolved Hide resolved
const mock = mockGlobalAbortController();
fetch.mockResolvedValueOnce({ text });
text.mockResolvedValueOnce('{ "data": { "hello": "world" } }');

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

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

expect(mock.created).toBe(true);
expect(mock.aborted).toBe(true);
phryneas marked this conversation as resolved.
Show resolved Hide resolved
});
phryneas marked this conversation as resolved.
Show resolved Hide resolved
sub.unsubscribe();

setTimeout(
makeCallback(resolve, reject, () => {
delete (global as any).AbortController;
expect(called).toBe(true);
fetch.mockReset();
text.mockReset();
}),
150,
);
it('passing a signal in means no AbortController is created internally', () => {
Copy link
Member

Choose a reason for hiding this comment

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

The more I think about this, the more I think this test isn't needed. It's testing an implementation detail rather than externally observable behavior. I feel like your next 2 tests test the observable behavior you're trying to fix in this PR and is plenty sufficient.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's pretty much the "this PR didn't remove intended existing behaviour" test - but I've changed it a bit, so now it should work without touching any implementation details.

const realAbortController = new AbortController();
const mock = mockGlobalAbortController();
fetch.mockResolvedValueOnce({ text });
text.mockResolvedValueOnce('{ "data": { "hello": "world" } }');

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

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

expect(mock.created).toBe(false);
expect(mock.aborted).toBe(false);
});

it('resolving fetch does not cause the AbortController to be aborted', async () => {
const mock = mockGlobalAbortController();
fetch.mockResolvedValueOnce({ text });
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(mock.created).toBe(true);
expect(mock.aborted).toBe(false);
});

it('throwing an error from fetch does not cause the AbortController to be aborted', async () => {
phryneas marked this conversation as resolved.
Show resolved Hide resolved
const mock = mockGlobalAbortController();
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(mock.created).toBe(true);
expect(mock.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';
import {
handleError,
readMultipartBody,
readJsonBody
parseAndCheckHttpResponse
} from './parseAndCheckHttpResponse';
import { checkFetcher } from './checkFetcher';
import type {
Expand All @@ -20,7 +20,6 @@ import {
defaultPrinter,
fallbackHttpConfig
} from './selectHttpOptionsAndBody';
import { createSignalIfSupported } from './createSignalIfSupported';
import { rewriteURIForGET } from './rewriteURIForGET';
import { fromError, filterOperationVariables } from '../utils';
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