Skip to content

Commit e0fdeb7

Browse files
authored
fix(api-graphql): events url pattern; non-retryable error handling (#13970)
1 parent 891dae5 commit e0fdeb7

File tree

9 files changed

+261
-19
lines changed

9 files changed

+261
-19
lines changed
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { Observable, Observer } from 'rxjs';
2+
import { Reachability } from '@aws-amplify/core/internals/utils';
3+
import { ConsoleLogger } from '@aws-amplify/core';
4+
import { MESSAGE_TYPES } from '../src/Providers/constants';
5+
import * as constants from '../src/Providers/constants';
6+
7+
import { delay, FakeWebSocketInterface } from './helpers';
8+
import { ConnectionState as CS } from '../src/types/PubSub';
9+
10+
import { AWSAppSyncEventProvider } from '../src/Providers/AWSAppSyncEventsProvider';
11+
12+
describe('AppSyncEventProvider', () => {
13+
describe('subscribe()', () => {
14+
describe('returned observer', () => {
15+
describe('connection logic with mocked websocket', () => {
16+
let fakeWebSocketInterface: FakeWebSocketInterface;
17+
const loggerSpy: jest.SpyInstance = jest.spyOn(
18+
ConsoleLogger.prototype,
19+
'_log',
20+
);
21+
22+
let provider: AWSAppSyncEventProvider;
23+
let reachabilityObserver: Observer<{ online: boolean }>;
24+
25+
beforeEach(async () => {
26+
// Set the network to "online" for these tests
27+
jest
28+
.spyOn(Reachability.prototype, 'networkMonitor')
29+
.mockImplementationOnce(() => {
30+
return new Observable(observer => {
31+
reachabilityObserver = observer;
32+
});
33+
})
34+
// Twice because we subscribe to get the initial state then again to monitor reachability
35+
.mockImplementationOnce(() => {
36+
return new Observable(observer => {
37+
reachabilityObserver = observer;
38+
});
39+
});
40+
41+
fakeWebSocketInterface = new FakeWebSocketInterface();
42+
provider = new AWSAppSyncEventProvider();
43+
44+
// Saving this spy and resetting it by hand causes badness
45+
// Saving it causes new websockets to be reachable across past tests that have not fully closed
46+
// Resetting it proactively causes those same past tests to be dealing with null while they reach a settled state
47+
jest
48+
.spyOn(provider as any, '_getNewWebSocket')
49+
.mockImplementation(() => {
50+
fakeWebSocketInterface.newWebSocket();
51+
return fakeWebSocketInterface.webSocket as WebSocket;
52+
});
53+
54+
// Reduce retry delay for tests to 100ms
55+
Object.defineProperty(constants, 'MAX_DELAY_MS', {
56+
value: 100,
57+
});
58+
// Reduce retry delay for tests to 100ms
59+
Object.defineProperty(constants, 'RECONNECT_DELAY', {
60+
value: 100,
61+
});
62+
});
63+
64+
afterEach(async () => {
65+
provider?.close();
66+
await fakeWebSocketInterface?.closeInterface();
67+
fakeWebSocketInterface?.teardown();
68+
loggerSpy.mockClear();
69+
});
70+
71+
test('subscription observer error is triggered when a connection is formed and a non-retriable connection_error data message is received', async () => {
72+
expect.assertions(3);
73+
74+
const socketCloseSpy = jest.spyOn(
75+
fakeWebSocketInterface.webSocket,
76+
'close',
77+
);
78+
fakeWebSocketInterface.webSocket.readyState = WebSocket.OPEN;
79+
80+
const observer = provider.subscribe({
81+
appSyncGraphqlEndpoint: 'ws://localhost:8080',
82+
});
83+
84+
observer.subscribe({
85+
error: e => {
86+
expect(e.errors[0].message).toEqual(
87+
'Connection failed: UnauthorizedException',
88+
);
89+
},
90+
});
91+
92+
await fakeWebSocketInterface?.readyForUse;
93+
await fakeWebSocketInterface?.triggerOpen();
94+
95+
// Resolve the message delivery actions
96+
await Promise.resolve(
97+
fakeWebSocketInterface?.sendDataMessage({
98+
type: MESSAGE_TYPES.GQL_CONNECTION_ERROR,
99+
errors: [
100+
{
101+
errorType: 'UnauthorizedException', // - non-retriable
102+
errorCode: 401,
103+
},
104+
],
105+
}),
106+
);
107+
108+
// Watching for raised exception to be caught and logged
109+
expect(loggerSpy).toHaveBeenCalledWith(
110+
'DEBUG',
111+
expect.stringContaining('error on bound '),
112+
expect.objectContaining({
113+
message: expect.stringMatching('UnauthorizedException'),
114+
}),
115+
);
116+
117+
await delay(1);
118+
119+
expect(socketCloseSpy).toHaveBeenCalledWith(3001);
120+
});
121+
122+
test('subscription observer error is not triggered when a connection is formed and a retriable connection_error data message is received', async () => {
123+
expect.assertions(2);
124+
125+
const observer = provider.subscribe({
126+
appSyncGraphqlEndpoint: 'ws://localhost:8080',
127+
});
128+
129+
observer.subscribe({
130+
error: x => {},
131+
});
132+
133+
const openSocketAttempt = async () => {
134+
await fakeWebSocketInterface?.readyForUse;
135+
await fakeWebSocketInterface?.triggerOpen();
136+
137+
// Resolve the message delivery actions
138+
await Promise.resolve(
139+
fakeWebSocketInterface?.sendDataMessage({
140+
type: MESSAGE_TYPES.GQL_CONNECTION_ERROR,
141+
errors: [
142+
{
143+
errorType: 'Retriable Test',
144+
errorCode: 408, // Request timed out - retriable
145+
},
146+
],
147+
}),
148+
);
149+
await fakeWebSocketInterface?.resetWebsocket();
150+
};
151+
152+
// Go through two connection attempts to excercise backoff and retriable raise
153+
await openSocketAttempt();
154+
await openSocketAttempt();
155+
156+
// Watching for raised exception to be caught and logged
157+
expect(loggerSpy).toHaveBeenCalledWith(
158+
'DEBUG',
159+
expect.stringContaining('error on bound '),
160+
expect.objectContaining({
161+
message: expect.stringMatching('Retriable Test'),
162+
}),
163+
);
164+
165+
await fakeWebSocketInterface?.waitUntilConnectionStateIn([
166+
CS.ConnectionDisrupted,
167+
]);
168+
169+
expect(loggerSpy).toHaveBeenCalledWith(
170+
'DEBUG',
171+
'Connection failed: Retriable Test',
172+
);
173+
});
174+
});
175+
});
176+
});
177+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { getRealtimeEndpointUrl } from '../src/Providers/AWSWebSocketProvider/appsyncUrl';
2+
3+
describe('getRealtimeEndpointUrl', () => {
4+
test('events', () => {
5+
const httpUrl =
6+
'https://abcdefghijklmnopqrstuvwxyz.appsync-api.us-east-1.amazonaws.com/event';
7+
8+
const res = getRealtimeEndpointUrl(httpUrl).toString();
9+
10+
expect(res).toEqual(
11+
'wss://abcdefghijklmnopqrstuvwxyz.appsync-realtime-api.us-east-1.amazonaws.com/event/realtime',
12+
);
13+
});
14+
});

packages/api-graphql/__tests__/events.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AppSyncEventProvider } from '../src/Providers/AWSAppSyncEventsProvider'
33

44
import { events } from '../src/';
55
import { appsyncRequest } from '../src/internals/events/appsyncRequest';
6+
67
import { GraphQLAuthMode } from '@aws-amplify/core/internals/utils';
78

89
const abortController = new AbortController();
@@ -38,7 +39,7 @@ jest.mock('../src/internals/events/appsyncRequest', () => {
3839
* so we're just sanity checking that the expected auth mode is passed to the provider in this test file.
3940
*/
4041

41-
describe('Events', () => {
42+
describe('Events client', () => {
4243
afterAll(() => {
4344
jest.resetAllMocks();
4445
jest.clearAllMocks();

packages/api-graphql/src/Providers/AWSAppSyncEventsProvider/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '@aws-amplify/core/internals/utils';
1010
import { CustomHeaders } from '@aws-amplify/data-schema/runtime';
1111

12-
import { MESSAGE_TYPES } from '../constants';
12+
import { DEFAULT_KEEP_ALIVE_TIMEOUT, MESSAGE_TYPES } from '../constants';
1313
import { AWSWebSocketProvider } from '../AWSWebSocketProvider';
1414
import { awsRealTimeHeaderBasedAuth } from '../AWSWebSocketProvider/authHeaders';
1515

@@ -44,7 +44,7 @@ interface DataResponse {
4444
const PROVIDER_NAME = 'AWSAppSyncEventsProvider';
4545
const WS_PROTOCOL_NAME = 'aws-appsync-event-ws';
4646

47-
class AWSAppSyncEventProvider extends AWSWebSocketProvider {
47+
export class AWSAppSyncEventProvider extends AWSWebSocketProvider {
4848
constructor() {
4949
super({ providerName: PROVIDER_NAME, wsProtocolName: WS_PROTOCOL_NAME });
5050
}
@@ -187,6 +187,21 @@ class AWSAppSyncEventProvider extends AWSWebSocketProvider {
187187
type: MESSAGE_TYPES.EVENT_STOP,
188188
};
189189
}
190+
191+
protected _extractConnectionTimeout(data: Record<string, any>): number {
192+
const { connectionTimeoutMs = DEFAULT_KEEP_ALIVE_TIMEOUT } = data;
193+
194+
return connectionTimeoutMs;
195+
}
196+
197+
protected _extractErrorCodeAndType(data: Record<string, any>): {
198+
errorCode: number;
199+
errorType: string;
200+
} {
201+
const { errors: [{ errorType = '', errorCode = 0 } = {}] = [] } = data;
202+
203+
return { errorCode, errorType };
204+
}
190205
}
191206

192207
export const AppSyncEventProvider = new AWSAppSyncEventProvider();

packages/api-graphql/src/Providers/AWSAppSyncRealTimeProvider/index.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from '@aws-amplify/core/internals/utils';
1111
import { CustomHeaders } from '@aws-amplify/data-schema/runtime';
1212

13-
import { MESSAGE_TYPES } from '../constants';
13+
import { DEFAULT_KEEP_ALIVE_TIMEOUT, MESSAGE_TYPES } from '../constants';
1414
import { AWSWebSocketProvider } from '../AWSWebSocketProvider';
1515
import { awsRealTimeHeaderBasedAuth } from '../AWSWebSocketProvider/authHeaders';
1616

@@ -158,4 +158,23 @@ export class AWSAppSyncRealTimeProvider extends AWSWebSocketProvider {
158158
type: MESSAGE_TYPES.GQL_STOP,
159159
};
160160
}
161+
162+
protected _extractConnectionTimeout(data: Record<string, any>): number {
163+
const {
164+
payload: { connectionTimeoutMs = DEFAULT_KEEP_ALIVE_TIMEOUT } = {},
165+
} = data;
166+
167+
return connectionTimeoutMs;
168+
}
169+
170+
protected _extractErrorCodeAndType(data: any): {
171+
errorCode: number;
172+
errorType: string;
173+
} {
174+
const {
175+
payload: { errors: [{ errorType = '', errorCode = 0 } = {}] = [] } = {},
176+
} = data;
177+
178+
return { errorCode, errorType };
179+
}
161180
}

packages/api-graphql/src/Providers/AWSWebSocketProvider/appsyncUrl.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const protocol = 'wss://';
1313
const standardDomainPattern =
1414
/^https:\/\/\w{26}\.appsync-api\.\w{2}(?:(?:-\w{2,})+)-\d\.amazonaws.com(?:\.cn)?\/graphql$/i;
1515
const eventDomainPattern =
16-
/^https:\/\/\w{26}\.ddpg-api\.\w{2}(?:(?:-\w{2,})+)-\d\.amazonaws.com(?:\.cn)?\/event$/i;
16+
/^https:\/\/\w{26}\.\w+-api\.\w{2}(?:(?:-\w{2,})+)-\d\.amazonaws.com(?:\.cn)?\/event$/i;
1717
const customDomainPath = '/realtime';
1818

1919
export const isCustomDomain = (url: string): boolean => {
@@ -31,7 +31,8 @@ export const getRealtimeEndpointUrl = (
3131
if (isEventDomain(realtimeEndpoint)) {
3232
realtimeEndpoint = realtimeEndpoint
3333
.concat(customDomainPath)
34-
.replace('ddpg-api', 'grt-gamma');
34+
.replace('ddpg-api', 'grt-gamma')
35+
.replace('appsync-api', 'appsync-realtime-api');
3536
} else if (isCustomDomain(realtimeEndpoint)) {
3637
realtimeEndpoint = realtimeEndpoint.concat(customDomainPath);
3738
} else {

packages/api-graphql/src/Providers/AWSWebSocketProvider/index.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
MAX_DELAY_MS,
2828
MESSAGE_TYPES,
2929
NON_RETRYABLE_CODES,
30+
NON_RETRYABLE_ERROR_TYPES,
3031
SOCKET_STATUS,
3132
START_ACK_TIMEOUT,
3233
SUBSCRIPTION_STATUS,
@@ -546,6 +547,15 @@ export abstract class AWSWebSocketProvider {
546547
{ id: string; payload: string | Record<string, unknown>; type: string },
547548
];
548549

550+
protected abstract _extractConnectionTimeout(
551+
data: Record<string, any>,
552+
): number;
553+
554+
protected abstract _extractErrorCodeAndType(data: Record<string, any>): {
555+
errorCode: number;
556+
errorType: string;
557+
};
558+
549559
private _handleIncomingSubscriptionMessage(message: MessageEvent) {
550560
if (typeof message.data !== 'string') {
551561
return;
@@ -629,14 +639,14 @@ export abstract class AWSWebSocketProvider {
629639
});
630640

631641
this.logger.debug(
632-
`${CONTROL_MSG.CONNECTION_FAILED}: ${JSON.stringify(payload)}`,
642+
`${CONTROL_MSG.CONNECTION_FAILED}: ${JSON.stringify(payload ?? data)}`,
633643
);
634644

635645
observer.error({
636646
errors: [
637647
{
638648
...new GraphQLError(
639-
`${CONTROL_MSG.CONNECTION_FAILED}: ${JSON.stringify(payload)}`,
649+
`${CONTROL_MSG.CONNECTION_FAILED}: ${JSON.stringify(payload ?? data)}`,
640650
),
641651
},
642652
],
@@ -830,10 +840,10 @@ export abstract class AWSWebSocketProvider {
830840
);
831841

832842
const data = JSON.parse(message.data) as ParsedMessagePayload;
833-
const {
834-
type,
835-
payload: { connectionTimeoutMs = DEFAULT_KEEP_ALIVE_TIMEOUT } = {},
836-
} = data;
843+
844+
const { type } = data;
845+
846+
const connectionTimeoutMs = this._extractConnectionTimeout(data);
837847

838848
if (type === MESSAGE_TYPES.GQL_CONNECTION_ACK) {
839849
ackOk = true;
@@ -844,11 +854,7 @@ export abstract class AWSWebSocketProvider {
844854
}
845855

846856
if (type === MESSAGE_TYPES.GQL_CONNECTION_ERROR) {
847-
const {
848-
payload: {
849-
errors: [{ errorType = '', errorCode = 0 } = {}] = [],
850-
} = {},
851-
} = data;
857+
const { errorType, errorCode } = this._extractErrorCodeAndType(data);
852858

853859
// TODO(Eslint): refactor to reject an Error object instead of a plain object
854860
// eslint-disable-next-line prefer-promise-reject-errors
@@ -920,7 +926,12 @@ export abstract class AWSWebSocketProvider {
920926
errorCode: number;
921927
};
922928

923-
if (NON_RETRYABLE_CODES.includes(errorCode)) {
929+
if (
930+
NON_RETRYABLE_CODES.includes(errorCode) ||
931+
// Event API does not currently return `errorCode`. This may change in the future.
932+
// For now fall back to also checking known non-retryable error types
933+
NON_RETRYABLE_ERROR_TYPES.includes(errorType)
934+
) {
924935
throw new NonRetryableError(errorType);
925936
} else if (errorType) {
926937
throw new Error(errorType);

packages/api-graphql/src/Providers/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export { AMPLIFY_SYMBOL } from '@aws-amplify/core/internals/utils';
55
export const MAX_DELAY_MS = 5000;
66

77
export const NON_RETRYABLE_CODES = [400, 401, 403];
8+
export const NON_RETRYABLE_ERROR_TYPES = [
9+
'BadRequestException',
10+
'UnauthorizedException',
11+
];
812

913
export const CONNECTION_STATE_CHANGE = 'ConnectionStateChange';
1014

0 commit comments

Comments
 (0)