Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/execution/ErrorBehavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** @category Execution */

/**
* The _error behavior_ to apply to a request, controlling how an _execution
* error_ affects the response. See the `onError` request parameter proposed
* in https://github.com/graphql/graphql-spec/pull/1236.
*
* - `NULL`: the erroring response position resolves to `null`, even if it is
* of a `Non-Null` type.
* - `PROPAGATE`: the erroring response position resolves to `null` if
* nullable, otherwise the error propagates to the nearest nullable parent
* position (or the entire response). This is the behavior used when
* `onError` is not provided.
* - `HALT`: execution of the current operation is stopped immediately and the
* response consists of only this one error, with `data` set to `null`.
* @experimental
*/
export type GraphQLErrorBehavior = 'NULL' | 'PROPAGATE' | 'HALT';

/** @internal */
export function isErrorBehavior(
onError: unknown,
): onError is GraphQLErrorBehavior {
return onError === 'NULL' || onError === 'PROPAGATE' || onError === 'HALT';
}
11 changes: 9 additions & 2 deletions src/execution/ExecutionArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
import type { GraphQLSchema } from '../type/schema.ts';

import type { FragmentDetails } from './collectFields.ts';
import type { GraphQLErrorBehavior } from './ErrorBehavior.ts';
import type { VariableValues } from './values.ts';

/** Arguments accepted by execute and executeSync. */
Expand All @@ -41,6 +42,12 @@ export interface ExecutionArgs {
subscribeFieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
/** Whether suggestion text should be omitted from request errors. */
hideSuggestions?: Maybe<boolean>;
/**
* The error behavior to apply to this request; see {@link GraphQLErrorBehavior}.
* Defaults to `"PROPAGATE"`.
* @experimental
*/
onError?: Maybe<GraphQLErrorBehavior>;
/** AbortSignal used to cancel execution. */
abortSignal?: Maybe<AbortSignal>;
/** Whether incremental execution may begin eligible work early. */
Expand Down Expand Up @@ -94,8 +101,8 @@ export interface ValidatedExecutionArgs {
subscribeFieldResolver: GraphQLFieldResolver<any, any>;
/** Whether suggestion text should be omitted from execution errors. */
hideSuggestions: boolean;
/** Whether execution should use error propagation. */
errorPropagation: boolean;
/** The error behavior applied to this request; see {@link GraphQLErrorBehavior}. */
errorBehavior: GraphQLErrorBehavior;
/** External signal that may abort execution. */
externalAbortSignal: AbortSignal | undefined;
/** Whether incremental execution may begin eligible work early. */
Expand Down
21 changes: 15 additions & 6 deletions src/execution/Executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,13 +725,22 @@ export class Executor<
pathToArray(path),
);

// If the field type is non-nullable, then it is resolved without any
// protection from errors, however it still properly locates the error.
if (
this.validatedExecutionArgs.errorPropagation &&
isNonNullType(returnType)
) {
const errorBehavior = this.validatedExecutionArgs.errorBehavior;
if (errorBehavior === 'PROPAGATE') {
// If the field type is non-nullable, then it is resolved without any
// protection from errors, however it still properly locates the error.
// Note: semantic non-null types are treated as nullable for the purposes
// of error handling.
if (isNonNullType(returnType)) {
throw error;
}
} else if (errorBehavior === 'HALT') {
// In this mode, any error aborts the request
throw error;
} else if (errorBehavior === 'NULL') {
// In this mode, the client takes responsibility for error handling, so we
// treat the field as if it were nullable.
/* c8 ignore next 6 */
}

// Otherwise, error protection is applied, logging the error and resolving
Expand Down
159 changes: 159 additions & 0 deletions src/execution/__tests__/onError-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { describe, it } from 'node:test';

import { expectJSON } from '../../__testUtils__/expectJSON.ts';

import { parse } from '../../language/parser.ts';

import { buildSchema } from '../../utilities/buildASTSchema.ts';

import type { GraphQLErrorBehavior } from '../ErrorBehavior.ts';
import { execute } from '../execute.ts';

const schema = buildSchema(`
type Query {
syncFoo: Int!
asyncFoo: Int!
bar: Int!
}
`);

const syncError = new Error('bar');

const rootValue = {
syncFoo() {
throw syncError;
},
asyncFoo() {
return Promise.reject(syncError);
},
bar: 42,
};

function executeQuery(query: string, onError?: GraphQLErrorBehavior) {
return execute({ schema, document: parse(query), rootValue, onError });
}

describe('Execute: onError', () => {
it('defaults to "PROPAGATE" when omitted', async () => {
const result = await executeQuery(`{ syncFoo bar }`);
expectJSON(result).toDeepEqual({
data: null,
errors: [
{
message: 'bar',
path: ['syncFoo'],
locations: [{ line: 1, column: 3 }],
},
],
});
});

it('"PROPAGATE" propagates a non-null error to the parent position', async () => {
const result = await executeQuery(`{ syncFoo bar }`, 'PROPAGATE');
expectJSON(result).toDeepEqual({
data: null,
errors: [
{
message: 'bar',
path: ['syncFoo'],
locations: [{ line: 1, column: 3 }],
},
],
});
});

it('"NULL" resolves the non-null position to null without propagating', async () => {
const result = await executeQuery(`{ syncFoo bar }`, 'NULL');
expectJSON(result).toDeepEqual({
data: { syncFoo: null, bar: 42 },
errors: [
{
message: 'bar',
path: ['syncFoo'],
locations: [{ line: 1, column: 3 }],
},
],
});
});

it('"NULL" works for asynchronous errors too', async () => {
const result = await executeQuery(`{ asyncFoo bar }`, 'NULL');
expectJSON(result).toDeepEqual({
data: { asyncFoo: null, bar: 42 },
errors: [
{
message: 'bar',
path: ['asyncFoo'],
locations: [{ line: 1, column: 3 }],
},
],
});
});

it('"HALT" stops execution and reports only the halting error', async () => {
const result = await executeQuery(`{ syncFoo bar }`, 'HALT');
expectJSON(result).toDeepEqual({
data: null,
errors: [
{
message: 'bar',
path: ['syncFoo'],
locations: [{ line: 1, column: 3 }],
},
],
});
});

it('"HALT" reports only the first error when multiple positions error', async () => {
const result = await executeQuery(`{ a: syncFoo b: syncFoo bar }`, 'HALT');
expectJSON(result).toDeepEqual({
data: null,
errors: [
{ message: 'bar', path: ['a'], locations: [{ line: 1, column: 3 }] },
],
});
});

it('takes precedence over `@experimental_disableErrorPropagation`', async () => {
const schemaWithDirective = buildSchema(`
type Query {
syncFoo: Int!
}

directive @experimental_disableErrorPropagation on QUERY | MUTATION | SUBSCRIPTION
`);
const result = await execute({
schema: schemaWithDirective,
document: parse(
`query getFoo @experimental_disableErrorPropagation { syncFoo }`,
),
rootValue,
onError: 'PROPAGATE',
});
expectJSON(result).toDeepEqual({
data: null,
errors: [
{
message: 'bar',
path: ['syncFoo'],
locations: [{ line: 1, column: 54 }],
},
],
});
});

it('rejects an invalid onError value as a request error', async () => {
const result = await executeQuery(
`{ bar }`,
'boom' as unknown as GraphQLErrorBehavior,
);
expectJSON(result).toDeepEqual({
errors: [
{
message:
'"onError" must be one of "NULL", "PROPAGATE", or "HALT", but got: "boom".',
},
],
});
});
});
19 changes: 17 additions & 2 deletions src/execution/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import { cancellablePromise } from './cancellablePromise.ts';
import type { FieldDetailsList, FragmentDetails } from './collectFields.ts';
import { collectFields } from './collectFields.ts';
import { createSharedExecutionContext } from './createSharedExecutionContext.ts';
import type { GraphQLErrorBehavior } from './ErrorBehavior.ts';
import { isErrorBehavior } from './ErrorBehavior.ts';
import type {
ExecutionArgs,
ValidatedExecutionArgs,
Expand Down Expand Up @@ -741,6 +743,7 @@ export function validateExecutionArgs(
fieldResolver,
typeResolver,
subscribeFieldResolver,
onError,
abortSignal: externalAbortSignal,
enableEarlyExecution,
hooks,
Expand All @@ -750,6 +753,16 @@ export function validateExecutionArgs(
// If the schema used for execution is invalid, throw an error.
assertValidSchema(schema);

if (onError != null && !isErrorBehavior(onError)) {
return [
new GraphQLError(
`"onError" must be one of "NULL", "PROPAGATE", or "HALT", but got: ${inspect(
onError,
)}.`,
),
];
}

let operation: OperationDefinitionNode | undefined;
const fragmentDefinitions: ObjMap<FragmentDefinitionNode> =
Object.create(null);
Expand Down Expand Up @@ -845,10 +858,12 @@ export function validateExecutionArgs(
return variableValuesOrErrors.errors;
}

const errorPropagation = !operation.directives?.find(
const disablesErrorPropagation = operation.directives?.some(
(directive) =>
directive.name.value === GraphQLDisableErrorPropagationDirective.name,
);
const errorBehavior: GraphQLErrorBehavior =
onError ?? (disablesErrorPropagation ? 'NULL' : 'PROPAGATE');

return {
schema,
Expand All @@ -863,7 +878,7 @@ export function validateExecutionArgs(
typeResolver: typeResolver ?? defaultTypeResolver,
subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
hideSuggestions,
errorPropagation,
errorBehavior,
externalAbortSignal: externalAbortSignal ?? undefined,
enableEarlyExecution: enableEarlyExecution === true,
hooks: hooks ?? undefined,
Expand Down
1 change: 1 addition & 0 deletions src/execution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type {
ValidatedExecutionArgs,
ValidatedSubscriptionArgs,
} from './ExecutionArgs.ts';
export type { GraphQLErrorBehavior } from './ErrorBehavior.ts';
export type { RootSelectionSetExecutor } from './execute.ts';

export type { ExecutionResult, FormattedExecutionResult } from './Executor.ts';
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export type {
RootSelectionSetExecutor,
AsyncWorkFinishedInfo,
ExecutionHooks,
GraphQLErrorBehavior,
VariableValues,
ValidatedExecutionArgs,
ValidatedSubscriptionArgs,
Expand Down
Loading