Skip to content

Commit ba7c42b

Browse files
authored
Handle Web Socket error (#324)
* Initial commit * Handle onError and add tests for repeated retries * Clean up * Clean up deps * Clean up
1 parent 7aaa325 commit ba7c42b

File tree

4 files changed

+182
-1
lines changed

4 files changed

+182
-1
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import 'dotenv/config';
2+
import 'global-agent/bootstrap';
3+
4+
import { defineEventAttribute, EventTarget } from 'event-target-shim';
5+
import nock from 'nock';
6+
import onErrorResumeNext from 'on-error-resume-next';
7+
8+
import { DirectLine } from '../src/directLine';
9+
10+
function corsReply(nockRequest) {
11+
nockRequest.reply(function () {
12+
const { headers } = this.req;
13+
14+
return [
15+
200,
16+
null,
17+
{
18+
'Access-Control-Allow-Headers': headers['access-control-request-headers'],
19+
'Access-Control-Allow-Methods': headers['access-control-request-method'],
20+
'Access-Control-Allow-Origin': headers.origin
21+
}
22+
];
23+
});
24+
}
25+
26+
describe('Unhappy path', () => {
27+
let unsubscribes;
28+
29+
beforeEach(() => (unsubscribes = []));
30+
afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn)));
31+
32+
describe('broken Web Socket', () => {
33+
let numErrors;
34+
let numReconnections;
35+
36+
beforeEach(async () => {
37+
numErrors = 0;
38+
numReconnections = 0;
39+
40+
nock('https://directline.botframework.com')
41+
.persist()
42+
.post(uri => uri.startsWith('/v3/directline/conversations'))
43+
.reply(
44+
200,
45+
JSON.stringify({
46+
conversationId: '123',
47+
token: '456',
48+
streamUrl: 'wss://not-exist-domain'
49+
})
50+
)
51+
.get(uri => uri.startsWith('/v3/directline/conversations'))
52+
.reply(
53+
200,
54+
JSON.stringify({
55+
conversationId: '123',
56+
token: '456',
57+
streamUrl: 'wss://not-exist-domain'
58+
})
59+
);
60+
61+
corsReply(
62+
nock('https://directline.botframework.com')
63+
.persist()
64+
.options(uri => uri.startsWith('/v3/directline/conversations'))
65+
);
66+
67+
window.WebSocket = class extends (
68+
EventTarget
69+
) {
70+
constructor() {
71+
super();
72+
73+
numReconnections++;
74+
75+
setTimeout(() => {
76+
numErrors++;
77+
78+
this.dispatchEvent(new ErrorEvent('error', { error: new Error('artificial') }));
79+
this.dispatchEvent(new CustomEvent('close'));
80+
}, 10);
81+
}
82+
};
83+
84+
defineEventAttribute(window.WebSocket.prototype, 'close');
85+
defineEventAttribute(window.WebSocket.prototype, 'error');
86+
});
87+
88+
test('should reconnect only once for every error', async () => {
89+
const directLine = new DirectLine({
90+
token: '123',
91+
webSocket: true
92+
});
93+
94+
// Remove retry delay
95+
directLine.getRetryDelay = () => 0;
96+
97+
unsubscribes.push(() => directLine.end());
98+
99+
await new Promise(resolve => {
100+
const subscription = directLine.activity$.subscribe(() => {});
101+
102+
setTimeout(() => {
103+
subscription.unsubscribe();
104+
resolve();
105+
}, 2000);
106+
});
107+
108+
// Because we abruptly stopped reconnection after 2 seconds, there is a
109+
// 10ms window that the number of reconnections is 1 more than number of errors.
110+
expect(Math.abs(numReconnections - numErrors)).toBeLessThanOrEqual(1);
111+
112+
// As we loop reconnections for 2000 ms, and we inject errors every 10 ms.
113+
// We should only see at most 200 errors and reconnections.
114+
expect(numReconnections).toBeLessThanOrEqual(200);
115+
});
116+
});
117+
});

package-lock.json

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,15 @@
4949
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
5050
"concurrently": "^4.1.2",
5151
"dotenv": "^8.1.0",
52+
"event-target-shim": "^5.0.1",
5253
"get-port": "^5.0.0",
5354
"global-agent": "^2.0.2",
5455
"has-resolved": "^1.1.0",
5556
"http-proxy": "^1.18.1",
5657
"jest": "^24.9.0",
5758
"jest-environment-jsdom-fourteen": "^0.1.0",
5859
"jsdom": "^14.1.0",
60+
"nock": "^13.0.5",
5961
"node-fetch": "^2.6.0",
6062
"on-error-resume-next": "^1.1.0",
6163
"restify": "^8.4.0",

src/directLine.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,7 @@ export class DirectLine implements IBotConnection {
930930
konsole.log("creating WebSocket", this.streamUrl);
931931
const ws = new this.services.WebSocket(this.streamUrl);
932932
let sub: Subscription;
933+
let closed: boolean;
933934

934935
ws.onopen = open => {
935936
konsole.log("WebSocket open", open);
@@ -949,7 +950,21 @@ export class DirectLine implements IBotConnection {
949950
ws.onclose = close => {
950951
konsole.log("WebSocket close", close);
951952
if (sub) sub.unsubscribe();
952-
subscriber.error(close);
953+
954+
// RxJS.retryWhen has a bug that would cause "error" signal to be sent after the observable is completed/errored.
955+
// We need to guard against extraneous "error" signal to workaround the bug.
956+
closed || subscriber.error(close);
957+
closed = true;
958+
}
959+
960+
ws.onerror = error => {
961+
konsole.log("WebSocket error", error);
962+
if (sub) sub.unsubscribe();
963+
964+
// RxJS.retryWhen has a bug that would cause "error" signal to be sent after the observable is completed/errored.
965+
// We need to guard against extraneous "error" signal to workaround the bug.
966+
closed || subscriber.error(error);
967+
closed = true;
953968
}
954969

955970
ws.onmessage = message => message.data && subscriber.next(JSON.parse(message.data));

0 commit comments

Comments
 (0)