diff --git a/src/execution/ErrorBehavior.ts b/src/execution/ErrorBehavior.ts new file mode 100644 index 0000000000..473cc16048 --- /dev/null +++ b/src/execution/ErrorBehavior.ts @@ -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'; +} diff --git a/src/execution/ExecutionArgs.ts b/src/execution/ExecutionArgs.ts index 8428b24e80..2a92c00917 100644 --- a/src/execution/ExecutionArgs.ts +++ b/src/execution/ExecutionArgs.ts @@ -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. */ @@ -41,6 +42,12 @@ export interface ExecutionArgs { subscribeFieldResolver?: Maybe>; /** Whether suggestion text should be omitted from request errors. */ hideSuggestions?: Maybe; + /** + * The error behavior to apply to this request; see {@link GraphQLErrorBehavior}. + * Defaults to `"PROPAGATE"`. + * @experimental + */ + onError?: Maybe; /** AbortSignal used to cancel execution. */ abortSignal?: Maybe; /** Whether incremental execution may begin eligible work early. */ @@ -94,8 +101,8 @@ export interface ValidatedExecutionArgs { subscribeFieldResolver: GraphQLFieldResolver; /** 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. */ diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 88163c712f..69cbf9e77c 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -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 diff --git a/src/execution/__tests__/onError-test.ts b/src/execution/__tests__/onError-test.ts new file mode 100644 index 0000000000..3484c3afc6 --- /dev/null +++ b/src/execution/__tests__/onError-test.ts @@ -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".', + }, + ], + }); + }); +}); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 4dea28d5c4..f54db4e089 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -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, @@ -741,6 +743,7 @@ export function validateExecutionArgs( fieldResolver, typeResolver, subscribeFieldResolver, + onError, abortSignal: externalAbortSignal, enableEarlyExecution, hooks, @@ -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 = Object.create(null); @@ -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, @@ -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, diff --git a/src/execution/index.ts b/src/execution/index.ts index 7a791c995d..20c982975a 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -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'; diff --git a/src/index.ts b/src/index.ts index 219478ad42..51e90be11e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -396,6 +396,7 @@ export type { RootSelectionSetExecutor, AsyncWorkFinishedInfo, ExecutionHooks, + GraphQLErrorBehavior, VariableValues, ValidatedExecutionArgs, ValidatedSubscriptionArgs,