Skip to content

Commit d95aee5

Browse files
authored
fix (project config manager & Optimizely): Always fulfill onReady Promise with a result object, instead of sometimes rejecting (#23)
Summary: When documenting datafile management, I realized the rationale behind the behavior of the promise returned from Optimizely onReady was inconsistent. It would sometimes fulfill even if the instance was still not ready to be used. Also, I thought it would be better to always fulfill because we attempt to never throw errors, and rejected promises are akin to thrown errors. Before this change, the promise would fulfill either when a valid project config was obtained prior to the timeout, or when the timeout expired, and reject in other cases. With this change, the promise always fulfills with a result object of the form { success: boolean, reason?: string }. When success is true, the instance has a valid project config and is ready to use. When success is false, the instance failed to become ready, and reason explains why. Test plan: Updated unit tests
1 parent 67105ba commit d95aee5

File tree

4 files changed

+189
-48
lines changed

4 files changed

+189
-48
lines changed

packages/optimizely-sdk/lib/core/project_config/project_config_manager.js

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ var ERROR_MESSAGES = enums.ERROR_MESSAGES;
2828

2929
var MODULE_NAME = 'PROJECT_CONFIG_MANAGER';
3030

31+
/**
32+
* Return an error message derived from a thrown value. If the thrown value is
33+
* an error, return the error's message property. Otherwise, return a default
34+
* provided by the second argument.
35+
* @param {*} maybeError
36+
* @param {String=} defaultMessage
37+
* @return {String}
38+
*/
39+
function getErrorMessage(maybeError, defaultMessage) {
40+
if (maybeError instanceof Error) {
41+
return maybeError.message;
42+
}
43+
return defaultMessage || 'Unknown error';
44+
}
45+
3146
/**
3247
* ProjectConfigManager provides project config objects via its methods
3348
* getConfig and onUpdate. It uses a DatafileManager to fetch datafiles. It is
@@ -47,7 +62,10 @@ function ProjectConfigManager(config) {
4762
logger.error(ex);
4863
this.__updateListeners = [];
4964
this.__configObj = null;
50-
this.__readyPromise = Promise.reject(ex);
65+
this.__readyPromise = Promise.resolve({
66+
success: false,
67+
reason: getErrorMessage(ex, 'Error in initialize'),
68+
});
5169
}
5270
}
5371

@@ -70,7 +88,10 @@ ProjectConfigManager.prototype.__initialize = function(config) {
7088
if (!config.datafile && !config.sdkKey) {
7189
this.__configObj = null;
7290
var datafileAndSdkKeyMissingError = new Error(sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, MODULE_NAME));
73-
this.__readyPromise = Promise.reject(datafileAndSdkKeyMissingError);
91+
this.__readyPromise = Promise.resolve({
92+
success: false,
93+
reason: getErrorMessage(datafileAndSdkKeyMissingError),
94+
});
7495
logger.error(datafileAndSdkKeyMissingError);
7596
return;
7697
}
@@ -101,37 +122,71 @@ ProjectConfigManager.prototype.__initialize = function(config) {
101122
if (this.__validateDatafileOptions(config.datafileOptions)) {
102123
fns.assign(datafileManagerConfig, config.datafileOptions);
103124
}
104-
if (initialDatafile) {
125+
if (initialDatafile && this.__configObj) {
105126
datafileManagerConfig.datafile = initialDatafile;
106127
}
107128
this.datafileManager = new datafileManager.DatafileManager(datafileManagerConfig);
108129
this.datafileManager.start();
109-
this.__readyPromise = this.datafileManager.onReady().then(this.__onDatafileManagerReady.bind(this));
130+
this.__readyPromise = this.datafileManager.onReady().then(
131+
this.__onDatafileManagerReadyFulfill.bind(this),
132+
this.__onDatafileManagerReadyReject.bind(this)
133+
);
110134
this.datafileManager.on('update', this.__onDatafileManagerUpdate.bind(this));
111135
} else if (this.__configObj) {
112-
this.__readyPromise = Promise.resolve();
136+
this.__readyPromise = Promise.resolve({
137+
success: true,
138+
});
113139
} else {
114-
this.__readyPromise = Promise.reject(projectConfigCreationEx);
140+
this.__readyPromise = Promise.resolve({
141+
success: false,
142+
reason: getErrorMessage(projectConfigCreationEx, 'Invalid datafile'),
143+
});
115144
}
116145
};
117146

118147
/**
119-
* Respond to datafile manager's onReady promise. When using a datafile manager,
120-
* ProjectConfigManager's ready promise is based on DatafileManager's ready
121-
* promise, and this function is used as a then callback. If this function
122-
* throws (from validation failures), ProjectConfigManager's ready promise is
123-
* rejected. Otherwise, ProjectConfigManager updates its own project config
124-
* object from the new datafile, and calls its own registered update listeners.
148+
* Respond to datafile manager's onReady promise becoming fulfilled.
149+
* If there are validation or parse failures using the datafile provided by
150+
* DatafileManager, ProjectConfigManager's ready promise is resolved with an
151+
* unsuccessful result. Otherwise, ProjectConfigManager updates its own project
152+
* config object from the new datafile, and its ready promise is resolved with a
153+
* successful result.
125154
*/
126-
ProjectConfigManager.prototype.__onDatafileManagerReady = function() {
155+
ProjectConfigManager.prototype.__onDatafileManagerReadyFulfill = function() {
127156
var newDatafile = this.datafileManager.get();
128-
var newConfigObj = projectConfig.tryCreatingProjectConfig({
129-
datafile: newDatafile,
130-
jsonSchemaValidator: this.jsonSchemaValidator,
131-
logger: logger,
132-
skipJSONValidation: this.skipJSONValidation,
133-
});
157+
var newConfigObj;
158+
try {
159+
newConfigObj = projectConfig.tryCreatingProjectConfig({
160+
datafile: newDatafile,
161+
jsonSchemaValidator: this.jsonSchemaValidator,
162+
logger: logger,
163+
skipJSONValidation: this.skipJSONValidation,
164+
});
165+
} catch (ex) {
166+
logger.error(ex);
167+
return {
168+
success: false,
169+
reason: getErrorMessage(ex),
170+
};
171+
}
134172
this.__handleNewConfigObj(newConfigObj);
173+
return {
174+
success: true,
175+
};
176+
};
177+
178+
/**
179+
* Respond to datafile manager's onReady promise becoming rejected.
180+
* When DatafileManager's onReady promise is rejected, there is no possibility
181+
* of obtaining a datafile. In this case, ProjectConfigManager's ready promise
182+
* is fulfilled with an unsuccessful result.
183+
* @param {Error} err
184+
*/
185+
ProjectConfigManager.prototype.__onDatafileManagerReadyReject = function(err) {
186+
return {
187+
success: false,
188+
reason: getErrorMessage(err, 'Failed to become ready'),
189+
};
135190
};
136191

137192
/**

packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -55,19 +55,21 @@ describe('lib/core/project_config/project_config_manager', function() {
5555
logging.resetLogger();
5656
});
5757

58-
it('should throw an error if neither datafile nor sdkKey are passed into the constructor', function(done) {
58+
it('should call the error handler and fulfill onReady with an unsuccessful result if neither datafile nor sdkKey are passed into the constructor', function() {
5959
var manager = new projectConfigManager.ProjectConfigManager({
6060
skipJSONValidation: true,
6161
});
6262
sinon.assert.calledOnce(globalStubErrorHandler.handleError);
6363
var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message;
6464
assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, 'PROJECT_CONFIG_MANAGER'));
65-
manager.onReady().catch(function() {
66-
done();
65+
return manager.onReady().then(function(result) {
66+
assert.include(result, {
67+
success: false,
68+
});
6769
});
6870
});
6971

70-
it('should throw an error if the datafile JSON is malformed', function(done) {
72+
it('should call the error handler and fulfill onReady with an unsuccessful result if the datafile JSON is malformed', function() {
7173
var invalidDatafileJSON = 'abc';
7274
var manager = new projectConfigManager.ProjectConfigManager({
7375
datafile: invalidDatafileJSON,
@@ -76,12 +78,14 @@ describe('lib/core/project_config/project_config_manager', function() {
7678
sinon.assert.calledOnce(globalStubErrorHandler.handleError);
7779
var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message;
7880
assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_MALFORMED, 'CONFIG_VALIDATOR'));
79-
manager.onReady().catch(function() {
80-
done();
81+
return manager.onReady().then(function(result) {
82+
assert.include(result, {
83+
success: false,
84+
});
8185
});
8286
});
8387

84-
it('should throw an error if the datafile is not valid', function(done) {
88+
it('should call the error handler and fulfill onReady with an unsuccessful result if the datafile is not valid', function() {
8589
var invalidDatafile = testData.getTestProjectConfig();
8690
delete invalidDatafile['projectId'];
8791
var manager = new projectConfigManager.ProjectConfigManager({
@@ -91,21 +95,25 @@ describe('lib/core/project_config/project_config_manager', function() {
9195
sinon.assert.calledOnce(globalStubErrorHandler.handleError);
9296
var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message;
9397
assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE, 'JSON_SCHEMA_VALIDATOR', 'projectId', 'is missing and it is required'));
94-
manager.onReady().catch(function() {
95-
done();
98+
return manager.onReady().then(function(result) {
99+
assert.include(result, {
100+
success: false,
101+
});
96102
});
97103
});
98104

99-
it('should throw an error if the datafile version is not supported', function(done) {
105+
it('should call the error handler and fulfill onReady with an unsuccessful result if the datafile version is not supported', function() {
100106
var manager = new projectConfigManager.ProjectConfigManager({
101107
datafile: testData.getUnsupportedVersionConfig(),
102108
jsonSchemaValidator: jsonSchemaValidator,
103109
});
104110
sinon.assert.calledOnce(globalStubErrorHandler.handleError);
105111
var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message;
106112
assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_VERSION, 'CONFIG_VALIDATOR', '5'));
107-
manager.onReady().catch(function() {
108-
done();
113+
return manager.onReady().then(function(result) {
114+
assert.include(result, {
115+
success: false,
116+
});
109117
});
110118
});
111119

@@ -141,7 +149,7 @@ describe('lib/core/project_config/project_config_manager', function() {
141149
});
142150
});
143151

144-
it('should return a valid datafile from getConfig and resolve onReady', function() {
152+
it('should return a valid datafile from getConfig and resolve onReady with a successful result', function() {
145153
var configWithFeatures = testData.getTestProjectConfigWithFeatures();
146154
var manager = new projectConfigManager.ProjectConfigManager({
147155
datafile: configWithFeatures,
@@ -150,7 +158,11 @@ describe('lib/core/project_config/project_config_manager', function() {
150158
manager.getConfig(),
151159
projectConfig.createProjectConfig(configWithFeatures)
152160
);
153-
return manager.onReady();
161+
return manager.onReady().then(function(result) {
162+
assert.include(result, {
163+
success: true,
164+
});
165+
});
154166
});
155167

156168
it('does not call onUpdate listeners after becoming ready when constructed with a valid datafile and without sdkKey', function() {
@@ -185,7 +197,7 @@ describe('lib/core/project_config/project_config_manager', function() {
185197
});
186198

187199
describe('when constructed with sdkKey and without datafile', function() {
188-
it('updates itself when the datafile manager is ready and then emits updates', function() {
200+
it('updates itself when the datafile manager is ready, fulfills its onReady promise with a successful result, and then emits updates', function() {
189201
var configWithFeatures = testData.getTestProjectConfigWithFeatures();
190202
datafileManager.DatafileManager.returns({
191203
start: sinon.stub(),
@@ -198,7 +210,10 @@ describe('lib/core/project_config/project_config_manager', function() {
198210
sdkKey: '12345',
199211
});
200212
assert.isNull(manager.getConfig());
201-
return manager.onReady().then(function() {
213+
return manager.onReady().then(function(result) {
214+
assert.include(result, {
215+
success: true,
216+
});
202217
assert.deepEqual(
203218
manager.getConfig(),
204219
projectConfig.createProjectConfig(configWithFeatures)
@@ -290,7 +305,7 @@ describe('lib/core/project_config/project_config_manager', function() {
290305
});
291306
});
292307

293-
it('rejects its ready promise when the datafile manager emits an invalid datafile', function(done) {
308+
it('fulfills its ready promise with an unsuccessful result when the datafile manager emits an invalid datafile', function() {
294309
var invalidDatafile = testData.getTestProjectConfig();
295310
delete invalidDatafile['projectId'];
296311
datafileManager.DatafileManager.returns({
@@ -304,8 +319,29 @@ describe('lib/core/project_config/project_config_manager', function() {
304319
jsonSchemaValidator: jsonSchemaValidator,
305320
sdkKey: '12345',
306321
});
307-
manager.onReady().catch(function() {
308-
done();
322+
return manager.onReady().then(function(result) {
323+
assert.include(result, {
324+
success: false,
325+
});
326+
});
327+
});
328+
329+
it('fullfils its ready promise with an unsuccessful result when the datafile manager onReady promise rejects', function() {
330+
datafileManager.DatafileManager.returns({
331+
start: sinon.stub(),
332+
stop: sinon.stub(),
333+
get: sinon.stub().returns(null),
334+
on: sinon.stub().returns(function() {}),
335+
onReady: sinon.stub().returns(Promise.reject(new Error('Failed to become ready')))
336+
});
337+
var manager = new projectConfigManager.ProjectConfigManager({
338+
jsonSchemaValidator: jsonSchemaValidator,
339+
sdkKey: '12345',
340+
});
341+
return manager.onReady().then(function(result) {
342+
assert.include(result, {
343+
success: false,
344+
});
309345
});
310346
});
311347

@@ -318,8 +354,8 @@ describe('lib/core/project_config/project_config_manager', function() {
318354
});
319355
});
320356

321-
describe('when constructed with sdkKey and with a valid datafile', function() {
322-
it('does not call onUpdate listeners after becoming ready', function() {
357+
describe('when constructed with sdkKey and with a valid datafile object', function() {
358+
it('fulfills its onReady promise with a successful result, and does not call onUpdate listeners after becoming ready', function() {
323359
datafileManager.DatafileManager.returns({
324360
start: sinon.stub(),
325361
stop: sinon.stub(),
@@ -334,7 +370,37 @@ describe('lib/core/project_config/project_config_manager', function() {
334370
});
335371
var onUpdateSpy = sinon.spy();
336372
manager.onUpdate(onUpdateSpy);
337-
return manager.onReady().then(function() {
373+
return manager.onReady().then(function(result) {
374+
assert.include(result, {
375+
success: true,
376+
});
377+
// Datafile is the same as what it was constructed with, so should
378+
// not have called update listener
379+
sinon.assert.notCalled(onUpdateSpy);
380+
});
381+
});
382+
});
383+
384+
describe('when constructed with sdkKey and with a valid datafile string', function() {
385+
it('fulfills its onReady promise with a successful result, and does not call onUpdate listeners after becoming ready', function() {
386+
datafileManager.DatafileManager.returns({
387+
start: sinon.stub(),
388+
stop: sinon.stub(),
389+
get: sinon.stub().returns(testData.getTestProjectConfigWithFeatures()),
390+
on: sinon.stub().returns(function() {}),
391+
onReady: sinon.stub().returns(Promise.resolve())
392+
});
393+
var configWithFeatures = testData.getTestProjectConfigWithFeatures();
394+
var manager = new projectConfigManager.ProjectConfigManager({
395+
datafile: JSON.stringify(configWithFeatures),
396+
sdkKey: '12345',
397+
});
398+
var onUpdateSpy = sinon.spy();
399+
manager.onUpdate(onUpdateSpy);
400+
return manager.onReady().then(function(result) {
401+
assert.include(result, {
402+
success: true,
403+
});
338404
// Datafile is the same as what it was constructed with, so should
339405
// not have called update listener
340406
sinon.assert.notCalled(onUpdateSpy);

packages/optimizely-sdk/lib/optimizely/index.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -859,14 +859,24 @@ Optimizely.prototype.close = function() {
859859
};
860860

861861
/**
862-
* Returns a Promise that resolves when this instance is ready to use (meaning
862+
* Returns a Promise that fulfills when this instance is ready to use (meaning
863863
* it has a valid datafile), or has failed to become ready within a period of
864864
* time (configurable by the timeout property of the options argument). If a
865865
* valid datafile was provided in the constructor, the instance is immediately
866866
* ready. If an sdkKey was provided, a manager will be used to fetch a datafile,
867867
* and the returned promise will resolve if that fetch succeeds or fails before
868868
* the timeout. The default timeout is 30 seconds, which will be used if no
869869
* timeout is provided in the argument options object.
870+
* The returned Promise is fulfilled with a result object containing these
871+
* properties:
872+
* - success (boolean): True if this instance is ready to use with a valid
873+
* datafile, or false if this instance failed to become
874+
* ready.
875+
* - reason (string=): If success is false, this is a string property with
876+
* an explanatory message. Failure could be due to
877+
* expiration of the timeout, network errors,
878+
* unsuccessful responses, datafile parse errors, or
879+
* datafile validation errors.
870880
* @param {Object=} options
871881
* @param {number|undefined} options.timeout
872882
* @return {Promise}
@@ -881,7 +891,10 @@ Optimizely.prototype.onReady = function(options) {
881891
}
882892
var timeoutPromise = new Promise(function(resolve) {
883893
setTimeout(function() {
884-
resolve();
894+
resolve({
895+
success: false,
896+
reason: sprintf('onReady timeout expired after %s ms', timeout),
897+
});
885898
}, timeout);
886899
});
887900
return Promise.race([this.__readyPromise, timeoutPromise]);

0 commit comments

Comments
 (0)