Repository: open-feature/js-sdk (ts)

Issue:
[FEATURE] Add support for a blocking setProvider
### Requirements

https://openfeature.dev/specification/sections/flag-evaluation#requirement-118

Pull request:
feat: add support for a blocking setProvider
## This PR

- adds setProviderAndWait method

### Related Issues

Fixes #576

## Follow-up

I'll update the READMEs with examples once the feature has been released.

Patch:
diff --git a/packages/shared/src/open-feature.ts b/packages/shared/src/open-feature.ts
index 8ca60106..573ce40e 100644
--- a/packages/shared/src/open-feature.ts
+++ b/packages/shared/src/open-feature.ts
@@ -89,7 +89,7 @@ export abstract class OpenFeatureCommonAPI<P extends CommonProvider = CommonProv
         }
       }
     });
-    
+
     this._events.addHandler(eventType, handler);
   }
 
@@ -111,13 +111,38 @@ export abstract class OpenFeatureCommonAPI<P extends CommonProvider = CommonProv
     return this._events.getHandlers(eventType);
   }
 
+  /**
+   * Sets the default provider for flag evaluations and returns a promise that resolves when the provider is ready.
+   * This provider will be used by unnamed clients and named clients to which no provider is bound.
+   * Setting a provider supersedes the current provider used in new and existing clients without a name.
+   * @template P
+   * @param {P} provider The provider responsible for flag evaluations.
+   * @returns {Promise<void>}
+   * @throws Uncaught exceptions thrown by the provider during initialization.
+   */
+  async setProviderAndWait(provider: P): Promise<void>;
+  /**
+   * Sets the provider that OpenFeature will use for flag evaluations of providers with the given name.
+   * A promise is returned that resolves when the provider is ready.
+   * Setting a provider supersedes the current provider used in new and existing clients with that name.
+   * @template P
+   * @param {string} clientName The name to identify the client
+   * @param {P} provider The provider responsible for flag evaluations.
+   * @returns {Promise<void>}
+   * @throws Uncaught exceptions thrown by the provider during initialization.
+   */
+  async setProviderAndWait(clientName: string, provider: P): Promise<void>;
+  async setProviderAndWait(clientOrProvider?: string | P, providerOrUndefined?: P): Promise<void> {
+    await this.setAwaitableProvider(clientOrProvider, providerOrUndefined);
+  }
+
   /**
    * Sets the default provider for flag evaluations.
    * This provider will be used by unnamed clients and named clients to which no provider is bound.
    * Setting a provider supersedes the current provider used in new and existing clients without a name.
    * @template P
    * @param {P} provider The provider responsible for flag evaluations.
-   * @returns {OpenFeatureCommonAPI} OpenFeature API
+   * @returns {this} OpenFeature API
    */
   setProvider(provider: P): this;
   /**
@@ -130,12 +155,22 @@ export abstract class OpenFeatureCommonAPI<P extends CommonProvider = CommonProv
    */
   setProvider(clientName: string, provider: P): this;
   setProvider(clientOrProvider?: string | P, providerOrUndefined?: P): this {
+    const maybePromise = this.setAwaitableProvider(clientOrProvider, providerOrUndefined);
+    if (maybePromise) {
+      maybePromise.catch(() => {
+        /* ignore, errors are emitted via the event emitter */
+      });
+    }
+    return this;
+  }
+
+  private setAwaitableProvider(clientOrProvider?: string | P, providerOrUndefined?: P): Promise<void> | void {
     const clientName = stringOrUndefined(clientOrProvider);
     const provider = objectOrUndefined<P>(clientOrProvider) ?? objectOrUndefined<P>(providerOrUndefined);
 
     if (!provider) {
       this._logger.debug('No provider defined, ignoring setProvider call');
-      return this;
+      return;
     }
 
     const oldProvider = this.getProviderForClient(clientName);
@@ -144,12 +179,12 @@ export abstract class OpenFeatureCommonAPI<P extends CommonProvider = CommonProv
     // ignore no-ops
     if (oldProvider === provider) {
       this._logger.debug('Provider is already set, ignoring setProvider call');
-      return this;
+      return;
     }
 
     if (!provider.runsOn) {
       this._logger.debug(`Provider '${provider.metadata.name}' has not defined its intended use.`);
-    } else if (provider.runsOn !== this._runsOn){
+    } else if (provider.runsOn !== this._runsOn) {
       throw new GeneralError(`Provider '${provider.metadata.name}' is intended for use on the ${provider.runsOn}.`);
     }
 
@@ -163,8 +198,10 @@ export abstract class OpenFeatureCommonAPI<P extends CommonProvider = CommonProv
       );
     }
 
+    let initializationPromise: Promise<void> | void = undefined;
+
     if (provider?.status === ProviderStatus.NOT_READY && typeof provider.initialize === 'function') {
-      provider
+      initializationPromise = provider
         .initialize?.(this._context)
         ?.then(() => {
           // fetch the most recent event emitters, some may have been added during init
@@ -175,9 +212,11 @@ export abstract class OpenFeatureCommonAPI<P extends CommonProvider = CommonProv
         })
         ?.catch((error) => {
           this.getAssociatedEventEmitters(clientName).forEach((emitter) => {
-            emitter?.emit(ProviderEvents.Error, { clientName, providerName,  message: error.message });
+            emitter?.emit(ProviderEvents.Error, { clientName, providerName, message: error.message });
           });
           this._events?.emit(ProviderEvents.Error, { clientName, providerName, message: error.message });
+          // rethrow after emitting error events, so that public methods can control error handling
+          throw error;
         });
     } else {
       emitters.forEach((emitter) => {
@@ -199,7 +238,7 @@ export abstract class OpenFeatureCommonAPI<P extends CommonProvider = CommonProv
       oldProvider?.onClose?.();
     }
 
-    return this;
+    return initializationPromise;
   }
 
   protected getProviderForClient(name?: string): P {


Test patch:
diff --git a/packages/client/test/client.spec.ts b/packages/client/test/client.spec.ts
index fb9d1fc1..109885c2 100644
--- a/packages/client/test/client.spec.ts
+++ b/packages/client/test/client.spec.ts
@@ -9,6 +9,7 @@ import {
   OpenFeature,
   OpenFeatureClient,
   Provider,
+  ProviderStatus,
   ResolutionDetails,
   StandardResolutionReasons,
 } from '../src';
@@ -91,7 +92,7 @@ const MOCK_PROVIDER: Provider = {
 };
 
 describe('OpenFeatureClient', () => {
-  beforeAll(() => {
+  beforeEach(() => {
     OpenFeature.setProvider(MOCK_PROVIDER);
   });
 
@@ -99,6 +100,65 @@ describe('OpenFeatureClient', () => {
     jest.clearAllMocks();
   });
 
+  describe('Requirement 1.1.8', () => {
+    class mockAsyncProvider implements Provider {
+      metadata = {
+        name: 'mock-async',
+      };
+
+      status = ProviderStatus.NOT_READY;
+      readonly runsOn = 'client';
+
+      constructor(private readonly throwInInit: boolean) {}
+
+      async initialize(): Promise<void> {
+        if (this.throwInInit) {
+          try {
+            throw new Error('provider failed to initialize');
+          } catch (err) {
+            this.status = ProviderStatus.ERROR;
+            throw err;
+          }
+        }
+        this.status = ProviderStatus.READY;
+        return;
+      }
+
+      resolveBooleanEvaluation(): ResolutionDetails<boolean> {
+        throw new Error('Method not implemented.');
+      }
+      resolveStringEvaluation(): ResolutionDetails<string> {
+        throw new Error('Method not implemented.');
+      }
+      resolveNumberEvaluation(): ResolutionDetails<number> {
+        throw new Error('Method not implemented.');
+      }
+      resolveObjectEvaluation<T extends JsonValue>(): ResolutionDetails<T> {
+        throw new Error('Method not implemented.');
+      }
+    }
+
+    it('should wait for the provider to successfully initialize', async () => {
+      const spy = jest.spyOn(mockAsyncProvider.prototype, 'initialize');
+
+      const provider = new mockAsyncProvider(false);
+      expect(provider.status).toBe(ProviderStatus.NOT_READY);
+      await OpenFeature.setProviderAndWait(provider);
+      expect(provider.status).toBe(ProviderStatus.READY);
+      expect(spy).toBeCalled();
+    });
+
+    it('should wait for the provider to fail during initialization', async () => {
+      const spy = jest.spyOn(mockAsyncProvider.prototype, 'initialize');
+
+      const provider = new mockAsyncProvider(true);
+      expect(provider.status).toBe(ProviderStatus.NOT_READY);
+      await expect(OpenFeature.setProviderAndWait(provider)).rejects.toThrow();
+      expect(provider.status).toBe(ProviderStatus.ERROR);
+      expect(spy).toBeCalled();
+    });
+  });
+
   describe('Requirement 1.2.1', () => {
     it('should allow addition of hooks', () => {
       expect(OpenFeatureClient.prototype.addHooks).toBeDefined();
diff --git a/packages/client/test/events.spec.ts b/packages/client/test/events.spec.ts
index 9efa1dcf..5715b30a 100644
--- a/packages/client/test/events.spec.ts
+++ b/packages/client/test/events.spec.ts
@@ -17,6 +17,7 @@ const TIMEOUT = 1000;
 class MockProvider implements Provider {
   readonly metadata: ProviderMetadata;
   readonly events?: OpenFeatureEventEmitter;
+  readonly runsOn = 'client';
   private hasInitialize: boolean;
   private failOnInit: boolean;
   private initDelay?: number;
@@ -85,7 +86,8 @@ describe('Events', () => {
     /* eslint-disable @typescript-eslint/no-explicit-any */
     (OpenFeature as any)._clientEventHandlers = new Map();
     /* eslint-disable @typescript-eslint/no-explicit-any */
-    (OpenFeature as any)._clientEvents = new Map();  });
+    (OpenFeature as any)._clientEvents = new Map();
+  });
 
   beforeEach(() => {
     OpenFeature.setProvider(NOOP_PROVIDER);
@@ -178,7 +180,7 @@ describe('Events', () => {
           OpenFeature.addHandler(ProviderEvents.Error, () => {
             resolve();
           });
-        })
+        }),
       ]).then(() => {
         done();
       });
@@ -306,7 +308,11 @@ describe('Events', () => {
     });
 
     it('handler added while while provider initializing runs', (done) => {
-      const provider = new MockProvider({ name: 'race', initialStatus: ProviderStatus.NOT_READY, initDelay: TIMEOUT / 2 });
+      const provider = new MockProvider({
+        name: 'race',
+        initialStatus: ProviderStatus.NOT_READY,
+        initDelay: TIMEOUT / 2,
+      });
 
       // set the default provider
       OpenFeature.setProvider(provider);
@@ -499,12 +505,12 @@ describe('Events', () => {
     describe('API', () => {
       it('Handlers attached after the provider is already in the associated state, MUST run immediately.', (done) => {
         const provider = new MockProvider({ initialStatus: ProviderStatus.ERROR });
-  
+
         OpenFeature.setProvider(clientId, provider);
         expect(provider.initialize).not.toHaveBeenCalled();
-  
+
         OpenFeature.addHandler(ProviderEvents.Error, () => {
-            done();
+          done();
         });
       });
     });
@@ -513,14 +519,14 @@ describe('Events', () => {
       it('Handlers attached after the provider is already in the associated state, MUST run immediately.', (done) => {
         const provider = new MockProvider({ initialStatus: ProviderStatus.READY });
         const client = OpenFeature.getClient(clientId);
-  
+
         OpenFeature.setProvider(clientId, provider);
         expect(provider.initialize).not.toHaveBeenCalled();
-  
+
         client.addHandler(ProviderEvents.Ready, () => {
-            done();
+          done();
         });
       });
-    });    
+    });
   });
 });
diff --git a/packages/client/test/open-feature.spec.ts b/packages/client/test/open-feature.spec.ts
index b214bc88..1dbe4420 100644
--- a/packages/client/test/open-feature.spec.ts
+++ b/packages/client/test/open-feature.spec.ts
@@ -1,15 +1,12 @@
 import { Paradigm } from '@openfeature/shared';
 import { OpenFeature, OpenFeatureAPI, OpenFeatureClient, Provider, ProviderStatus } from '../src';
 
-const mockProvider = (config?: {
-  initialStatus?: ProviderStatus,
-  runsOn?: Paradigm,
-}) => {
+const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => {
   return {
     metadata: {
       name: 'mock-events-success',
     },
-    runsOn: config?.runsOn,
+    runsOn: config?.runsOn || 'client',
     status: config?.initialStatus || ProviderStatus.NOT_READY,
     initialize: jest.fn(() => {
       return Promise.resolve('started');
@@ -40,13 +37,13 @@ describe('OpenFeature', () => {
 
     describe('Requirement 1.1.2.1', () => {
       it('should throw because the provider is not intended for the client', () => {
-        const provider = mockProvider({ runsOn: 'server'});
+        const provider = mockProvider({ runsOn: 'server' });
         expect(() => OpenFeature.setProvider(provider)).toThrowError(
           "Provider 'mock-events-success' is intended for use on the server."
         );
       });
       it('should succeed because the provider is intended for the client', () => {
-        const provider = mockProvider({ runsOn: 'client'});
+        const provider = mockProvider({ runsOn: 'client' });
         expect(() => OpenFeature.setProvider(provider)).not.toThrowError();
       });
     });
@@ -59,7 +56,7 @@ describe('OpenFeature', () => {
         expect(provider.initialize).toHaveBeenCalled();
       });
 
-      it('should not invoke initialze function if the provider is not in state NOT_READY', () => {
+      it('should not invoke initialize function if the provider is not in state NOT_READY', () => {
         const provider = mockProvider({ initialStatus: ProviderStatus.READY });
         OpenFeature.setProvider(provider);
         expect(OpenFeature.providerMetadata.name).toBe('mock-events-success');
diff --git a/packages/server/test/client.spec.ts b/packages/server/test/client.spec.ts
index 2eb74e88..be0b0ee6 100644
--- a/packages/server/test/client.spec.ts
+++ b/packages/server/test/client.spec.ts
@@ -10,6 +10,7 @@ import {
   OpenFeature,
   OpenFeatureClient,
   Provider,
+  ProviderStatus,
   ResolutionDetails,
   StandardResolutionReasons,
   TransactionContext,
@@ -82,7 +83,7 @@ const MOCK_PROVIDER: Provider = {
 };
 
 describe('OpenFeatureClient', () => {
-  beforeAll(() => {
+  beforeEach(() => {
     OpenFeature.setProvider(MOCK_PROVIDER);
   });
 
@@ -90,6 +91,65 @@ describe('OpenFeatureClient', () => {
     jest.clearAllMocks();
   });
 
+  describe('Requirement 1.1.8', () => {
+    class mockAsyncProvider implements Provider {
+      metadata = {
+        name: 'mock-async',
+      };
+
+      status = ProviderStatus.NOT_READY;
+      readonly runsOn = 'server';
+
+      constructor(private readonly throwInInit: boolean) {}
+
+      async initialize(): Promise<void> {
+        if (this.throwInInit) {
+          try {
+            throw new Error('provider failed to initialize');
+          } catch (err) {
+            this.status = ProviderStatus.ERROR;
+            throw err;
+          }
+        }
+        this.status = ProviderStatus.READY;
+        return;
+      }
+
+      resolveBooleanEvaluation(): Promise<ResolutionDetails<boolean>> {
+        throw new Error('Method not implemented.');
+      }
+      resolveStringEvaluation(): Promise<ResolutionDetails<string>> {
+        throw new Error('Method not implemented.');
+      }
+      resolveNumberEvaluation(): Promise<ResolutionDetails<number>> {
+        throw new Error('Method not implemented.');
+      }
+      resolveObjectEvaluation<T extends JsonValue>(): Promise<ResolutionDetails<T>> {
+        throw new Error('Method not implemented.');
+      }
+    }
+
+    it('should wait for the provider to successfully initialize', async () => {
+      const spy = jest.spyOn(mockAsyncProvider.prototype, 'initialize');
+
+      const provider = new mockAsyncProvider(false);
+      expect(provider.status).toBe(ProviderStatus.NOT_READY);
+      await OpenFeature.setProviderAndWait(provider);
+      expect(provider.status).toBe(ProviderStatus.READY);
+      expect(spy).toBeCalled();
+    });
+
+    it('should wait for the provider to fail during initialization', async () => {
+      const spy = jest.spyOn(mockAsyncProvider.prototype, 'initialize');
+
+      const provider = new mockAsyncProvider(true);
+      expect(provider.status).toBe(ProviderStatus.NOT_READY);
+      await expect(OpenFeature.setProviderAndWait(provider)).rejects.toThrow();
+      expect(provider.status).toBe(ProviderStatus.ERROR);
+      expect(spy).toBeCalled();
+    });
+  });
+
   describe('Requirement 1.2.1', () => {
     it('should allow addition of hooks', () => {
       expect(OpenFeatureClient.prototype.addHooks).toBeDefined();
@@ -358,7 +418,7 @@ describe('OpenFeatureClient', () => {
       const defaultNumberValue = 123;
       const defaultStringValue = 'hey!';
 
-      beforeAll(async () => {
+      beforeEach(async () => {
         OpenFeature.setProvider(errorProvider);
         client = OpenFeature.getClient();
         nonOpenFeatureErrorDetails = await client.getNumberDetails('some-flag', defaultNumberValue);
diff --git a/packages/server/test/events.spec.ts b/packages/server/test/events.spec.ts
index 9e891630..37e0ba3b 100644
--- a/packages/server/test/events.spec.ts
+++ b/packages/server/test/events.spec.ts
@@ -17,6 +17,7 @@ const TIMEOUT = 1000;
 class MockProvider implements Provider {
   readonly metadata: ProviderMetadata;
   readonly events?: OpenFeatureEventEmitter;
+  readonly runsOn = 'server';
   private hasInitialize: boolean;
   private failOnInit: boolean;
   private initDelay?: number;
diff --git a/packages/server/test/open-feature.spec.ts b/packages/server/test/open-feature.spec.ts
index 8ce3dc30..83097857 100644
--- a/packages/server/test/open-feature.spec.ts
+++ b/packages/server/test/open-feature.spec.ts
@@ -1,15 +1,12 @@
 import { Paradigm } from '@openfeature/shared';
 import { OpenFeature, OpenFeatureAPI, OpenFeatureClient, Provider, ProviderStatus } from '../src';
 
-const mockProvider = (config?: {
-  initialStatus?: ProviderStatus,
-  runsOn?: Paradigm,
-}) => {
+const mockProvider = (config?: { initialStatus?: ProviderStatus; runsOn?: Paradigm }) => {
   return {
     metadata: {
       name: 'mock-events-success',
     },
-    runsOn: config?.runsOn,
+    runsOn: config?.runsOn || 'server',
     status: config?.initialStatus || ProviderStatus.NOT_READY,
     initialize: jest.fn(() => {
       return Promise.resolve('started');
@@ -40,13 +37,13 @@ describe('OpenFeature', () => {
 
     describe('Requirement 1.1.2.1', () => {
       it('should throw because the provider is not intended for the server', () => {
-        const provider = mockProvider({ runsOn: 'client'});
+        const provider = mockProvider({ runsOn: 'client' });
         expect(() => OpenFeature.setProvider(provider)).toThrowError(
           "Provider 'mock-events-success' is intended for use on the client."
         );
       });
       it('should succeed because the provider is intended for the server', () => {
-        const provider = mockProvider({ runsOn: 'server'});
+        const provider = mockProvider({ runsOn: 'server' });
         expect(() => OpenFeature.setProvider(provider)).not.toThrowError();
       });
     });
@@ -59,7 +56,7 @@ describe('OpenFeature', () => {
         expect(provider.initialize).toHaveBeenCalled();
       });
 
-      it('should not invoke initialze function if the provider is not in state NOT_READY', () => {
+      it('should not invoke initialize function if the provider is not in state NOT_READY', () => {
         const provider = mockProvider({ initialStatus: ProviderStatus.READY });
         OpenFeature.setProvider(provider);
         expect(OpenFeature.providerMetadata.name).toBe('mock-events-success');