From 4175af59419dbb698c32c074f44229f3a5b3b83d Mon Sep 17 00:00:00 2001 From: Alessia Bellisario Date: Fri, 31 Mar 2023 13:32:29 -0400 Subject: [PATCH] chore: handle Event sent on WS error and add tests for error cases (#10586) --- .changeset/chatty-elephants-itch.md | 5 ++ .../subscriptions/__tests__/graphqlWsLink.ts | 72 ++++++++++++++++++- src/link/subscriptions/index.ts | 26 +++---- 3 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 .changeset/chatty-elephants-itch.md diff --git a/.changeset/chatty-elephants-itch.md b/.changeset/chatty-elephants-itch.md new file mode 100644 index 00000000000..c5e2a146492 --- /dev/null +++ b/.changeset/chatty-elephants-itch.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Improve WebSocket error handling for generic `Event` received on error. For more information see [https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event). diff --git a/src/link/subscriptions/__tests__/graphqlWsLink.ts b/src/link/subscriptions/__tests__/graphqlWsLink.ts index 3f87ec60b4c..b9e63cd7f0c 100644 --- a/src/link/subscriptions/__tests__/graphqlWsLink.ts +++ b/src/link/subscriptions/__tests__/graphqlWsLink.ts @@ -1,8 +1,9 @@ import { Client } from "graphql-ws"; -import { ExecutionResult } from "graphql"; +import { ExecutionResult, GraphQLError } from "graphql"; import gql from "graphql-tag"; import { Observable } from "../../../utilities"; +import { ApolloError } from "../../../errors"; import { execute } from "../../core"; import { GraphQLWsLink } from ".."; @@ -104,4 +105,73 @@ describe("GraphQLWSlink", () => { const obs = execute(link, { query: subscription }); await expect(observableToArray(obs)).resolves.toEqual(results); }); + + describe("should reject", () => { + it("with Error on subscription error via Error", async () => { + const subscribe: Client["subscribe"] = (_, sink) => { + sink.error(new Error("an error occurred")); + return () => {}; + }; + const client = mockClient(subscribe); + const link = new GraphQLWsLink(client); + + const obs = execute(link, { query: subscription }); + await expect(observableToArray(obs)).rejects.toEqual( + new Error("an error occurred") + ); + }); + + it("with Error on subscription error via CloseEvent", async () => { + const subscribe: Client["subscribe"] = (_, sink) => { + // A WebSocket close event receives a CloseEvent + // See: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event + sink.error( + new CloseEvent("an error occurred", { + code: 1006, + reason: "abnormally closed", + }) + ); + return () => {}; + }; + const client = mockClient(subscribe); + const link = new GraphQLWsLink(client); + + const obs = execute(link, { query: subscription }); + await expect(observableToArray(obs)).rejects.toEqual( + new Error("Socket closed with event 1006 abnormally closed") + ); + }); + + it("with ApolloError on subscription error via Event (network disconnected)", async () => { + const subscribe: Client["subscribe"] = (_, sink) => { + // A WebSocket error event receives a generic Event + // See: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event + sink.error({ target: { readyState: WebSocket.CLOSED } }); + return () => {}; + }; + const client = mockClient(subscribe); + const link = new GraphQLWsLink(client); + + const obs = execute(link, { query: subscription }); + await expect(observableToArray(obs)).rejects.toEqual( + new Error("Socket closed") + ); + }); + + it("with ApolloError on subscription error via GraphQLError[]", async () => { + const subscribe: Client["subscribe"] = (_, sink) => { + sink.error([new GraphQLError("Foo bar.")]); + return () => {}; + }; + const client = mockClient(subscribe); + const link = new GraphQLWsLink(client); + + const obs = execute(link, { query: subscription }); + await expect(observableToArray(obs)).rejects.toEqual( + new ApolloError({ + graphQLErrors: [new GraphQLError("Foo bar.")], + }) + ); + }); + }); }); diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts index 198948bc3a9..2527ccbd2bc 100644 --- a/src/link/subscriptions/index.ts +++ b/src/link/subscriptions/index.ts @@ -35,18 +35,16 @@ import { ApolloLink, Operation, FetchResult } from "../core"; import { isNonNullObject, Observable } from "../../utilities"; import { ApolloError } from "../../errors"; -interface LikeCloseEvent { - /** Returns the WebSocket connection close code provided by the server. */ - readonly code: number; - /** Returns the WebSocket connection close reason provided by the server. */ - readonly reason: string; +// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event +function isLikeCloseEvent(val: unknown): val is CloseEvent { + return isNonNullObject(val) && "code" in val && "reason" in val; } -function isLikeCloseEvent(val: unknown): val is LikeCloseEvent { - return isNonNullObject(val) && 'code' in val && 'reason' in val; +// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event +function isLikeErrorEvent(err: unknown): err is Event { + return isNonNullObject(err) && err.target?.readyState === WebSocket.CLOSED; } - export class GraphQLWsLink extends ApolloLink { constructor(public readonly client: Client) { super(); @@ -63,13 +61,15 @@ export class GraphQLWsLink extends ApolloLink { if (err instanceof Error) { return observer.error(err); } - - if (isLikeCloseEvent(err)) { + const likeClose = isLikeCloseEvent(err); + if (likeClose || isLikeErrorEvent(err)) { return observer.error( // reason will be available on clean closes - new Error( - `Socket closed with event ${err.code} ${err.reason || ""}` - ) + new Error(`Socket closed${ + likeClose ? ` with event ${err.code}` : "" + }${ + likeClose ? ` ${err.reason}` : "" + }`) ); }