Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions packages/shared/common/src/utils/timedPromise.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { LDLogger } from '../api';

/**
* Returns a promise which errors after t seconds.
*
* @param t Timeout in seconds.
* @param taskName Name of task being timed for logging and error reporting.
* @param logger {@link LDLogger} object.
*/
const timedPromise = (t: number, taskName: string, logger?: LDLogger) =>
const timedPromise = (t: number, taskName: string) =>
new Promise<void>((_res, reject) => {
setTimeout(() => {
const e = `${taskName} timed out after ${t} seconds.`;
logger?.error(e);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not log here because a Promise once created will always run so this will always run, which is futile.

reject(new Error(e));
}, t * 1000);
});
Expand Down
8 changes: 3 additions & 5 deletions packages/shared/mocks/src/streamingProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,9 @@ export const setupMockStreamingProcessor = (
start: jest.fn(async () => {
if (shouldError) {
setTimeout(() => {
const unauthorized: LDStreamingError = {
code: 401,
name: 'LaunchDarklyStreamingError',
message: 'test-error',
};
Comment on lines -31 to -35
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error mock should be an error object and not a plain js object.

const unauthorized = new Error('test-error') as LDStreamingError;
// @ts-ignore
unauthorized.code = 401;
errorHandler(unauthorized);
}, errorTimeoutSeconds * 1000);
} else {
Expand Down
37 changes: 2 additions & 35 deletions packages/shared/sdk-client/src/LDClientImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,37 +115,6 @@ describe('sdk-client object', () => {
});
});

test('variation', async () => {
await ldc.identify(context);
const devTestFlag = ldc.variation('dev-test-flag');

expect(devTestFlag).toBe(true);
});

test('variationDetail flag not found', async () => {
await ldc.identify(context);
const flag = ldc.variationDetail('does-not-exist', 'not-found');

expect(flag).toEqual({
reason: { errorKind: 'FLAG_NOT_FOUND', kind: 'ERROR' },
value: 'not-found',
variationIndex: null,
});
});

test('variationDetail deleted flag not found', async () => {
await ldc.identify(context);
// @ts-ignore
ldc.flags['dev-test-flag'].deleted = true;
const flag = ldc.variationDetail('dev-test-flag', 'deleted');

expect(flag).toEqual({
reason: { errorKind: 'FLAG_NOT_FOUND', kind: 'ERROR' },
value: 'deleted',
variationIndex: null,
});
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variation tests are moved to its own test file to make this file size manageable.

test('identify success', async () => {
defaultPutResponse['dev-test-flag'].value = false;
const carContext: LDContext = { kind: 'car', key: 'test-car' };
Expand Down Expand Up @@ -240,11 +209,9 @@ describe('sdk-client object', () => {
setupMockStreamingProcessor(true);
const carContext: LDContext = { kind: 'car', key: 'test-car' };

await expect(ldc.identify(carContext)).rejects.toMatchObject({
code: 401,
message: 'test-error',
});
await expect(ldc.identify(carContext)).rejects.toThrow('test-error');
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(/^error:.*test-error/));
expect(ldc.getContext()).toBeUndefined();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ describe('sdk-client identify timeout', () => {
test('rejects with custom timeout', async () => {
const timeout = 15;
jest.advanceTimersByTimeAsync(timeout * 1000).then();

await expect(ldc.identify(carContext, { timeout })).rejects.toThrow(/identify timed out/);
});

Expand Down
62 changes: 31 additions & 31 deletions packages/shared/sdk-client/src/LDClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ export default class LDClientImpl implements LDClient {
private eventFactoryWithReasons = new EventFactory(true);
private emitter: LDEmitter;
private flags: Flags = {};
private identifyChangeListener?: (c: LDContext, changedKeys: string[]) => void;
private identifyErrorListener?: (c: LDContext, err: any) => void;

private readonly clientContext: ClientContext;

Expand Down Expand Up @@ -81,6 +79,12 @@ export default class LDClientImpl implements LDClient {
!this.isOffline(),
);
this.emitter = new LDEmitter();
this.emitter.on('change', (c: LDContext, changedKeys: string[]) => {
this.logger.debug(`change: context: ${JSON.stringify(c)}, flags: ${changedKeys}`);
});
this.emitter.on('error', (c: LDContext, err: any) => {
this.logger.error(`error: ${err}, context: ${JSON.stringify(c)}`);
});
Comment on lines +82 to +87
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was moved from createIdentifyPromise because it's more efficient to run this once on construction rather than on every identify. Another important change to note is now these just log errors. The error listener does not reject.

}

/**
Expand Down Expand Up @@ -244,32 +248,22 @@ export default class LDClientImpl implements LDClient {

private createIdentifyPromise(timeout: number) {
let res: any;
let rej: any;

const slow = new Promise<void>((resolve, reject) => {
res = resolve;
rej = reject;
});

if (this.identifyChangeListener) {
this.emitter.off('change', this.identifyChangeListener);
}
if (this.identifyErrorListener) {
this.emitter.off('error', this.identifyErrorListener);
const timed = timedPromise(timeout, 'identify');
const raced = Promise.race([timed, slow]).catch((e) => {
if (e.message.includes('timed out')) {
this.logger.error(`identify error: ${e}`);
}

this.identifyChangeListener = (c: LDContext, changedKeys: string[]) => {
this.logger.debug(`change: context: ${JSON.stringify(c)}, flags: ${changedKeys}`);
};
this.identifyErrorListener = (c: LDContext, err: Error) => {
this.logger.debug(`error: ${err}, context: ${JSON.stringify(c)}`);
reject(err);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the bug where any error will reject the identify promise.

};

this.emitter.on('change', this.identifyChangeListener);
this.emitter.on('error', this.identifyErrorListener);
throw e;
});

const timed = timedPromise(timeout, 'identify', this.logger);
const raced = Promise.race([timed, slow]);

return { identifyPromise: raced, identifyResolve: res };
return { identifyPromise: raced, identifyResolve: res, identifyReject: rej };
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Include reject so we can manually reject the identify promise if needed.

}

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

this.eventProcessor?.sendEvent(this.eventFactoryDefault.identifyEvent(checkedContext));
const { identifyPromise, identifyResolve } = this.createIdentifyPromise(this.identifyTimeout);
const { identifyPromise, identifyResolve, identifyReject } = this.createIdentifyPromise(
this.identifyTimeout,
);
this.logger.debug(`Identifying ${JSON.stringify(context)}`);

const flagsStorage = await this.getFlagsFromStorage(checkedContext.canonicalKey);
Expand Down Expand Up @@ -340,7 +342,7 @@ export default class LDClientImpl implements LDClient {
this.createStreamListeners(context, checkedContext.canonicalKey, identifyResolve),
this.diagnosticsManager,
(e) => {
this.logger.error(e);
identifyReject(e);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If streamer errors, reject the identify promise. This maybe more relevant during the initial connection to streamer and when PUT is being processed. The other PATCH/DELETE commands come afterwards and by that time the identify promise will already be done so any errors that happens then is moot to the identify promise.

this.emitter.emit('error', context, e);
},
);
Expand Down Expand Up @@ -387,12 +389,12 @@ export default class LDClientImpl implements LDClient {

track(key: string, data?: any, metricValue?: number): void {
if (!this.context) {
this.logger?.warn(ClientMessages.missingContextKeyNoEvent);
this.logger.warn(ClientMessages.missingContextKeyNoEvent);
return;
}
const checkedContext = Context.fromLDContext(this.context);
if (!checkedContext.valid) {
this.logger?.warn(ClientMessages.missingContextKeyNoEvent);
this.logger.warn(ClientMessages.missingContextKeyNoEvent);
return;
}

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

Expand All @@ -420,7 +422,6 @@ export default class LDClientImpl implements LDClient {
const error = new LDClientError(
`Unknown feature flag "${flagKey}"; returning default value ${defVal}.`,
);
this.logger.error(error);
this.emitter.emit('error', this.context, error);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Emit error now logs the error so there's no need to duplicate here.

this.eventProcessor?.sendEvent(
this.eventFactoryDefault.unknownFlagEvent(flagKey, defVal, evalContext),
Expand All @@ -446,7 +447,6 @@ export default class LDClientImpl implements LDClient {
const error = new LDClientError(
`Wrong type "${type}" for feature flag "${flagKey}"; returning default value`,
);
this.logger.error(error);
this.emitter.emit('error', this.context, error);
return createErrorEvaluationDetail(ErrorKinds.WrongType, defaultValue);
}
Expand Down
93 changes: 93 additions & 0 deletions packages/shared/sdk-client/src/LDClientImpl.variation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AutoEnvAttributes, clone, LDContext } from '@launchdarkly/js-sdk-common';
import { basicPlatform, logger, setupMockStreamingProcessor } from '@launchdarkly/private-js-mocks';

import * as mockResponseJson from './evaluation/mockResponse.json';
import LDClientImpl from './LDClientImpl';
import { Flags } from './types';

jest.mock('@launchdarkly/js-sdk-common', () => {
const actual = jest.requireActual('@launchdarkly/js-sdk-common');
const actualMock = jest.requireActual('@launchdarkly/private-js-mocks');
return {
...actual,
...{
internal: {
...actual.internal,
StreamingProcessor: actualMock.MockStreamingProcessor,
},
},
};
});

const testSdkKey = 'test-sdk-key';
const context: LDContext = { kind: 'org', key: 'Testy Pizza' };

let ldc: LDClientImpl;
let defaultPutResponse: Flags;

describe('sdk-client object', () => {
beforeEach(() => {
defaultPutResponse = clone<Flags>(mockResponseJson);
setupMockStreamingProcessor(false, defaultPutResponse);
ldc = new LDClientImpl(testSdkKey, AutoEnvAttributes.Enabled, basicPlatform, {
logger,
sendEvents: false,
});
jest
.spyOn(LDClientImpl.prototype as any, 'createStreamUriPath')
.mockReturnValue('/stream/path');
});

afterEach(() => {
jest.resetAllMocks();
});

test('variation', async () => {
await ldc.identify(context);
const devTestFlag = ldc.variation('dev-test-flag');

expect(devTestFlag).toBe(true);
});

test('variation flag not found', async () => {
// set context manually to pass validation
ldc.context = { kind: 'user', key: 'test-user' };
const errorListener = jest.fn().mockName('errorListener');
ldc.on('error', errorListener);

const p = ldc.identify(context);
setTimeout(() => {
// call variation in the next tick to give ldc a chance to hook up event emitter
ldc.variation('does-not-exist', 'not-found');
});

await expect(p).resolves.toBeUndefined();
const error = errorListener.mock.calls[0][1];
expect(errorListener).toHaveBeenCalledTimes(1);
expect(error.message).toMatch(/unknown feature/i);
});

test('variationDetail flag not found', async () => {
await ldc.identify(context);
const flag = ldc.variationDetail('does-not-exist', 'not-found');

expect(flag).toEqual({
reason: { errorKind: 'FLAG_NOT_FOUND', kind: 'ERROR' },
value: 'not-found',
variationIndex: null,
});
});

test('variationDetail deleted flag not found', async () => {
await ldc.identify(context);
// @ts-ignore
ldc.flags['dev-test-flag'].deleted = true;
const flag = ldc.variationDetail('dev-test-flag', 'deleted');

expect(flag).toEqual({
reason: { errorKind: 'FLAG_NOT_FOUND', kind: 'ERROR' },
value: 'deleted',
variationIndex: null,
});
});
});
10 changes: 9 additions & 1 deletion packages/shared/sdk-client/src/api/LDClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,15 @@ export interface LDClient {
* @param identifyOptions
* Optional configuration. Please see {@link LDIdentifyOptions}.
* @returns
* A Promise which resolves when the flag values for the specified context are available.
* A Promise which resolves when the flag values for the specified
* context are available. It rejects when:
*
* 1. The context is unspecified or has no key.
*
* 2. The identify timeout is exceeded. In client SDKs this defaults to 5s.
* You can customize this timeout with {@link LDIdentifyOptions | identifyOptions}.
*
* 3. A network error is encountered during initialization.
*/
identify(context: LDContext, identifyOptions?: LDIdentifyOptions): Promise<void>;

Expand Down