Skip to content

feat: support metadata in errors in OFREP #1203

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 13, 2025
Merged
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
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
136 changes: 75 additions & 61 deletions libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,46 @@
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 +56,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 +86,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 +107,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 +147,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 +176,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 +185,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 +198,40 @@ export class OFREPWebProvider implements Provider {
}

if (response.httpStatus !== 200) {
handleEvaluationError(response);
throw new GeneralError(`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 +270,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 +328,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
Loading