Skip to content

Commit

Permalink
Ensure subscriptions adhere to the errorPolicy (#11162)
Browse files Browse the repository at this point in the history
  • Loading branch information
jerelmiller authored Aug 28, 2023
1 parent 5951f6f commit d9685f5
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 8 deletions.
5 changes: 5 additions & 0 deletions .changeset/fast-candles-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Ensures GraphQL errors returned in subscription payloads adhere to the `errorPolicy` set in `client.subscribe(...)` calls.
4 changes: 2 additions & 2 deletions .size-limit.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const checks = [
{
path: "dist/apollo-client.min.cjs",
limit: "38056",
limit: "38074",
},
{
path: "dist/main.cjs",
Expand All @@ -10,7 +10,7 @@ const checks = [
{
path: "dist/index.js",
import: "{ ApolloClient, InMemoryCache, HttpLink }",
limit: "31971",
limit: "31980",
},
...[
"ApolloProvider",
Expand Down
248 changes: 244 additions & 4 deletions src/__tests__/graphqlSubscriptions.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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<FetchResult[]>((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<FetchResult[]>((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<FetchResult[]>((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<FetchResult[]>((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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
14 changes: 12 additions & 2 deletions src/core/QueryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,7 @@ export class QueryManager<TStore> {
public startGraphQLSubscription<T = any>({
query,
fetchPolicy,
errorPolicy,
errorPolicy = 'none',
variables,
context = {},
}: SubscriptionOptions): Observable<FetchResult<T>> {
Expand Down Expand Up @@ -971,7 +971,17 @@ export class QueryManager<TStore> {
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;
Expand Down

0 comments on commit d9685f5

Please sign in to comment.