Skip to content
Draft
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
93 changes: 58 additions & 35 deletions packages/shared/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { BaseHook, EvaluationLifeCycle } from './hooks';
import type { Logger, ManageLogger } from './logger';
import { DefaultLogger, SafeLogger } from './logger';
import type { ClientProviderStatus, CommonProvider, ProviderMetadata, ServerProviderStatus } from './provider';
import { isStateManagingProvider } from './provider';
import { objectOrUndefined, stringOrUndefined } from './type-guards';
import type { Paradigm } from './types';

Expand All @@ -27,28 +28,31 @@ type AnyProviderStatus = ClientProviderStatus | ServerProviderStatus;
*/
export class ProviderWrapper<P extends CommonProvider<AnyProviderStatus>, S extends AnyProviderStatus> {
private _pendingContextChanges = 0;
private readonly _delegateManagesState: boolean;

constructor(
private _provider: P,
private _status: S,
_statusEnumType: typeof ClientProviderStatus | typeof ServerProviderStatus,
) {
// update the providers status with events
_provider.events?.addHandler(AllProviderEvents.Ready, () => {
// These casts are due to the face we don't "know" what status enum we are dealing with here (client or server).
// We could abstract this an implement it in the client/server libs to fix this, but the value is low.
this._status = _statusEnumType.READY as S;
});
_provider.events?.addHandler(AllProviderEvents.Stale, () => {
this._status = _statusEnumType.STALE as S;
});
_provider.events?.addHandler(AllProviderEvents.Error, (details) => {
if (details?.errorCode === ErrorCode.PROVIDER_FATAL) {
this._status = _statusEnumType.FATAL as S;
} else {
this._status = _statusEnumType.ERROR as S;
}
});
this._delegateManagesState = isStateManagingProvider(_provider);

// For legacy providers, update status from events. State-managing providers own their own status.
if (!this._delegateManagesState) {
_provider.events?.addHandler(AllProviderEvents.Ready, () => {
this._status = _statusEnumType.READY as S;
});
_provider.events?.addHandler(AllProviderEvents.Stale, () => {
this._status = _statusEnumType.STALE as S;
});
_provider.events?.addHandler(AllProviderEvents.Error, (details) => {
if (details?.errorCode === ErrorCode.PROVIDER_FATAL) {
this._status = _statusEnumType.FATAL as S;
} else {
this._status = _statusEnumType.ERROR as S;
}
});
}
}

get provider(): P {
Expand All @@ -60,11 +64,21 @@ export class ProviderWrapper<P extends CommonProvider<AnyProviderStatus>, S exte
}

get status(): S {
if (this._delegateManagesState) {
return this._provider.status as S;
}
return this._status;
}

set status(status: S) {
this._status = status;
// No-op for state-managing providers — they own their own status.
if (!this._delegateManagesState) {
this._status = status;
}
}

get delegateManagesState(): boolean {
return this._delegateManagesState;
}

get allContextChangesSettled() {
Expand Down Expand Up @@ -256,11 +270,14 @@ export abstract class OpenFeatureCommonAPI<
.initialize?.(domain ? (this._domainScopedContext.get(domain) ?? this._context) : this._context)
?.then(() => {
wrappedProvider.status = this._statusEnumType.READY;
// fetch the most recent event emitters, some may have been added during init
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
});
this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
// State-managing providers emit their own events; skip SDK-side emission.
if (!wrappedProvider.delegateManagesState) {
// fetch the most recent event emitters, some may have been added during init
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
});
this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
}
})
?.catch((error) => {
// if this is a fatal error, transition to FATAL status
Expand All @@ -269,29 +286,35 @@ export abstract class OpenFeatureCommonAPI<
} else {
wrappedProvider.status = this._statusEnumType.ERROR;
}
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(AllProviderEvents.Error, {
// State-managing providers emit their own events; skip SDK-side emission.
if (!wrappedProvider.delegateManagesState) {
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(AllProviderEvents.Error, {
clientName: domain,
domain,
providerName,
message: error?.message,
});
});
this._apiEmitter?.emit(AllProviderEvents.Error, {
clientName: domain,
domain,
providerName,
message: error?.message,
});
});
this._apiEmitter?.emit(AllProviderEvents.Error, {
clientName: domain,
domain,
providerName,
message: error?.message,
});
}
// rethrow after emitting error events, so that public methods can control error handling
throw error;
});
} else {
wrappedProvider.status = this._statusEnumType.READY;
emitters.forEach((emitter) => {
emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
});
this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
// State-managing providers emit their own events; skip SDK-side emission.
if (!wrappedProvider.delegateManagesState) {
emitters.forEach((emitter) => {
emitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
});
this._apiEmitter?.emit(AllProviderEvents.Ready, { clientName: domain, domain, providerName });
}
}

if (domain) {
Expand Down
34 changes: 34 additions & 0 deletions packages/shared/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,37 @@
*/
track?(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void;
}

/**
* A provider that manages its own state. The SDK reads state from the provider
* rather than maintaining shadow state. Implementations MUST ensure that `status`
* is safe for concurrent access and that state transitions and associated event
* emissions are atomic from the perspective of external observers.
*
* Legacy providers that do not implement this interface continue to have their state
* managed by the SDK (deprecated behavior, to be removed in the next major version).
*/
export interface StateManagingProvider<
S extends ClientProviderStatus | ServerProviderStatus,
> extends CommonProvider<S> {
/**
* The current state of this provider. Must reflect NOT_READY before initialize()
* is called and after onClose() completes. Must reflect READY if initialize()
* resolves successfully.
*/
readonly status: S;

/**
* Discriminant indicating that this provider manages its own state.
*/
readonly managesState: true;
}

/**

Check warning on line 164 in packages/shared/src/provider/provider.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @returns declaration

Check warning on line 164 in packages/shared/src/provider/provider.ts

View workflow job for this annotation

GitHub Actions / format-lint

Missing JSDoc @PARAM "provider" declaration
* Type guard for providers that manage their own state.
*/
export function isStateManagingProvider<S extends ClientProviderStatus | ServerProviderStatus>(
provider: CommonProvider<S>,
): provider is StateManagingProvider<S> {
return 'managesState' in provider && (provider as StateManagingProvider<S>).managesState === true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Casting to the full StateManagingProvider<S> interface within the type guard that is meant to validate it can be slightly circular. A more precise approach is to cast to a minimal anonymous type that only contains the property being checked. This improves type safety by not over-stating the type before it's fully validated.

Suggested change
return 'managesState' in provider && (provider as StateManagingProvider<S>).managesState === true;
return 'managesState' in provider && (provider as { managesState?: boolean }).managesState === true;

}
48 changes: 28 additions & 20 deletions packages/web/src/open-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ export class OpenFeatureAPI
): Promise<void> {
// this should always be set according to the typings, but let's be defensive considering JS
const providerName = wrapper.provider?.metadata?.name || 'unnamed-provider';
const skipStateAndEvents = wrapper.delegateManagesState;

try {
if (typeof wrapper.provider.onContextChange === 'function') {
Expand All @@ -386,36 +387,43 @@ export class OpenFeatureAPI
// only reconcile if the onContextChange method returns a promise
if (maybePromise && typeof maybePromise?.then === 'function') {
wrapper.incrementPendingContextChanges();
wrapper.status = this._statusEnumType.RECONCILING;
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
// State-managing providers own their own state and event emissions.
if (!skipStateAndEvents) {
wrapper.status = this._statusEnumType.RECONCILING;
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.Reconciling, { domain, providerName });
}

await maybePromise;
wrapper.decrementPendingContextChanges();
}
}
// only run the event handlers, and update the state if the onContextChange method succeeded
wrapper.status = this._statusEnumType.READY;
if (wrapper.allContextChangesSettled) {
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
});
this._apiEmitter?.emit(ProviderEvents.ContextChanged, { clientName: domain, domain, providerName });
if (!skipStateAndEvents) {
wrapper.status = this._statusEnumType.READY;
if (wrapper.allContextChangesSettled) {
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;
if (wrapper.allContextChangesSettled) {
const error = err as Error | undefined;
const message = `Error running ${providerName}'s context change handler: ${error?.message}`;
this._logger?.error(`${message}`, err);
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
});
this._apiEmitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
if (!skipStateAndEvents) {
wrapper.status = this._statusEnumType.ERROR;
if (wrapper.allContextChangesSettled) {
const error = err as Error | undefined;
const message = `Error running ${providerName}'s context change handler: ${error?.message}`;
this._logger?.error(`${message}`, err);
this.getAssociatedEventEmitters(domain).forEach((emitter) => {
emitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
});
this._apiEmitter?.emit(ProviderEvents.Error, { clientName: domain, domain, providerName, message });
}
}
}
}
Expand Down
Loading