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
108 changes: 79 additions & 29 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,96 @@ describe('HttpLink', () => {
}),
);
});
itAsync('supports being cancelled and does not throw', (resolve, reject) => {
let called = false;
class AbortController {

describe('AbortController', () => {
let aborted = false;
let created = false;
beforeEach(() => { created = aborted = false; })

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

(global as any).AbortController = AbortController;

fetch.mockReturnValueOnce(Promise.resolve({ text }));
text.mockReturnValueOnce(
Promise.resolve('{ "data": { "hello": "world" } }'),
);

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

const sub = execute(link, { query: sampleQuery }).subscribe({
next: result => {
reject('result should not have been called');
const originalAbortController = globalThis.AbortController;
beforeAll(() => {
globalThis.AbortController = AbortControllerMock as any;
})
afterAll(() => {
globalThis.AbortController = originalAbortController;
})

beforeEach( () => {
fetch.mockResolvedValueOnce({ text });
text.mockResolvedValueOnce('{ "data": { "hello": "world" } }');
})
afterEach(() => {
fetch.mockReset();
text.mockReset();
})

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');
},
}
phryneas marked this conversation as resolved.
Show resolved Hide resolved

it('unsubscribing cancels internally created AbortController', () => {
const link = createHttpLink({ uri: 'data', fetch: fetch as any });

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

expect(created).toBe(true);
expect(aborted).toBe(true);
Copy link
Member

Choose a reason for hiding this comment

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

Another reason is that I prefer to inline the setup is that it isolates test state. Because we are asserting on test state that can be written by any test in this describe block, it is impossible to run these tests in parallel because any given test could update this state and affect another. If we can move this state to be local to the test, we don't have to worry about race conditions or test state that affects each other. These types of test failures are SUPER difficult to debug when they happen.

Copy link
Member Author

@phryneas phryneas Jul 6, 2023

Choose a reason for hiding this comment

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

Jest will never run multiple tests in the same file at the same time (unless you actively specify that they should, using test.concurrent) - test parallelism in jest is always about running multiple different files in different node instances at the same time. A file itself will always run top-to-bottom.
There can never be any collision between tests - or otherwise, these tests writing on globalThis, or any kind of mock, would be incredibly dangerous from the start :)

The only way parallelism affects test flimsyness is CPU usage: if the CPU is maxed out, tests just run slower.

Copy link
Member

Choose a reason for hiding this comment

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

Thats fair. I forget jest parallelism out-of-the-box is per-file, not per test.

My point still remains though that I tend to prefer avoiding shared state between multiple tests because they become impossible to debug if the tests need to run in a certain order to pass.

I like the refactor you did though extracting to a function. You're still able to reduce the boilerplate, but my concern about shared state is gone. Thanks for that!

Copy link
Member

Choose a reason for hiding this comment

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

This test is interesting to me. Based on the implementation and the description in the changeset, I would have expected aborted here to be false since "the AbortController will only be .abort()ed by outside events" (according to the changeset), yet I see no "outside events" in this test.

Looking through some of the other tests, perhaps a tweak to the name of the test would help? The fact that the request hasn't completed is relevant here.

My suggestion 👇

 it("unsubscribing cancels internally created AbortController when request hasn't completed"

Copy link
Member Author

Choose a reason for hiding this comment

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

sub.unsubscribe(); - tearing down the running link subscription because the result it isn't needed anymore would be that outside event.
But yeah, I can tweak the wording a bit :)

});
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 link = createHttpLink({ uri: 'data', fetch: fetch as any, fetchOptions: { signal: {} } });
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 link = createHttpLink({ uri: 'data', fetch: fetch as any, fetchOptions: { signal: {} } });
const controller = new AbortController();
const link = createHttpLink({ uri: 'data', fetch: fetch as any, fetchOptions: { signal: controller.signal } });

I'd prefer if we could test this like the real world to ensure it works as expected.

Yet another reason why I prefer inlining the setup since you'd benefit from not mocking out AbortController here 🙂

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 problem with using the unmocked AbortController would be that we cannot test if there is ever a new AbortController() call inside the link - I think mocking of globals is mostly impossible.
I'll see what I can do here.

Copy link
Member

Choose a reason for hiding this comment

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

I actually wonder how valuable this test is since its encroaching on the "testing internal implementation details" territory. We should only need to care what the observable end-user effects are, which in this case is that we don't want to abort requests after they've completed. Whether or not we create an abort controller internally or not technically shouldn't matter.

This would solve the "can't mock global" problem since you wouldn't need to check whether something was created internal to the link or not.

Thoughts?

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'll noodle on that for a bit. I feel like it's important to test that the external AbortSignal is not overwritten, so maybe I can adjust the test accordingly.


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

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

it('resolving fetch does not cause the AbortController to be aborted', async () => {
// (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(created).toBe(true);
expect(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
fetch.mockReset();
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(created).toBe(true);
expect(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
88 changes: 35 additions & 53 deletions src/link/http/parseAndCheckHttpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type ServerParseError = Error & {

export async function readMultipartBody<
T extends object = Record<string, unknown>
>(response: Response, observer: Observer<T>) {
>(response: Response, nextValue: (value: T) => void) {
if (TextDecoder === undefined) {
throw new Error(
"TextDecoder must be defined in the environment: please import a polyfill."
Expand Down Expand Up @@ -74,52 +74,47 @@ export async function readMultipartBody<
const body = message.slice(i);

if (body) {
try {
const result = parseJsonBody<T>(response, body);
if (
Object.keys(result).length > 1 ||
"data" in result ||
"incremental" in result ||
"errors" in result ||
"payload" in result
) {
if (isApolloPayloadResult(result)) {
let next = {};
if ("payload" in result) {
next = { ...result.payload };
}
if ("errors" in result) {
next = {
...next,
extensions: {
...("extensions" in next ? next.extensions : null as any),
[PROTOCOL_ERRORS_SYMBOL]: result.errors
},
};
}
observer.next?.(next as T);
} else {
// for the last chunk with only `hasNext: false`
// we don't need to call observer.next as there is no data/errors
observer.next?.(result);
const result = parseJsonBody<T>(response, body);
if (
Object.keys(result).length > 1 ||
"data" in result ||
"incremental" in result ||
"errors" in result ||
"payload" in result
) {
if (isApolloPayloadResult(result)) {
let next = {};
if ("payload" in result) {
next = { ...result.payload };
}
if ("errors" in result) {
next = {
...next,
extensions: {
...("extensions" in next ? next.extensions : null as any),
[PROTOCOL_ERRORS_SYMBOL]: result.errors
},
};
}
} else if (
// If the chunk contains only a "hasNext: false", we can call
// observer.complete() immediately.
Object.keys(result).length === 1 &&
"hasNext" in result &&
!result.hasNext
) {
observer.complete?.();
nextValue(next as T);
} else {
// for the last chunk with only `hasNext: false`
// we don't need to call observer.next as there is no data/errors
nextValue(result);
}
} catch (err) {
handleError(err, observer);
} else if (
// If the chunk contains only a "hasNext: false", we can call
// observer.complete() immediately.
Object.keys(result).length === 1 &&
"hasNext" in result &&
!result.hasNext
) {
return;
}
}
bi = buffer.indexOf(boundary);
}
}
observer.complete?.();
}

export function parseHeaders(headerText: string): Record<string, string> {
Expand Down Expand Up @@ -206,19 +201,6 @@ export function handleError(err: any, observer: Observer<any>) {
observer.error?.(err);
}

export function readJsonBody<T = Record<string, unknown>>(
response: Response,
operation: Operation,
observer: Observer<T>
) {
parseAndCheckHttpResponse(operation)(response)
.then((result) => {
observer.next?.(result);
observer.complete?.();
})
.catch((err) => handleError(err, observer));
}

export function parseAndCheckHttpResponse(operations: Operation | Operation[]) {
return (response: Response) =>
response
Expand Down