launchdarkly-js-sdk-common 4.3.1 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  All notable changes to the `launchdarkly-js-sdk-common` package will be documented in this file. Changes that affect the dependent SDKs such as `launchdarkly-js-client-sdk` should also be logged in those projects, in the next release that uses the updated version of this package. This project adheres to [Semantic Versioning](http://semver.org).
4
4
 
5
+ ## [4.3.1] - 2022-10-17
6
+ ### Fixed:
7
+ - Fixed an issue that prevented the `flag-used` inspector from being called.
8
+
5
9
  ## [4.3.0] - 2022-10-17
6
10
  ### Added:
7
11
  - Added support for `Inspectors` that can be used for collecting information for monitoring, analytics, and debugging.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchdarkly-js-sdk-common",
3
- "version": "4.3.1",
3
+ "version": "4.3.2",
4
4
  "description": "LaunchDarkly SDK for JavaScript - common code",
5
5
  "author": "LaunchDarkly <team@launchdarkly.com>",
6
6
  "license": "Apache-2.0",
package/src/Stream.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const messages = require('./messages');
2
2
  const { appendUrlPath, base64URLEncode, objectHasOwnProperty } = require('./utils');
3
3
  const { getLDHeaders, transformHeaders } = require('./headers');
4
+ const { isHttpErrorRecoverable } = require('./errors');
4
5
 
5
6
  // The underlying event source implementation is abstracted via the platform object, which should
6
7
  // have these three properties:
@@ -16,6 +17,8 @@ const { getLDHeaders, transformHeaders } = require('./headers');
16
17
  // interval between heartbeats from the LaunchDarkly streaming server. If this amount of time elapses
17
18
  // with no new data, the connection will be cycled.
18
19
  const streamReadTimeoutMillis = 5 * 60 * 1000; // 5 minutes
20
+ const maxRetryDelay = 30 * 1000; // Maximum retry delay 30 seconds.
21
+ const jitterRatio = 0.5; // Delay should be 50%-100% of calculated time.
19
22
 
20
23
  function Stream(platform, config, environment, diagnosticsAccumulator) {
21
24
  const baseUrl = config.streamUrl;
@@ -24,7 +27,7 @@ function Stream(platform, config, environment, diagnosticsAccumulator) {
24
27
  const evalUrlPrefix = appendUrlPath(baseUrl, '/eval/' + environment);
25
28
  const useReport = config.useReport;
26
29
  const withReasons = config.evaluationReasons;
27
- const streamReconnectDelay = config.streamReconnectDelay;
30
+ const baseReconnectDelay = config.streamReconnectDelay;
28
31
  const headers = getLDHeaders(platform, config);
29
32
  let firstConnectionErrorLogged = false;
30
33
  let es = null;
@@ -33,6 +36,22 @@ function Stream(platform, config, environment, diagnosticsAccumulator) {
33
36
  let user = null;
34
37
  let hash = null;
35
38
  let handlers = null;
39
+ let retryCount = 0;
40
+
41
+ function backoff() {
42
+ const delay = baseReconnectDelay * Math.pow(2, retryCount);
43
+ return delay > maxRetryDelay ? maxRetryDelay : delay;
44
+ }
45
+
46
+ function jitter(computedDelayMillis) {
47
+ return computedDelayMillis - Math.trunc(Math.random() * jitterRatio * computedDelayMillis);
48
+ }
49
+
50
+ function getNextRetryDelay() {
51
+ const delay = jitter(backoff());
52
+ retryCount += 1;
53
+ return delay;
54
+ }
36
55
 
37
56
  stream.connect = function(newUser, newHash, newHandlers) {
38
57
  user = newUser;
@@ -63,13 +82,31 @@ function Stream(platform, config, environment, diagnosticsAccumulator) {
63
82
  };
64
83
 
65
84
  function handleError(err) {
85
+ // The event source may not produce a status. But the LaunchDarkly
86
+ // polyfill can. If we can get the status, then we should stop retrying
87
+ // on certain error codes.
88
+ if (err.status && typeof err.status === 'number' && !isHttpErrorRecoverable(err.status)) {
89
+ // If we encounter an unrecoverable condition, then we do not want to
90
+ // retry anymore.
91
+ closeConnection();
92
+ logger.error(messages.unrecoverableStreamError(err));
93
+ // Ensure any pending retry attempts are not done.
94
+ if (reconnectTimeoutReference) {
95
+ clearTimeout(reconnectTimeoutReference);
96
+ reconnectTimeoutReference = null;
97
+ }
98
+ return;
99
+ }
100
+
101
+ const delay = getNextRetryDelay();
102
+
66
103
  if (!firstConnectionErrorLogged) {
67
- logger.warn(messages.streamError(err, streamReconnectDelay));
104
+ logger.warn(messages.streamError(err, delay));
68
105
  firstConnectionErrorLogged = true;
69
106
  }
70
107
  logConnectionResult(false);
71
108
  closeConnection();
72
- tryConnect(streamReconnectDelay);
109
+ tryConnect(delay);
73
110
  }
74
111
 
75
112
  function tryConnect(delay) {
@@ -123,6 +160,11 @@ function Stream(platform, config, environment, diagnosticsAccumulator) {
123
160
  }
124
161
 
125
162
  es.onerror = handleError;
163
+
164
+ es.onopen = () => {
165
+ // If the connection is a success, then reset the retryCount.
166
+ retryCount = 0;
167
+ };
126
168
  }
127
169
  }
128
170
 
@@ -170,6 +170,7 @@ describe('Stream', () => {
170
170
  const nAttempts = 5;
171
171
  for (let i = 0; i < nAttempts; i++) {
172
172
  es.mockError('test error');
173
+ await sleepAsync(10);
173
174
  const created1 = await platform.testing.expectStream();
174
175
  const es1 = created1.eventSource;
175
176
 
@@ -185,6 +186,40 @@ describe('Stream', () => {
185
186
  }
186
187
  });
187
188
 
189
+ it.each([401, 403])('does not reconnect after an unrecoverable error', async status => {
190
+ const config = { ...defaultConfig, streamReconnectDelay: 1, useReport: false };
191
+ const stream = new Stream(platform, config, envName);
192
+ stream.connect(user);
193
+
194
+ const created = await platform.testing.expectStream();
195
+ const es = created.eventSource;
196
+
197
+ expect(es.readyState).toBe(EventSource.CONNECTING);
198
+ es.mockOpen();
199
+ expect(es.readyState).toBe(EventSource.OPEN);
200
+
201
+ es.mockError({ status });
202
+ await sleepAsync(10);
203
+ expect(platform.testing.eventSourcesCreated.length()).toEqual(0);
204
+ });
205
+
206
+ it.each([400, 408, 429])('does reconnect after a recoverable error', async status => {
207
+ const config = { ...defaultConfig, streamReconnectDelay: 1, useReport: false };
208
+ const stream = new Stream(platform, config, envName);
209
+ stream.connect(user);
210
+
211
+ const created = await platform.testing.expectStream();
212
+ const es = created.eventSource;
213
+
214
+ expect(es.readyState).toBe(EventSource.CONNECTING);
215
+ es.mockOpen();
216
+ expect(es.readyState).toBe(EventSource.OPEN);
217
+
218
+ es.mockError({ status });
219
+ await sleepAsync(10);
220
+ expect(platform.testing.eventSourcesCreated.length()).toEqual(1);
221
+ });
222
+
188
223
  it('logs a warning for only the first failed connection attempt', async () => {
189
224
  const config = { ...defaultConfig, streamReconnectDelay: 1 };
190
225
  const stream = new Stream(platform, config, envName);
@@ -197,6 +232,7 @@ describe('Stream', () => {
197
232
  const nAttempts = 5;
198
233
  for (let i = 0; i < nAttempts; i++) {
199
234
  es.mockError('test error');
235
+ await sleepAsync(10);
200
236
  const created1 = await platform.testing.expectStream();
201
237
  es = created1.eventSource;
202
238
  es.mockOpen();
@@ -221,6 +257,7 @@ describe('Stream', () => {
221
257
  const nAttempts = 5;
222
258
  for (let i = 0; i < nAttempts; i++) {
223
259
  es.mockError('test error #1');
260
+ await sleepAsync(10);
224
261
  const created1 = await platform.testing.expectStream();
225
262
  es = created1.eventSource;
226
263
  es.mockOpen();
@@ -232,6 +269,7 @@ describe('Stream', () => {
232
269
 
233
270
  for (let i = 0; i < nAttempts; i++) {
234
271
  es.mockError('test error #2');
272
+ await sleepAsync(10);
235
273
  const created1 = await platform.testing.expectStream();
236
274
  es = created1.eventSource;
237
275
  es.mockOpen();
@@ -239,8 +277,8 @@ describe('Stream', () => {
239
277
 
240
278
  // make sure there is just a single logged message rather than five (one per attempt)
241
279
  expect(logger.output.warn).toEqual([
242
- messages.streamError('test error #1', 1),
243
- messages.streamError('test error #2', 1),
280
+ expect.stringContaining('test error #1'),
281
+ expect.stringContaining('test error #2'),
244
282
  ]);
245
283
  });
246
284
 
package/src/headers.js CHANGED
@@ -17,9 +17,10 @@ function getLDHeaders(platform, options) {
17
17
  if (tagKeys.length) {
18
18
  h['x-launchdarkly-tags'] = tagKeys
19
19
  .sort()
20
- .flatMap(
20
+ .map(
21
21
  key => (Array.isArray(tags[key]) ? tags[key].sort().map(value => `${key}/${value}`) : [`${key}/${tags[key]}`])
22
22
  )
23
+ .reduce((flattened, item) => flattened.concat(item), [])
23
24
  .join(' ');
24
25
  }
25
26
  return h;
package/src/messages.js CHANGED
@@ -123,7 +123,7 @@ const streamError = function(err, streamReconnectDelay) {
123
123
  return (
124
124
  'Error on stream connection: ' +
125
125
  errorString(err) +
126
- ', will continue retrying every ' +
126
+ ', will continue retrying after ' +
127
127
  streamReconnectDelay +
128
128
  ' milliseconds.'
129
129
  );
@@ -131,6 +131,8 @@ const streamError = function(err, streamReconnectDelay) {
131
131
 
132
132
  const unknownOption = name => 'Ignoring unknown config option "' + name + '"';
133
133
 
134
+ const unrecoverableStreamError = err => `Error on stream connection ${errorString(err)}, giving up permanently`;
135
+
134
136
  const wrongOptionType = (name, expectedType, actualType) =>
135
137
  'Config option "' + name + '" should be of type ' + expectedType + ', got ' + actualType + ', using default value';
136
138
 
@@ -228,6 +230,7 @@ module.exports = {
228
230
  tagValueTooLong,
229
231
  unknownCustomEventKey,
230
232
  unknownOption,
233
+ unrecoverableStreamError,
231
234
  userNotSpecified,
232
235
  wrongOptionType,
233
236
  wrongOptionTypeBoolean,