Skip to content
Open
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
20 changes: 13 additions & 7 deletions packages/web/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,23 +392,29 @@ export class OpenFeatureAPI
});
this._apiEmitter?.emit(ProviderEvents.Reconciling, { domain, providerName });

await maybePromise;
wrapper.decrementPendingContextChanges();
try {
await maybePromise;
} finally {
// decrement exactly once per increment, even if the handler rejects;
// decrementing in the catch below would corrupt the counter for synchronously-thrown errors
wrapper.decrementPendingContextChanges();
}
}
}
// only run the event handlers, and update the state if the onContextChange method succeeded
wrapper.status = this._statusEnumType.READY;
// only update the status and run the event handlers if the onContextChange method succeeded and
// all in-flight context changes have settled; otherwise a fast-completing change would report
// READY while an earlier change is still reconciling against a different context
if (wrapper.allContextChangesSettled) {
wrapper.status = this._statusEnumType.READY;
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
}
} catch (err) {
// run error handlers instead
wrapper.decrementPendingContextChanges();
wrapper.status = this._statusEnumType.ERROR;
// run error handlers instead, once all in-flight context changes have settled
if (wrapper.allContextChangesSettled) {
wrapper.status = this._statusEnumType.ERROR;
const error = err as Error | undefined;
const message = `Error running ${providerName}'s context change handler: ${error?.message}`;
this._logger?.error(`${message}`, err);
Expand Down
75 changes: 75 additions & 0 deletions packages/web/test/events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,4 +881,79 @@ describe('Events', () => {
expect(client.providerStatus).toEqual(ProviderStatus.ERROR);
});
});

describe('concurrent context changes', () => {
it('does not report READY while an earlier context change is still reconciling', async () => {
const resolvers: Array<() => void> = [];
const provider = {
metadata: { name: 'concurrent-context-change-provider' },
runsOn: 'client',
onContextChange: jest.fn(() => new Promise<void>((resolve) => resolvers.push(resolve))),
resolveBooleanEvaluation: jest.fn(),
resolveStringEvaluation: jest.fn(),
resolveNumberEvaluation: jest.fn(),
resolveObjectEvaluation: jest.fn(),
} as unknown as Provider;

const testDomain = uuid();
await OpenFeature.setProviderAndWait(testDomain, provider);
const client = OpenFeature.getClient(testDomain);
const contextChangedHandler = jest.fn();
client.addHandler(ProviderEvents.ContextChanged, contextChangedHandler);

const firstChange = OpenFeature.setContext(testDomain, { change: 1 });
const secondChange = OpenFeature.setContext(testDomain, { change: 2 });
expect(resolvers).toHaveLength(2);

// complete the second (newer) change while the first is still in flight
resolvers[1]();
await new Promise((resolve) => setTimeout(resolve));

expect(client.providerStatus).toEqual(ProviderStatus.RECONCILING);
expect(contextChangedHandler).not.toHaveBeenCalled();

// complete the first (older) change
resolvers[0]();
await Promise.all([firstChange, secondChange]);

expect(client.providerStatus).toEqual(ProviderStatus.READY);
expect(contextChangedHandler).toHaveBeenCalledTimes(1);
});

it('recovers status and events after onContextChange throws synchronously', async () => {
let shouldThrow = true;
const provider = {
metadata: { name: 'sync-throw-provider' },
runsOn: 'client',
onContextChange: jest.fn(() => {
if (shouldThrow) {
throw new Error(ERR_MESSAGE);
}
return Promise.resolve();
}),
resolveBooleanEvaluation: jest.fn(),
resolveStringEvaluation: jest.fn(),
resolveNumberEvaluation: jest.fn(),
resolveObjectEvaluation: jest.fn(),
} as unknown as Provider;

const testDomain = uuid();
await OpenFeature.setProviderAndWait(testDomain, provider);
const client = OpenFeature.getClient(testDomain);
const errorHandler = jest.fn();
const contextChangedHandler = jest.fn();
client.addHandler(ProviderEvents.Error, errorHandler);
client.addHandler(ProviderEvents.ContextChanged, contextChangedHandler);

await OpenFeature.setContext(testDomain, { attempt: 1 });
expect(client.providerStatus).toEqual(ProviderStatus.ERROR);
expect(errorHandler).toHaveBeenCalledTimes(1);

// a subsequent successful change must return the provider to READY
shouldThrow = false;
await OpenFeature.setContext(testDomain, { attempt: 2 });
expect(client.providerStatus).toEqual(ProviderStatus.READY);
expect(contextChangedHandler).toHaveBeenCalledTimes(1);
});
});
});
Loading