Skip to content

Commit 300ccfc

Browse files
authored
feat(execution): expose partial result on abort errors (#4674)
When execution is aborted while work is still resolving, the executor previously rejected with only the abort reason. That loses access to any response data and errors that execution can still produce while unwinding the in-flight operation. This PR wraps pre-result aborts in `AbortedGraphQLExecutionError` and attach the partial result as `abortedResult`. If the response has already been produced, `abortedResult` is that response value. If root execution is still pending, `abortedResult` is the original execution promise so callers can await the eventual partial response separately from the abort rejection. For example, if a query aborts while a field resolver is pending, `execute()` rejects with the original abort reason as the error message and cause, while `error.abortedResult` later resolves to a response such as `{ data: { blocker: null }, errors: [{ message: 'Aborted!', path: ['blocker'] }] }`. [Incremental execution follows the same pattern for the pending initial result, but aborts after the initial incremental payload continue to surface as rejection of `next` calls on the `subsequentResults` iterator (without this new wrapper). `abortedResult` is usually a a `Promise` rather than a value because since the `execute` promise rejects immediately, execution has not finished.] It may sometimes be a value, i.e. if the executor aborted internally during synchronous execution, which currently can happen if a resolver triggers the external abort signal or if there is a developer error, such as using a schema or query with defer/stream directives when using `execute` (instead of using `experimentalExecuteIncrementally`).
1 parent 85e545c commit 300ccfc

7 files changed

Lines changed: 314 additions & 9 deletions

File tree

src/__testUtils__/expectPromise.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { isPromise } from '../jsutils/isPromise.js';
55

66
interface PromiseExpectation {
77
toResolve: () => Promise<unknown>;
8-
toRejectWith: (message: string) => Promise<void>;
8+
toRejectWith: (message: string) => Promise<Error>;
99
}
1010

1111
export function expectPromise(maybePromise: unknown): PromiseExpectation {
@@ -18,7 +18,7 @@ export function expectPromise(maybePromise: unknown): PromiseExpectation {
1818
toResolve(): Promise<unknown> {
1919
return maybePromise;
2020
},
21-
async toRejectWith(message: string): Promise<void> {
21+
async toRejectWith(message: string): Promise<Error> {
2222
let caughtError: unknown;
2323
let resolved;
2424
let rejected = false;
@@ -38,6 +38,7 @@ export function expectPromise(maybePromise: unknown): PromiseExpectation {
3838

3939
expect(caughtError).to.be.an.instanceOf(Error);
4040
expect(caughtError).to.have.property('message', message);
41+
return caughtError as Error;
4142
},
4243
};
4344
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js';
2+
3+
export class AbortedGraphQLExecutionError<TResult> extends Error {
4+
readonly abortedResult: PromiseOrValue<TResult>;
5+
6+
constructor(reason: unknown, result: PromiseOrValue<TResult>) {
7+
super(getAbortReasonMessage(reason), { cause: reason });
8+
this.name = 'AbortedGraphQLExecutionError';
9+
this.abortedResult = result;
10+
}
11+
12+
get [Symbol.toStringTag](): string {
13+
return 'AbortedGraphQLExecutionError';
14+
}
15+
}
16+
17+
function getAbortReasonMessage(reason: unknown): string {
18+
if (reason instanceof Error) {
19+
return reason.message;
20+
}
21+
if (
22+
typeof reason === 'object' &&
23+
reason !== null &&
24+
'message' in reason &&
25+
typeof reason.message === 'string'
26+
) {
27+
return reason.message;
28+
}
29+
return String(reason);
30+
}

src/execution/Executor.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
} from '../type/definition.js';
4545
import type { GraphQLSchema } from '../type/schema.js';
4646

47+
import { AbortedGraphQLExecutionError } from './AbortedGraphQLExecutionError.js';
4748
import { withCancellation } from './cancellablePromise.js';
4849
import type {
4950
DeferUsage,
@@ -339,7 +340,7 @@ export class Executor<
339340
const { promise: cancellablePromise, abort: abortResultPromise } =
340341
withCancellation(promise.then((resolved) => this.finish(resolved)));
341342
this.abortResultPromise = () => {
342-
abortResultPromise(this.abortReason);
343+
abortResultPromise(this.createAbortedExecutionError(promise));
343344
};
344345
if (this.aborted) {
345346
this.abortResultPromise();
@@ -369,12 +370,18 @@ export class Executor<
369370

370371
finish<T>(result: T): T {
371372
if (this.aborted) {
372-
throw this.abortReason;
373+
throw this.createAbortedExecutionError(result);
373374
}
374375
this.aborted = true;
375376
return result;
376377
}
377378

379+
createAbortedExecutionError<T>(
380+
result: PromiseOrValue<T>,
381+
): AbortedGraphQLExecutionError<T> {
382+
return new AbortedGraphQLExecutionError(this.abortReason, result);
383+
}
384+
378385
getFinishSharedExecution(): () => void {
379386
const resolverAbortController = this.resolverAbortController;
380387
const asyncWorkFinishedHook =
@@ -649,10 +656,6 @@ export class Executor<
649656
fieldDetailsList: FieldDetailsList,
650657
path: Path,
651658
): void {
652-
if (this.aborted) {
653-
throw new Error('Aborted!');
654-
}
655-
656659
const error = locatedError(
657660
rawError,
658661
toNodes(fieldDetailsList),
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { AbortedGraphQLExecutionError } from '../AbortedGraphQLExecutionError.js';
5+
6+
describe('AbortedGraphQLExecutionError', () => {
7+
it('uses the original Error reason message and cause', () => {
8+
const reason = new Error('Original reason');
9+
const result = { data: { ok: true } };
10+
11+
const error = new AbortedGraphQLExecutionError(reason, result);
12+
13+
expect(error).to.be.instanceof(Error);
14+
expect(error).to.be.instanceof(AbortedGraphQLExecutionError);
15+
expect(error).to.include({
16+
name: 'AbortedGraphQLExecutionError',
17+
message: 'Original reason',
18+
cause: reason,
19+
abortedResult: result,
20+
});
21+
expect(Object.prototype.toString.call(error)).to.equal(
22+
'[object AbortedGraphQLExecutionError]',
23+
);
24+
});
25+
26+
it('uses the message property from non-Error reasons', () => {
27+
const reason = { message: 'Object reason' };
28+
const result = Promise.resolve({ data: null });
29+
30+
const error = new AbortedGraphQLExecutionError(reason, result);
31+
32+
expect(error).to.include({
33+
message: 'Object reason',
34+
cause: reason,
35+
abortedResult: result,
36+
});
37+
});
38+
39+
it('stringifies reasons without a message', () => {
40+
const error = new AbortedGraphQLExecutionError('String reason', {
41+
data: null,
42+
});
43+
44+
expect(error).to.include({
45+
message: 'String reason',
46+
cause: 'String reason',
47+
});
48+
});
49+
50+
it('stringifies null reasons', () => {
51+
const error = new AbortedGraphQLExecutionError(null, { data: null });
52+
53+
expect(error).to.include({
54+
message: 'null',
55+
cause: null,
56+
});
57+
});
58+
});

0 commit comments

Comments
 (0)