From d9685f53c34483245e6ea21e91b669ef1180ae97 Mon Sep 17 00:00:00 2001 From: Jerel Miller Date: Mon, 28 Aug 2023 15:31:12 -0600 Subject: [PATCH] Ensure subscriptions adhere to the `errorPolicy` (#11162) --- .changeset/fast-candles-trade.md | 5 + .size-limit.cjs | 4 +- src/__tests__/graphqlSubscriptions.ts | 248 +++++++++++++++++++++++++- src/core/QueryManager.ts | 14 +- 4 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 .changeset/fast-candles-trade.md diff --git a/.changeset/fast-candles-trade.md b/.changeset/fast-candles-trade.md new file mode 100644 index 00000000000..6c788d55520 --- /dev/null +++ b/.changeset/fast-candles-trade.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +Ensures GraphQL errors returned in subscription payloads adhere to the `errorPolicy` set in `client.subscribe(...)` calls. diff --git a/.size-limit.cjs b/.size-limit.cjs index ab25a3df44e..6b6dae27a9b 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -1,7 +1,7 @@ const checks = [ { path: "dist/apollo-client.min.cjs", - limit: "38056", + limit: "38074", }, { path: "dist/main.cjs", @@ -10,7 +10,7 @@ const checks = [ { path: "dist/index.js", import: "{ ApolloClient, InMemoryCache, HttpLink }", - limit: "31971", + limit: "31980", }, ...[ "ApolloProvider", diff --git a/src/__tests__/graphqlSubscriptions.ts b/src/__tests__/graphqlSubscriptions.ts index 2dc9b6b3cc1..1e1f2cf160d 100644 --- a/src/__tests__/graphqlSubscriptions.ts +++ b/src/__tests__/graphqlSubscriptions.ts @@ -1,10 +1,11 @@ import gql from 'graphql-tag'; -import { ApolloClient } from '../core'; +import { ApolloClient, FetchResult } from '../core'; import { InMemoryCache } from '../cache'; -import { PROTOCOL_ERRORS_SYMBOL } from '../errors'; +import { ApolloError, PROTOCOL_ERRORS_SYMBOL } from '../errors'; import { QueryManager } from '../core/QueryManager'; import { itAsync, mockObservableLink } from '../testing'; +import { GraphQLError } from 'graphql'; describe('GraphQL Subscriptions', () => { const results = [ @@ -222,6 +223,239 @@ describe('GraphQL Subscriptions', () => { return Promise.resolve(promise); }); + it('returns errors in next result when `errorPolicy` is "all"', async () => { + const query = gql` + subscription UserInfo($name: String) { + user(name: $name) { + name + } + } + `; + const link = mockObservableLink(); + const queryManager = new QueryManager({ + link, + cache: new InMemoryCache(), + }); + + const obs = queryManager.startGraphQLSubscription({ + query, + variables: { name: 'Iron Man' }, + errorPolicy: 'all' + }); + + const promise = new Promise((resolve, reject) => { + const results: FetchResult[] = [] + + obs.subscribe({ + next: (result) => results.push(result), + complete: () => resolve(results), + error: reject, + }); + }); + + const errorResult = { + result: { + data: null, + errors: [new GraphQLError('This is an error')], + }, + }; + + link.simulateResult(errorResult, true); + + await expect(promise).resolves.toEqual([ + { + data: null, + errors: [new GraphQLError('This is an error')], + } + ]); + }); + + it('throws protocol errors when `errorPolicy` is "all"', async () => { + const query = gql` + subscription UserInfo($name: String) { + user(name: $name) { + name + } + } + `; + const link = mockObservableLink(); + const queryManager = new QueryManager({ + link, + cache: new InMemoryCache(), + }); + + const obs = queryManager.startGraphQLSubscription({ + query, + variables: { name: 'Iron Man' }, + errorPolicy: 'all' + }); + + const promise = new Promise((resolve, reject) => { + const results: FetchResult[] = [] + + obs.subscribe({ + next: (result) => results.push(result), + complete: () => resolve(results), + error: reject, + }); + }); + + const errorResult = { + result: { + data: null, + extensions: { + [PROTOCOL_ERRORS_SYMBOL]: [ + { + message: 'cannot read message from websocket', + extensions: [ + { + code: "WEBSOCKET_MESSAGE_ERROR" + } + ], + } as any, + ], + } + }, + }; + + // Silence expected warning about missing field for cache write + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + link.simulateResult(errorResult, true); + + await expect(promise).rejects.toEqual( + new ApolloError({ + protocolErrors: [ + { + message: 'cannot read message from websocket', + extensions: [ + { + code: "WEBSOCKET_MESSAGE_ERROR" + } + ], + } + ] + }) + ); + + consoleSpy.mockRestore(); + }); + + it('strips errors in next result when `errorPolicy` is "ignore"', async () => { + const query = gql` + subscription UserInfo($name: String) { + user(name: $name) { + name + } + } + `; + const link = mockObservableLink(); + const queryManager = new QueryManager({ + link, + cache: new InMemoryCache(), + }); + + const obs = queryManager.startGraphQLSubscription({ + query, + variables: { name: 'Iron Man' }, + errorPolicy: 'ignore' + }); + + const promise = new Promise((resolve, reject) => { + const results: FetchResult[] = [] + + obs.subscribe({ + next: (result) => results.push(result), + complete: () => resolve(results), + error: reject, + }); + }); + + const errorResult = { + result: { + data: null, + errors: [new GraphQLError('This is an error')], + }, + }; + + link.simulateResult(errorResult, true); + + await expect(promise).resolves.toEqual([ + { data: null } + ]); + }); + + it('throws protocol errors when `errorPolicy` is "ignore"', async () => { + const query = gql` + subscription UserInfo($name: String) { + user(name: $name) { + name + } + } + `; + const link = mockObservableLink(); + const queryManager = new QueryManager({ + link, + cache: new InMemoryCache(), + }); + + const obs = queryManager.startGraphQLSubscription({ + query, + variables: { name: 'Iron Man' }, + errorPolicy: 'ignore' + }); + + const promise = new Promise((resolve, reject) => { + const results: FetchResult[] = [] + + obs.subscribe({ + next: (result) => results.push(result), + complete: () => resolve(results), + error: reject, + }); + }); + + const errorResult = { + result: { + data: null, + extensions: { + [PROTOCOL_ERRORS_SYMBOL]: [ + { + message: 'cannot read message from websocket', + extensions: [ + { + code: "WEBSOCKET_MESSAGE_ERROR" + } + ], + } as any, + ], + } + }, + }; + + // Silence expected warning about missing field for cache write + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + link.simulateResult(errorResult, true); + + await expect(promise).rejects.toEqual( + new ApolloError({ + protocolErrors: [ + { + message: 'cannot read message from websocket', + extensions: [ + { + code: "WEBSOCKET_MESSAGE_ERROR" + } + ], + } + ] + }) + ); + + consoleSpy.mockRestore(); + }); + it('should call complete handler when the subscription completes', () => { const link = mockObservableLink(); const client = new ApolloClient({ @@ -258,7 +492,7 @@ describe('GraphQL Subscriptions', () => { link.simulateResult(results[0]); }); - it('should throw an error if the result has protocolErrors on it', () => { + it('should throw an error if the result has protocolErrors on it', async () => { const link = mockObservableLink(); const queryManager = new QueryManager({ link, @@ -297,7 +531,13 @@ describe('GraphQL Subscriptions', () => { }, }; + // Silence expected warning about missing field for cache write + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + link.simulateResult(errorResult); - return Promise.resolve(promise); + + await promise; + + consoleSpy.mockRestore(); }); }); diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index fdcf475a807..48dd5110bfa 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -933,7 +933,7 @@ export class QueryManager { public startGraphQLSubscription({ query, fetchPolicy, - errorPolicy, + errorPolicy = 'none', variables, context = {}, }: SubscriptionOptions): Observable> { @@ -971,7 +971,17 @@ export class QueryManager { if (hasProtocolErrors) { errors.protocolErrors = result.extensions[PROTOCOL_ERRORS_SYMBOL]; } - throw new ApolloError(errors); + + // `errorPolicy` is a mechanism for handling GraphQL errors, according + // to our documentation, so we throw protocol errors regardless of the + // set error policy. + if (errorPolicy === 'none' || hasProtocolErrors) { + throw new ApolloError(errors); + } + } + + if (errorPolicy === 'ignore') { + delete result.errors } return result;