Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion libs/hooks/open-telemetry/src/lib/otel-hook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FlagMetadata, Logger } from '@openfeature/server-sdk';
import type { FlagMetadata, Logger } from '@openfeature/server-sdk';
import { Attributes } from '@opentelemetry/api';

export type AttributeMapper = (flagMetadata: FlagMetadata) => Attributes;
Expand Down
9 changes: 7 additions & 2 deletions libs/providers/ofrep-web/src/lib/model/in-memory-cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { FlagValue, ResolutionDetails } from '@openfeature/web-sdk';
import type { FlagMetadata, FlagValue, ResolutionDetails } from '@openfeature/web-sdk';
import { ResolutionError } from './resolution-error';

/**
* FlagCache is a type representing the internal cache of the flags.
* Cache of flag values from bulk evaluation.
*/
export type FlagCache = { [key: string]: ResolutionDetails<FlagValue> | ResolutionError };

/**
* Cache of metadata from bulk evaluation.
*/
export type MetadataCache = FlagMetadata;
5 changes: 2 additions & 3 deletions libs/providers/ofrep-web/src/lib/model/resolution-error.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { ResolutionReason } from '@openfeature/web-sdk';
import { EvaluationFailureErrorCode } from '@openfeature/ofrep-core';
import { ErrorCode, ResolutionReason } from '@openfeature/web-sdk';

export type ResolutionError = {
reason: ResolutionReason;
errorCode: EvaluationFailureErrorCode;
errorCode: ErrorCode;
errorDetails?: string;
};

Expand Down
9 changes: 6 additions & 3 deletions libs/providers/ofrep-web/src/lib/ofrep-web-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import TestLogger from '../../test/test-logger';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { server } from '../../../../shared/ofrep-core/src/test/mock-service-worker';
import { ClientProviderEvents, ClientProviderStatus, OpenFeature } from '@openfeature/web-sdk';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { TEST_FLAG_METADATA, TEST_FLAG_SET_METADATA } from '../../../../shared/ofrep-core/src/test/test-constants';

describe('OFREPWebProvider', () => {
beforeAll(() => server.listen());
Expand Down Expand Up @@ -144,7 +146,7 @@ describe('OFREPWebProvider', () => {
expect(client.providerStatus).toBe(ClientProviderStatus.ERROR);
});

it('should return a FLAG_NOT_FOUND error if the flag does not exist', async () => {
it('should return a FLAG_NOT_FOUND error and flag set metadata if the flag does not exist', async () => {
const providerName = expect.getState().currentTestName || 'test-provider';
const provider = new OFREPWebProvider({ baseUrl: endpointBaseURL }, new TestLogger());
await OpenFeature.setContext(defaultContext);
Expand All @@ -154,6 +156,7 @@ describe('OFREPWebProvider', () => {
const flag = client.getBooleanDetails('non-existent-flag', false);
expect(flag.errorCode).toBe('FLAG_NOT_FOUND');
expect(flag.value).toBe(false);
expect(flag.flagMetadata).toEqual(TEST_FLAG_SET_METADATA);
});

it('should return EvaluationDetails if the flag exists', async () => {
Expand All @@ -169,7 +172,7 @@ describe('OFREPWebProvider', () => {
flagKey,
value: true,
variant: 'variantA',
flagMetadata: { context: defaultContext },
flagMetadata: TEST_FLAG_METADATA,
reason: 'STATIC',
});
});
Expand All @@ -187,7 +190,7 @@ describe('OFREPWebProvider', () => {
flagKey,
value: false,
errorCode: 'PARSE_ERROR',
errorMessage: 'parse error for flag key parse-error: custom error details',
errorMessage: 'Flag or flag configuration could not be parsed',
reason: 'ERROR',
flagMetadata: {},
});
Expand Down
137 changes: 75 additions & 62 deletions libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,45 @@
import {
EvaluationFailureErrorCode,
EvaluationRequest,
EvaluationResponse,
OFREPApi,
OFREPApiFetchError,
OFREPApiTooManyRequestsError,
OFREPApiUnauthorizedError,
OFREPForbiddenError,
handleEvaluationError,
isEvaluationFailureResponse,
isEvaluationSuccessResponse,
} from '@openfeature/ofrep-core';
import {
ClientProviderEvents,
ErrorCode,
EvaluationContext,
FlagMetadata,
FlagNotFoundError,
FlagValue,
GeneralError,
Hook,
InvalidContextError,
JsonValue,
Logger,
OpenFeatureError,
OpenFeatureEventEmitter,
ParseError,
Provider,
ProviderFatalError,
ResolutionDetails,
StandardResolutionReasons,
TargetingKeyMissingError,
TypeMismatchError,
} from '@openfeature/web-sdk';
import { BulkEvaluationStatus, EvaluateFlagsResponse } from './model/evaluate-flags-response';
import { FlagCache } from './model/in-memory-cache';
import { FlagCache, MetadataCache } from './model/in-memory-cache';
import { OFREPWebProviderOptions } from './model/ofrep-web-provider-options';
import { isResolutionError } from './model/resolution-error';

const ErrorMessageMap: { [key in ErrorCode]: string } = {
[ErrorCode.FLAG_NOT_FOUND]: 'Flag was not found',
[ErrorCode.GENERAL]: 'General error',
[ErrorCode.INVALID_CONTEXT]: 'Context is invalid or could be parsed',
[ErrorCode.PARSE_ERROR]: 'Flag or flag configuration could not be parsed',
[ErrorCode.PROVIDER_FATAL]: 'Provider is in a fatal error state',
[ErrorCode.PROVIDER_NOT_READY]: 'Provider is not yet ready',
[ErrorCode.TARGETING_KEY_MISSING]: 'Targeting key is missing',
[ErrorCode.TYPE_MISMATCH]: 'Flag is not of expected type',
};

export class OFREPWebProvider implements Provider {
DEFAULT_POLL_INTERVAL = 30000;

Expand All @@ -52,10 +55,11 @@ export class OFREPWebProvider implements Provider {
// _options is the options used to configure the provider.
private _options: OFREPWebProviderOptions;
private _ofrepAPI: OFREPApi;
private _etag: string | null;
private _etag: string | null | undefined;
private _pollingInterval: number;
private _retryPollingAfter: Date | undefined;
private _flagCache: FlagCache = {};
private _flagSetMetadataCache: MetadataCache = {};
private _context: EvaluationContext | undefined;
private _pollingIntervalId?: number;

Expand All @@ -81,7 +85,7 @@ export class OFREPWebProvider implements Provider {
async initialize(context?: EvaluationContext | undefined): Promise<void> {
try {
this._context = context;
await this._evaluateFlags(context);
await this._fetchFlags(context);

if (this._pollingInterval > 0) {
this.startPolling();
Expand All @@ -102,30 +106,29 @@ export class OFREPWebProvider implements Provider {
defaultValue: boolean,
context: EvaluationContext,
): ResolutionDetails<boolean> {
return this.evaluate(flagKey, 'boolean');
return this._resolve(flagKey, 'boolean', defaultValue);
}
resolveStringEvaluation(
flagKey: string,
defaultValue: string,
context: EvaluationContext,
): ResolutionDetails<string> {
return this.evaluate(flagKey, 'string');
return this._resolve(flagKey, 'string', defaultValue);
}
resolveNumberEvaluation(
flagKey: string,
defaultValue: number,
context: EvaluationContext,
): ResolutionDetails<number> {
return this.evaluate(flagKey, 'number');
return this._resolve(flagKey, 'number', defaultValue);
}
resolveObjectEvaluation<T extends JsonValue>(
flagKey: string,
defaultValue: T,
context: EvaluationContext,
): ResolutionDetails<T> {
return this.evaluate(flagKey, 'object');
return this._resolve(flagKey, 'object', defaultValue);
}
/* eslint-enable @typescript-eslint/no-unused-vars */

/**
* onContextChange is called when the context changes, it will re-evaluate the flags with the new context
Expand All @@ -143,7 +146,7 @@ export class OFREPWebProvider implements Provider {
return;
}

await this._evaluateFlags(newContext);
await this._fetchFlags(newContext);
} catch (error) {
if (error instanceof OFREPApiTooManyRequestsError) {
this.events?.emit(ClientProviderEvents.Stale, { message: `${error.name}: ${error.message}` });
Expand Down Expand Up @@ -172,7 +175,7 @@ export class OFREPWebProvider implements Provider {
}

/**
* _evaluateFlags is a function that will call the bulk evaluate flags endpoint to get the flags values.
* _fetchFlags is a function that will call the bulk evaluate flags endpoint to get the flags values.
* @param context - the context to use for the evaluation
* @private
* @returns EvaluationStatus if the evaluation the API returned a 304, 200.
Expand All @@ -181,7 +184,7 @@ export class OFREPWebProvider implements Provider {
* @throws ParseError if the API returned a 400 with the error code ParseError
* @throws GeneralError if the API returned a 400 with an unknown error code
*/
private async _evaluateFlags(context?: EvaluationContext | undefined): Promise<EvaluateFlagsResponse> {
private async _fetchFlags(context?: EvaluationContext | undefined): Promise<EvaluateFlagsResponse> {
try {
const evalReq: EvaluationRequest = {
context,
Expand All @@ -194,34 +197,40 @@ export class OFREPWebProvider implements Provider {
}

if (response.httpStatus !== 200) {
handleEvaluationError(response);
throw new Error(`Failed OFREP bulk evaluation request, status: ${response.httpStatus}`);
}

const bulkSuccessResp = response.value;
const newCache: FlagCache = {};

bulkSuccessResp.flags?.forEach((evalResp: EvaluationResponse) => {
if (isEvaluationFailureResponse(evalResp)) {
newCache[evalResp.key] = {
errorCode: evalResp.errorCode,
errorDetails: evalResp.errorDetails,
reason: StandardResolutionReasons.ERROR,
};
}
if ('flags' in bulkSuccessResp && Array.isArray(bulkSuccessResp.flags)) {
bulkSuccessResp.flags.forEach((evalResp: EvaluationResponse) => {
if (isEvaluationFailureResponse(evalResp)) {
newCache[evalResp.key] = {
reason: StandardResolutionReasons.ERROR,
flagMetadata: evalResp.metadata,
errorCode: evalResp.errorCode,
errorDetails: evalResp.errorDetails,
};
}

if (isEvaluationSuccessResponse(evalResp) && evalResp.key) {
newCache[evalResp.key] = {
value: evalResp.value,
flagMetadata: evalResp.metadata as FlagMetadata,
reason: evalResp.reason,
variant: evalResp.variant,
};
}
});
const listUpdatedFlags = this._getListUpdatedFlags(this._flagCache, newCache);
this._flagCache = newCache;
this._etag = response.httpResponse?.headers.get('etag');
return { status: BulkEvaluationStatus.SUCCESS_WITH_CHANGES, flags: listUpdatedFlags };
if (isEvaluationSuccessResponse(evalResp) && evalResp.key) {
newCache[evalResp.key] = {
value: evalResp.value,
variant: evalResp.variant,
reason: evalResp.reason,
flagMetadata: evalResp.metadata,
};
}
});
const listUpdatedFlags = this._getListUpdatedFlags(this._flagCache, newCache);
this._flagCache = newCache;
this._etag = response.httpResponse?.headers.get('etag');
this._flagSetMetadataCache = typeof bulkSuccessResp.metadata === 'object' ? bulkSuccessResp.metadata : {};
return { status: BulkEvaluationStatus.SUCCESS_WITH_CHANGES, flags: listUpdatedFlags };
} else {
throw new Error('No flags in OFREP bulk evaluation response');
}
} catch (error) {
if (error instanceof OFREPApiTooManyRequestsError && error.retryAfterDate !== null) {
this._retryPollingAfter = error.retryAfterDate;
Expand Down Expand Up @@ -260,37 +269,41 @@ export class OFREPWebProvider implements Provider {
}

/**
* Evaluate is a function retrieving the value from a flag in the cache.
* _resolve is a function retrieving the value from a flag in the cache.
* @param flagKey - name of the flag to retrieve
* @param type - type of the flag
* @param defaultValue - default value
* @private
*/
private evaluate<T extends FlagValue>(flagKey: string, type: string): ResolutionDetails<T> {
private _resolve<T extends FlagValue>(flagKey: string, type: string, defaultValue: T): ResolutionDetails<T> {
const resolved = this._flagCache[flagKey];

if (!resolved) {
throw new FlagNotFoundError(`flag key ${flagKey} not found in cache`);
return {
value: defaultValue,
flagMetadata: this._flagSetMetadataCache,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.FLAG_NOT_FOUND,
errorMessage: ErrorMessageMap[ErrorCode.FLAG_NOT_FOUND],
};
}

if (isResolutionError(resolved)) {
switch (resolved.errorCode) {
case EvaluationFailureErrorCode.FlagNotFound:
throw new FlagNotFoundError(`flag key ${flagKey} not found: ${resolved.errorDetails}`);
case EvaluationFailureErrorCode.TargetingKeyMissing:
throw new TargetingKeyMissingError(`targeting key missing for flag key ${flagKey}: ${resolved.errorDetails}`);
case EvaluationFailureErrorCode.InvalidContext:
throw new InvalidContextError(`invalid context for flag key ${flagKey}: ${resolved.errorDetails}`);
case EvaluationFailureErrorCode.ParseError:
throw new ParseError(`parse error for flag key ${flagKey}: ${resolved.errorDetails}`);
case EvaluationFailureErrorCode.General:
default:
throw new GeneralError(
`general error during flag evaluation for flag key ${flagKey}: ${resolved.errorDetails}`,
);
}
return {
...resolved,
value: defaultValue,
errorMessage: ErrorMessageMap[resolved.errorCode],
};
}

if (typeof resolved.value !== type) {
throw new TypeMismatchError(`flag key ${flagKey} is not of type ${type}`);
return {
value: defaultValue,
flagMetadata: resolved.flagMetadata,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.TYPE_MISMATCH,
errorMessage: ErrorMessageMap[ErrorCode.TYPE_MISMATCH],
};
}

return {
Expand All @@ -314,7 +327,7 @@ export class OFREPWebProvider implements Provider {
if (this._retryPollingAfter !== undefined && this._retryPollingAfter > now) {
return;
}
const res = await this._evaluateFlags(this._context);
const res = await this._fetchFlags(this._context);
if (res.status === BulkEvaluationStatus.SUCCESS_WITH_CHANGES) {
this.events?.emit(ClientProviderEvents.ConfigurationChanged, {
message: 'Flags updated',
Expand Down
Loading