diff --git a/.api-reports/api-report-core.api.md b/.api-reports/api-report-core.api.md index 75937521116..4bc22faf6ef 100644 --- a/.api-reports/api-report-core.api.md +++ b/.api-reports/api-report-core.api.md @@ -259,9 +259,9 @@ export class ApolloLink { // @public (undocumented) export interface ApolloPayloadResult, TExtensions = Record> { // (undocumented) - errors?: ReadonlyArray; + errors?: ReadonlyArray; // (undocumented) - payload: SingleExecutionResult | ExecutionPatchResult | null; + payload: SingleExecutionResult | ExecutionPatchResult | null; } // @public (undocumented) diff --git a/.api-reports/api-report-link_core.api.md b/.api-reports/api-report-link_core.api.md index 32f6e56f608..173736f0ee9 100644 --- a/.api-reports/api-report-link_core.api.md +++ b/.api-reports/api-report-link_core.api.md @@ -43,9 +43,11 @@ export class ApolloLink { // @public (undocumented) export interface ApolloPayloadResult, TExtensions = Record> { // (undocumented) - errors?: ReadonlyArray; + errors?: ReadonlyArray; + // Warning: (ae-forgotten-export) The symbol "DefaultContext" needs to be exported by the entry point index.d.ts + // // (undocumented) - payload: SingleExecutionResult | ExecutionPatchResult | null; + payload: SingleExecutionResult | ExecutionPatchResult | null; } // @public (undocumented) @@ -106,8 +108,6 @@ export const from: typeof ApolloLink.from; // @public (undocumented) export interface GraphQLRequest> { - // Warning: (ae-forgotten-export) The symbol "DefaultContext" needs to be exported by the entry point index.d.ts - // // (undocumented) context?: DefaultContext; // (undocumented) diff --git a/.api-reports/api-report-link_error.api.md b/.api-reports/api-report-link_error.api.md index 92cbae62eb5..bf6e3185840 100644 --- a/.api-reports/api-report-link_error.api.md +++ b/.api-reports/api-report-link_error.api.md @@ -6,6 +6,7 @@ import type { DocumentNode } from 'graphql'; import type { FormattedExecutionResult } from 'graphql'; +import type { GraphQLErrorExtensions } from 'graphql'; import type { GraphQLFormattedError } from 'graphql'; import { Observable } from 'zen-observable-ts'; import type { Observer } from 'zen-observable-ts'; @@ -80,14 +81,15 @@ export class ErrorLink extends ApolloLink { export interface ErrorResponse { // (undocumented) forward: NextLink; - // (undocumented) graphQLErrors?: ReadonlyArray; // Warning: (ae-forgotten-export) The symbol "NetworkError" needs to be exported by the entry point index.d.ts - // - // (undocumented) networkError?: NetworkError; // (undocumented) operation: Operation; + protocolErrors?: ReadonlyArray<{ + message: string; + extensions?: GraphQLErrorExtensions[]; + }>; // (undocumented) response?: FormattedExecutionResult; } diff --git a/.api-reports/api-report-utilities.api.md b/.api-reports/api-report-utilities.api.md index 6f944d1a205..2040d463195 100644 --- a/.api-reports/api-report-utilities.api.md +++ b/.api-reports/api-report-utilities.api.md @@ -313,12 +313,12 @@ class ApolloLink { // @public (undocumented) interface ApolloPayloadResult, TExtensions = Record> { // (undocumented) - errors?: ReadonlyArray; + errors?: ReadonlyArray; // Warning: (ae-forgotten-export) The symbol "SingleExecutionResult" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ExecutionPatchResult" needs to be exported by the entry point index.d.ts // // (undocumented) - payload: SingleExecutionResult | ExecutionPatchResult | null; + payload: SingleExecutionResult | ExecutionPatchResult | null; } // @public (undocumented) diff --git a/.api-reports/api-report.api.md b/.api-reports/api-report.api.md index f56f037b9df..5d15453120a 100644 --- a/.api-reports/api-report.api.md +++ b/.api-reports/api-report.api.md @@ -282,9 +282,9 @@ export class ApolloLink { // @public (undocumented) export interface ApolloPayloadResult, TExtensions = Record> { // (undocumented) - errors?: ReadonlyArray; + errors?: ReadonlyArray; // (undocumented) - payload: SingleExecutionResult | ExecutionPatchResult | null; + payload: SingleExecutionResult | ExecutionPatchResult | null; } // Warning: (ae-forgotten-export) The symbol "ApolloProviderProps" needs to be exported by the entry point index.d.ts diff --git a/.changeset/giant-peas-lie.md b/.changeset/giant-peas-lie.md new file mode 100644 index 00000000000..27e4096c99c --- /dev/null +++ b/.changeset/giant-peas-lie.md @@ -0,0 +1,13 @@ +--- +"@apollo/client": patch +--- + +Make fatal [tranport-level errors](https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol#message-and-error-format) from multipart subscriptions available to the error link with the `protocolErrors` property. + +```js +const errorLink = onError(({ protocolErrors }) => { + if (protocolErrors) { + console.log(protocolErrors); + } +}); +``` diff --git a/.changeset/mighty-shoes-clap.md b/.changeset/mighty-shoes-clap.md new file mode 100644 index 00000000000..da087271edb --- /dev/null +++ b/.changeset/mighty-shoes-clap.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Fix the array type for the `errors` field on the `ApolloPayloadResult` type. This type was always in the shape of the GraphQL error format, per the [multipart subscriptions protocol](https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol#message-and-error-format) and never a plain string or a JavaScript error object. diff --git a/docs/source/api/link/apollo-link-error.md b/docs/source/api/link/apollo-link-error.md index 4b1052d8a85..138c9eaf1f5 100644 --- a/docs/source/api/link/apollo-link-error.md +++ b/docs/source/api/link/apollo-link-error.md @@ -10,14 +10,23 @@ Use the `onError` link to perform custom logic when a [GraphQL or network error] ```js import { onError } from "@apollo/client/link/error"; -// Log any GraphQL errors or network error that occurred -const errorLink = onError(({ graphQLErrors, networkError }) => { +// Log any GraphQL errors, protocol errors, or network error that occurred +const errorLink = onError(({ graphQLErrors, networkError, protocolErrors }) => { if (graphQLErrors) graphQLErrors.forEach(({ message, locations, path }) => console.log( `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` ) ); + + if (protocolErrors) { + protocolErrors.forEach(({ message, extensions }) => { + console.log( + `[Protocol error]: Message: ${message}, Extensions: ${JSON.stringify(extensions)}` + ); + }); + } + if (networkError) console.log(`[Network error]: ${networkError}`); }); ``` @@ -100,6 +109,20 @@ A network error that occurred while attempting to execute the operation, if any. + + + +###### `protocolErrors` + +`ReadonlyArray<{ message: string; extensions?: GraphQLErrorExtensions[]; }>` + + + +Fatal transport-level errors from multipart subscriptions. See the [multipart subscription protocol](https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol#message-and-error-format) for more information. + + + + diff --git a/package-lock.json b/package-lock.json index 5f48dcce505..bceaa0a8f36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "glob": "8.1.0", "globals": "15.14.0", "graphql": "16.9.0", + "graphql-17-alpha2": "npm:graphql@17.0.0-alpha.2", "graphql-ws": "5.16.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", @@ -7792,6 +7793,17 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/graphql-17-alpha2": { + "name": "graphql", + "version": "17.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.0-alpha.2.tgz", + "integrity": "sha512-aRAd/BQ5hSO0+l7x+sHBfJVUp2JUOjPTE/iwJ3BhtYNH/MC7n4gjlZbKvnBVFZZAczyMS3vezS4teEZivoqIzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.19.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/graphql-tag": { "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", diff --git a/package.json b/package.json index 9e335e82256..c76c26acf98 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "glob": "8.1.0", "globals": "15.14.0", "graphql": "16.9.0", + "graphql-17-alpha2": "npm:graphql@17.0.0-alpha.2", "graphql-ws": "5.16.0", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", diff --git a/src/link/core/types.ts b/src/link/core/types.ts index c596ecac0c2..22a83844a46 100644 --- a/src/link/core/types.ts +++ b/src/link/core/types.ts @@ -50,10 +50,13 @@ export interface ApolloPayloadResult< TData = Record, TExtensions = Record, > { - payload: SingleExecutionResult | ExecutionPatchResult | null; + payload: + | SingleExecutionResult + | ExecutionPatchResult + | null; // Transport layer errors (as distinct from GraphQL or NetworkErrors), // these are fatal errors that will include done: true. - errors?: ReadonlyArray; + errors?: ReadonlyArray; } export type ExecutionPatchResult< diff --git a/src/link/error/__tests__/index.ts b/src/link/error/__tests__/index.ts index 0a3bf2bbfb8..9fe9cf257af 100644 --- a/src/link/error/__tests__/index.ts +++ b/src/link/error/__tests__/index.ts @@ -6,6 +6,11 @@ import { ServerError, throwServerError } from "../../utils/throwServerError"; import { Observable } from "../../../utilities/observables/Observable"; import { onError, ErrorLink } from "../"; import { ObservableStream } from "../../../testing/internal"; +import { PROTOCOL_ERRORS_SYMBOL } from "../../../errors"; +import { + mockDeferStream, + mockMultipartSubscriptionStream, +} from "../../../testing/internal/incremental"; describe("error handling", () => { it("has an easy way to handle GraphQL errors", async () => { @@ -71,6 +76,130 @@ describe("error handling", () => { expect(called).toBe(true); }); + it.failing("handles protocol errors (@defer)", async () => { + // TODO: this test doesn't execute the `errorHandler` yet. Should be 4, is 2. + fail(); + expect.assertions(4); + const query = gql` + query Foo { + foo { + ... @defer { + bar + } + } + } + `; + + const errorLink = onError(({ operation, protocolErrors }) => { + expect(operation.operationName).toBe("Foo"); + expect(protocolErrors).toEqual([ + { + message: "could not read data", + extensions: { + code: "INCREMENTAL_ERROR", + }, + }, + ]); + }); + + const { httpLink, enqueueInitialChunk, enqueueErrorChunk } = + mockDeferStream(); + const link = errorLink.concat(httpLink); + const stream = new ObservableStream(execute(link, { query })); + + enqueueInitialChunk({ + hasNext: true, + data: {}, + }); + + enqueueErrorChunk([ + { + message: "could not read data", + extensions: { + code: "INCREMENTAL_ERROR", + }, + }, + ]); + await expect(stream).toEmitValue({ + data: {}, + hasNext: true, + }); + + await expect(stream).toEmitValue({ + hasNext: true, + incremental: [ + { + errors: [ + { + message: "could not read data", + extensions: { + code: "INCREMENTAL_ERROR", + }, + }, + ], + }, + ], + }); + }); + + it("handles protocol errors (multipart subscription)", async () => { + expect.assertions(4); + const sampleSubscription = gql` + subscription MySubscription { + aNewDieWasCreated { + die { + roll + sides + color + } + } + } + `; + + const errorLink = onError((args) => { + const { operation, protocolErrors } = args; + expect(operation.operationName).toBe("MySubscription"); + expect(protocolErrors).toEqual([ + { + message: "Error field", + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }, + ]); + }); + + const { httpLink, enqueuePayloadResult, enqueueProtocolErrors } = + mockMultipartSubscriptionStream(); + const link = errorLink.concat(httpLink); + const stream = new ObservableStream( + execute(link, { query: sampleSubscription }) + ); + + enqueuePayloadResult({ + data: { aNewDieWasCreated: { die: { color: "red", roll: 1, sides: 4 } } }, + }); + + enqueueProtocolErrors([ + { message: "Error field", extensions: { code: "INTERNAL_SERVER_ERROR" } }, + ]); + + await expect(stream).toEmitValue({ + data: { aNewDieWasCreated: { die: { color: "red", roll: 1, sides: 4 } } }, + }); + + await expect(stream).toEmitValue({ + extensions: { + [PROTOCOL_ERRORS_SYMBOL]: [ + { + extensions: { + code: "INTERNAL_SERVER_ERROR", + }, + message: "Error field", + }, + ], + }, + }); + }); + it("captures errors within links", async () => { const query = gql` query Foo { @@ -356,6 +485,62 @@ describe("error handling with class", () => { expect(called).toBe(true); }); + it("handles protocol errors (multipart subscription)", async () => { + expect.assertions(4); + const subscription = gql` + subscription MySubscription { + aNewDieWasCreated { + die { + roll + sides + color + } + } + } + `; + + const { httpLink, enqueuePayloadResult, enqueueProtocolErrors } = + mockMultipartSubscriptionStream(); + + const errorLink = new ErrorLink(({ operation, protocolErrors }) => { + expect(operation.operationName).toBe("MySubscription"); + expect(protocolErrors).toEqual([ + { + message: "Error field", + extensions: { code: "INTERNAL_SERVER_ERROR" }, + }, + ]); + }); + + const link = errorLink.concat(httpLink); + const stream = new ObservableStream(execute(link, { query: subscription })); + + enqueuePayloadResult({ + data: { aNewDieWasCreated: { die: { color: "red", roll: 1, sides: 4 } } }, + }); + + enqueueProtocolErrors([ + { message: "Error field", extensions: { code: "INTERNAL_SERVER_ERROR" } }, + ]); + + await expect(stream).toEmitValue({ + data: { aNewDieWasCreated: { die: { color: "red", roll: 1, sides: 4 } } }, + }); + + await expect(stream).toEmitValue({ + extensions: { + [PROTOCOL_ERRORS_SYMBOL]: [ + { + extensions: { + code: "INTERNAL_SERVER_ERROR", + }, + message: "Error field", + }, + ], + }, + }); + }); + it("captures errors within links", async () => { const query = gql` { @@ -550,6 +735,63 @@ describe("support for request retrying", () => { await expect(stream).toComplete(); }); + it("supports retrying when the initial request had protocol errors", async () => { + let errorHandlerCalled = false; + + const { httpLink, enqueuePayloadResult, enqueueProtocolErrors } = + mockMultipartSubscriptionStream(); + + const errorLink = new ErrorLink( + ({ protocolErrors, operation, forward }) => { + if (protocolErrors) { + errorHandlerCalled = true; + expect(protocolErrors).toEqual([ + { + message: "cannot read message from websocket", + extensions: { + code: "WEBSOCKET_MESSAGE_ERROR", + }, + }, + ]); + return forward(operation); + } + } + ); + + const link = errorLink.concat(httpLink); + const stream = new ObservableStream( + execute(link, { + query: gql` + subscription Foo { + foo { + bar + } + } + `, + }) + ); + + enqueuePayloadResult({ data: { foo: { bar: true } } }); + + await expect(stream).toEmitValue({ data: { foo: { bar: true } } }); + + enqueueProtocolErrors([ + { + message: "cannot read message from websocket", + extensions: { + code: "WEBSOCKET_MESSAGE_ERROR", + }, + }, + ]); + + enqueuePayloadResult({ data: { foo: { bar: true } } }, false); + + // Ensure the error result is not emitted but rather the retried result + await expect(stream).toEmitValue({ data: { foo: { bar: true } } }); + expect(errorHandlerCalled).toBe(true); + await expect(stream).toComplete(); + }); + it("returns errors from retried requests", async () => { let errorHandlerCalled = false; diff --git a/src/link/error/index.ts b/src/link/error/index.ts index bf9494c5dfa..e77e7b27f8d 100644 --- a/src/link/error/index.ts +++ b/src/link/error/index.ts @@ -1,13 +1,37 @@ -import type { FormattedExecutionResult, GraphQLFormattedError } from "graphql"; +import type { + FormattedExecutionResult, + GraphQLErrorExtensions, + GraphQLFormattedError, +} from "graphql"; +import { + graphQLResultHasProtocolErrors, + PROTOCOL_ERRORS_SYMBOL, +} from "../../errors/index.js"; import type { NetworkError } from "../../errors/index.js"; import { Observable } from "../../utilities/index.js"; import type { Operation, FetchResult, NextLink } from "../core/index.js"; import { ApolloLink } from "../core/index.js"; export interface ErrorResponse { + /** + * Errors returned in the `errors` property of the GraphQL response. + */ graphQLErrors?: ReadonlyArray; + /** + * Errors thrown during a network request. This is usually an error thrown + * during a `fetch` call or an error while parsing the response from the + * network. + */ networkError?: NetworkError; + /** + * Fatal transport-level errors from multipart subscriptions. + * See the [multipart subscription protocol](https://www.apollographql.com/docs/graphos/routing/operations/subscriptions/multipart-protocol#message-and-error-format) for more information. + */ + protocolErrors?: ReadonlyArray<{ + message: string; + extensions?: GraphQLErrorExtensions[]; + }>; response?: FormattedExecutionResult; operation: Operation; forward: NextLink; @@ -42,16 +66,24 @@ export function onError(errorHandler: ErrorHandler): ApolloLink { operation, forward, }); + } else if (graphQLResultHasProtocolErrors(result)) { + retriedResult = errorHandler({ + protocolErrors: result.extensions[PROTOCOL_ERRORS_SYMBOL], + response: result, + operation, + forward, + }); + } - if (retriedResult) { - retriedSub = retriedResult.subscribe({ - next: observer.next.bind(observer), - error: observer.error.bind(observer), - complete: observer.complete.bind(observer), - }); - return; - } + if (retriedResult) { + retriedSub = retriedResult.subscribe({ + next: observer.next.bind(observer), + error: observer.error.bind(observer), + complete: observer.complete.bind(observer), + }); + return; } + observer.next(result); }, error: (networkError) => { diff --git a/src/testing/internal/incremental.ts b/src/testing/internal/incremental.ts new file mode 100644 index 00000000000..b03aebe7bfa --- /dev/null +++ b/src/testing/internal/incremental.ts @@ -0,0 +1,183 @@ +import { HttpLink } from "../../link/http/index.js"; +import type { + GraphQLFormattedError, + InitialIncrementalExecutionResult, + SubsequentIncrementalExecutionResult, +} from "graphql-17-alpha2"; +import type { GraphQLError } from "graphql"; +import { + ReadableStream as NodeReadableStream, + TextEncoderStream, + TransformStream, +} from "node:stream/web"; +import type { ApolloPayloadResult } from "../../core/index.js"; + +const hasNextSymbol = Symbol("hasNext"); + +export function mockIncrementalStream({ + responseHeaders, +}: { + responseHeaders: Headers; +}) { + type Payload = Chunks & { [hasNextSymbol]: boolean }; + const CLOSE = Symbol(); + let streamController: ReadableStreamDefaultController | null = null; + let sentInitialChunk = false; + + const queue: Array = []; + + function processQueue() { + if (!streamController) { + throw new Error("Cannot process queue without stream controller"); + } + + let chunk; + while ((chunk = queue.shift())) { + if (chunk === CLOSE) { + streamController.close(); + } else { + streamController.enqueue(chunk); + } + } + } + + function createStream() { + return new NodeReadableStream({ + start(c) { + streamController = c; + processQueue(); + }, + }) + .pipeThrough( + new TransformStream({ + transform: (chunk, controller) => { + controller.enqueue( + (!sentInitialChunk ? "\r\n---\r\n" : "") + + "content-type: application/json; charset=utf-8\r\n\r\n" + + JSON.stringify(chunk) + + (chunk[hasNextSymbol] ? "\r\n---\r\n" : "\r\n-----\r\n") + ); + sentInitialChunk = true; + }, + }) + ) + .pipeThrough(new TextEncoderStream()); + } + + const httpLink = new HttpLink({ + fetch(input, init) { + return Promise.resolve( + new Response( + createStream() satisfies NodeReadableStream as ReadableStream, + { + status: 200, + headers: responseHeaders, + } + ) + ); + }, + }); + + function queueNext(event: Payload | typeof CLOSE) { + queue.push(event); + + if (streamController) { + processQueue(); + } + } + + function close() { + queueNext(CLOSE); + + streamController = null; + sentInitialChunk = false; + } + + function enqueue(chunk: Chunks, hasNext: boolean) { + queueNext({ ...chunk, [hasNextSymbol]: hasNext }); + + if (!hasNext) { + close(); + } + } + + return { + httpLink, + enqueue, + close, + }; +} + +export function mockDeferStream< + TData = Record, + TExtensions = Record, +>() { + const { httpLink, enqueue } = mockIncrementalStream< + | InitialIncrementalExecutionResult + | SubsequentIncrementalExecutionResult + >({ + responseHeaders: new Headers({ + "Content-Type": 'multipart/mixed; boundary="-"; deferSpec=20220824', + }), + }); + return { + httpLink, + enqueueInitialChunk( + chunk: InitialIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + enqueueSubsequentChunk( + chunk: SubsequentIncrementalExecutionResult + ) { + enqueue(chunk, chunk.hasNext); + }, + enqueueErrorChunk(errors: GraphQLFormattedError[]) { + enqueue( + { + hasNext: true, + incremental: [ + { + // eslint-disable-next-line @typescript-eslint/no-restricted-types + errors: errors as GraphQLError[], + }, + ], + } satisfies SubsequentIncrementalExecutionResult, + true + ); + }, + }; +} + +export function mockMultipartSubscriptionStream< + TData = Record, + TExtensions = Record, +>() { + const { httpLink, enqueue } = mockIncrementalStream< + ApolloPayloadResult + >({ + responseHeaders: new Headers({ + "Content-Type": "multipart/mixed", + }), + }); + + enqueueHeartbeat(); + + function enqueueHeartbeat() { + enqueue({} as any, true); + } + + return { + httpLink, + enqueueHeartbeat, + enqueuePayloadResult( + payload: ApolloPayloadResult["payload"], + hasNext = true + ) { + enqueue({ payload }, hasNext); + }, + enqueueProtocolErrors(errors: ApolloPayloadResult["errors"]) { + enqueue({ payload: null, errors }, false); + }, + }; +} diff --git a/src/testing/internal/index.ts b/src/testing/internal/index.ts index cf38a14db79..2d422f519a9 100644 --- a/src/testing/internal/index.ts +++ b/src/testing/internal/index.ts @@ -28,3 +28,8 @@ export { export { actAsync } from "./rtl/actAsync.js"; export { renderAsync } from "./rtl/renderAsync.js"; export { renderHookAsync } from "./rtl/renderHookAsync.js"; +export { + mockIncrementalStream, + mockDeferStream, + mockMultipartSubscriptionStream, +} from "./incremental.js";