Skip to content

Commit a019dd6

Browse files
authored
fix: Identify incorrectly rejected in client-sdk (#426)
This is a bugfix. Previously, any `error` event from the emitter rejects the identify promise, which is incorrect.
1 parent bd52c9c commit a019dd6

File tree

7 files changed

+139
-78
lines changed

7 files changed

+139
-78
lines changed

packages/shared/common/src/utils/timedPromise.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1-
import { LDLogger } from '../api';
2-
31
/**
42
* Returns a promise which errors after t seconds.
53
*
64
* @param t Timeout in seconds.
75
* @param taskName Name of task being timed for logging and error reporting.
8-
* @param logger {@link LDLogger} object.
96
*/
10-
const timedPromise = (t: number, taskName: string, logger?: LDLogger) =>
7+
const timedPromise = (t: number, taskName: string) =>
118
new Promise<void>((_res, reject) => {
129
setTimeout(() => {
1310
const e = `${taskName} timed out after ${t} seconds.`;
14-
logger?.error(e);
1511
reject(new Error(e));
1612
}, t * 1000);
1713
});

packages/shared/mocks/src/streamingProcessor.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,9 @@ export const setupMockStreamingProcessor = (
2828
start: jest.fn(async () => {
2929
if (shouldError) {
3030
setTimeout(() => {
31-
const unauthorized: LDStreamingError = {
32-
code: 401,
33-
name: 'LaunchDarklyStreamingError',
34-
message: 'test-error',
35-
};
31+
const unauthorized = new Error('test-error') as LDStreamingError;
32+
// @ts-ignore
33+
unauthorized.code = 401;
3634
errorHandler(unauthorized);
3735
}, errorTimeoutSeconds * 1000);
3836
} else {

packages/shared/sdk-client/src/LDClientImpl.test.ts

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -115,37 +115,6 @@ describe('sdk-client object', () => {
115115
});
116116
});
117117

118-
test('variation', async () => {
119-
await ldc.identify(context);
120-
const devTestFlag = ldc.variation('dev-test-flag');
121-
122-
expect(devTestFlag).toBe(true);
123-
});
124-
125-
test('variationDetail flag not found', async () => {
126-
await ldc.identify(context);
127-
const flag = ldc.variationDetail('does-not-exist', 'not-found');
128-
129-
expect(flag).toEqual({
130-
reason: { errorKind: 'FLAG_NOT_FOUND', kind: 'ERROR' },
131-
value: 'not-found',
132-
variationIndex: null,
133-
});
134-
});
135-
136-
test('variationDetail deleted flag not found', async () => {
137-
await ldc.identify(context);
138-
// @ts-ignore
139-
ldc.flags['dev-test-flag'].deleted = true;
140-
const flag = ldc.variationDetail('dev-test-flag', 'deleted');
141-
142-
expect(flag).toEqual({
143-
reason: { errorKind: 'FLAG_NOT_FOUND', kind: 'ERROR' },
144-
value: 'deleted',
145-
variationIndex: null,
146-
});
147-
});
148-
149118
test('identify success', async () => {
150119
defaultPutResponse['dev-test-flag'].value = false;
151120
const carContext: LDContext = { kind: 'car', key: 'test-car' };
@@ -240,11 +209,9 @@ describe('sdk-client object', () => {
240209
setupMockStreamingProcessor(true);
241210
const carContext: LDContext = { kind: 'car', key: 'test-car' };
242211

243-
await expect(ldc.identify(carContext)).rejects.toMatchObject({
244-
code: 401,
245-
message: 'test-error',
246-
});
212+
await expect(ldc.identify(carContext)).rejects.toThrow('test-error');
247213
expect(logger.error).toHaveBeenCalledTimes(1);
214+
expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/^error:.*test-error/));
248215
expect(ldc.getContext()).toBeUndefined();
249216
});
250217

packages/shared/sdk-client/src/LDClientImpl.timeout.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ describe('sdk-client identify timeout', () => {
6161
test('rejects with custom timeout', async () => {
6262
const timeout = 15;
6363
jest.advanceTimersByTimeAsync(timeout * 1000).then();
64-
6564
await expect(ldc.identify(carContext, { timeout })).rejects.toThrow(/identify timed out/);
6665
});
6766

packages/shared/sdk-client/src/LDClientImpl.ts

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@ export default class LDClientImpl implements LDClient {
4646
private eventFactoryWithReasons = new EventFactory(true);
4747
private emitter: LDEmitter;
4848
private flags: Flags = {};
49-
private identifyChangeListener?: (c: LDContext, changedKeys: string[]) => void;
50-
private identifyErrorListener?: (c: LDContext, err: any) => void;
5149

5250
private readonly clientContext: ClientContext;
5351

@@ -81,6 +79,12 @@ export default class LDClientImpl implements LDClient {
8179
!this.isOffline(),
8280
);
8381
this.emitter = new LDEmitter();
82+
this.emitter.on('change', (c: LDContext, changedKeys: string[]) => {
83+
this.logger.debug(`change: context: ${JSON.stringify(c)}, flags: ${changedKeys}`);
84+
});
85+
this.emitter.on('error', (c: LDContext, err: any) => {
86+
this.logger.error(`error: ${err}, context: ${JSON.stringify(c)}`);
87+
});
8488
}
8589

8690
/**
@@ -244,32 +248,22 @@ export default class LDClientImpl implements LDClient {
244248

245249
private createIdentifyPromise(timeout: number) {
246250
let res: any;
251+
let rej: any;
252+
247253
const slow = new Promise<void>((resolve, reject) => {
248254
res = resolve;
255+
rej = reject;
256+
});
249257

250-
if (this.identifyChangeListener) {
251-
this.emitter.off('change', this.identifyChangeListener);
252-
}
253-
if (this.identifyErrorListener) {
254-
this.emitter.off('error', this.identifyErrorListener);
258+
const timed = timedPromise(timeout, 'identify');
259+
const raced = Promise.race([timed, slow]).catch((e) => {
260+
if (e.message.includes('timed out')) {
261+
this.logger.error(`identify error: ${e}`);
255262
}
256-
257-
this.identifyChangeListener = (c: LDContext, changedKeys: string[]) => {
258-
this.logger.debug(`change: context: ${JSON.stringify(c)}, flags: ${changedKeys}`);
259-
};
260-
this.identifyErrorListener = (c: LDContext, err: Error) => {
261-
this.logger.debug(`error: ${err}, context: ${JSON.stringify(c)}`);
262-
reject(err);
263-
};
264-
265-
this.emitter.on('change', this.identifyChangeListener);
266-
this.emitter.on('error', this.identifyErrorListener);
263+
throw e;
267264
});
268265

269-
const timed = timedPromise(timeout, 'identify', this.logger);
270-
const raced = Promise.race([timed, slow]);
271-
272-
return { identifyPromise: raced, identifyResolve: res };
266+
return { identifyPromise: raced, identifyResolve: res, identifyReject: rej };
273267
}
274268

275269
private async getFlagsFromStorage(canonicalKey: string): Promise<Flags | undefined> {
@@ -282,8 +276,15 @@ export default class LDClientImpl implements LDClient {
282276
*
283277
* @param pristineContext The LDContext object to be identified.
284278
* @param identifyOptions Optional configuration. See {@link LDIdentifyOptions}.
279+
* @returns A Promise which resolves when the flag values for the specified
280+
* context are available. It rejects when:
281+
*
282+
* 1. The context is unspecified or has no key.
285283
*
286-
* @returns {Promise<void>}.
284+
* 2. The identify timeout is exceeded. In client SDKs this defaults to 5s.
285+
* You can customize this timeout with {@link LDIdentifyOptions | identifyOptions}.
286+
*
287+
* 3. A network error is encountered during initialization.
287288
*/
288289
async identify(pristineContext: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void> {
289290
if (identifyOptions?.timeout) {
@@ -303,13 +304,14 @@ export default class LDClientImpl implements LDClient {
303304
const checkedContext = Context.fromLDContext(context);
304305
if (!checkedContext.valid) {
305306
const error = new Error('Context was unspecified or had no key');
306-
this.logger.error(error);
307307
this.emitter.emit('error', context, error);
308308
return Promise.reject(error);
309309
}
310310

311311
this.eventProcessor?.sendEvent(this.eventFactoryDefault.identifyEvent(checkedContext));
312-
const { identifyPromise, identifyResolve } = this.createIdentifyPromise(this.identifyTimeout);
312+
const { identifyPromise, identifyResolve, identifyReject } = this.createIdentifyPromise(
313+
this.identifyTimeout,
314+
);
313315
this.logger.debug(`Identifying ${JSON.stringify(context)}`);
314316

315317
const flagsStorage = await this.getFlagsFromStorage(checkedContext.canonicalKey);
@@ -340,7 +342,7 @@ export default class LDClientImpl implements LDClient {
340342
this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve),
341343
this.diagnosticsManager,
342344
(e) => {
343-
this.logger.error(e);
345+
identifyReject(e);
344346
this.emitter.emit('error', context, e);
345347
},
346348
);
@@ -387,12 +389,12 @@ export default class LDClientImpl implements LDClient {
387389

388390
track(key: string, data?: any, metricValue?: number): void {
389391
if (!this.context) {
390-
this.logger?.warn(ClientMessages.missingContextKeyNoEvent);
392+
this.logger.warn(ClientMessages.missingContextKeyNoEvent);
391393
return;
392394
}
393395
const checkedContext = Context.fromLDContext(this.context);
394396
if (!checkedContext.valid) {
395-
this.logger?.warn(ClientMessages.missingContextKeyNoEvent);
397+
this.logger.warn(ClientMessages.missingContextKeyNoEvent);
396398
return;
397399
}
398400

@@ -408,7 +410,7 @@ export default class LDClientImpl implements LDClient {
408410
typeChecker?: (value: any) => [boolean, string],
409411
): LDFlagValue {
410412
if (!this.context) {
411-
this.logger?.debug(ClientMessages.missingContextKeyNoEvent);
413+
this.logger.debug(ClientMessages.missingContextKeyNoEvent);
412414
return createErrorEvaluationDetail(ErrorKinds.UserNotSpecified, defaultValue);
413415
}
414416

@@ -420,7 +422,6 @@ export default class LDClientImpl implements LDClient {
420422
const error = new LDClientError(
421423
`Unknown feature flag "${flagKey}"; returning default value ${defVal}.`,
422424
);
423-
this.logger.error(error);
424425
this.emitter.emit('error', this.context, error);
425426
this.eventProcessor?.sendEvent(
426427
this.eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext),
@@ -446,7 +447,6 @@ export default class LDClientImpl implements LDClient {
446447
const error = new LDClientError(
447448
`Wrong type "${type}" for feature flag "${flagKey}"; returning default value`,
448449
);
449-
this.logger.error(error);
450450
this.emitter.emit('error', this.context, error);
451451
return createErrorEvaluationDetail(ErrorKinds.WrongType, defaultValue);
452452
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { AutoEnvAttributes, clone, LDContext } from '@launchdarkly/js-sdk-common';
2+
import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks';
3+
4+
import * as mockResponseJson from './evaluation/mockResponse.json';
5+
import LDClientImpl from './LDClientImpl';
6+
import { Flags } from './types';
7+
8+
jest.mock('@launchdarkly/js-sdk-common', () => {
9+
const actual = jest.requireActual('@launchdarkly/js-sdk-common');
10+
const actualMock = jest.requireActual('@launchdarkly/private-js-mocks');
11+
return {
12+
...actual,
13+
...{
14+
internal: {
15+
...actual.internal,
16+
StreamingProcessor: actualMock.MockStreamingProcessor,
17+
},
18+
},
19+
};
20+
});
21+
22+
const testSdkKey = 'test-sdk-key';
23+
const context: LDContext = { kind: 'org', key: 'Testy Pizza' };
24+
25+
let ldc: LDClientImpl;
26+
let defaultPutResponse: Flags;
27+
28+
describe('sdk-client object', () => {
29+
beforeEach(() => {
30+
defaultPutResponse = clone<Flags>(mockResponseJson);
31+
setupMockStreamingProcessor(false, defaultPutResponse);
32+
ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, basicPlatform, {
33+
logger,
34+
sendEvents: false,
35+
});
36+
jest
37+
.spyOn(LDClientImpl.prototype as any, 'createStreamUriPath')
38+
.mockReturnValue('/stream/path');
39+
});
40+
41+
afterEach(() => {
42+
jest.resetAllMocks();
43+
});
44+
45+
test('variation', async () => {
46+
await ldc.identify(context);
47+
const devTestFlag = ldc.variation('dev-test-flag');
48+
49+
expect(devTestFlag).toBe(true);
50+
});
51+
52+
test('variation flag not found', async () => {
53+
// set context manually to pass validation
54+
ldc.context = { kind: 'user', key: 'test-user' };
55+
const errorListener = jest.fn().mockName('errorListener');
56+
ldc.on('error', errorListener);
57+
58+
const p = ldc.identify(context);
59+
setTimeout(() => {
60+
// call variation in the next tick to give ldc a chance to hook up event emitter
61+
ldc.variation('does-not-exist', 'not-found');
62+
});
63+
64+
await expect(p).resolves.toBeUndefined();
65+
const error = errorListener.mock.calls[0][1];
66+
expect(errorListener).toHaveBeenCalledTimes(1);
67+
expect(error.message).toMatch(/unknown feature/i);
68+
});
69+
70+
test('variationDetail flag not found', async () => {
71+
await ldc.identify(context);
72+
const flag = ldc.variationDetail('does-not-exist', 'not-found');
73+
74+
expect(flag).toEqual({
75+
reason: { errorKind: 'FLAG_NOT_FOUND', kind: 'ERROR' },
76+
value: 'not-found',
77+
variationIndex: null,
78+
});
79+
});
80+
81+
test('variationDetail deleted flag not found', async () => {
82+
await ldc.identify(context);
83+
// @ts-ignore
84+
ldc.flags['dev-test-flag'].deleted = true;
85+
const flag = ldc.variationDetail('dev-test-flag', 'deleted');
86+
87+
expect(flag).toEqual({
88+
reason: { errorKind: 'FLAG_NOT_FOUND', kind: 'ERROR' },
89+
value: 'deleted',
90+
variationIndex: null,
91+
});
92+
});
93+
});

packages/shared/sdk-client/src/api/LDClient.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,15 @@ export interface LDClient {
112112
* @param identifyOptions
113113
* Optional configuration. Please see {@link LDIdentifyOptions}.
114114
* @returns
115-
* A Promise which resolves when the flag values for the specified context are available.
115+
* A Promise which resolves when the flag values for the specified
116+
* context are available. It rejects when:
117+
*
118+
* 1. The context is unspecified or has no key.
119+
*
120+
* 2. The identify timeout is exceeded. In client SDKs this defaults to 5s.
121+
* You can customize this timeout with {@link LDIdentifyOptions | identifyOptions}.
122+
*
123+
* 3. A network error is encountered during initialization.
116124
*/
117125
identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void>;
118126

0 commit comments

Comments
 (0)