Skip to content

Commit ce37b6a

Browse files
authored
feat: support metadata in errors in OFREP (#1203)
Signed-off-by: Todd Baert <[email protected]>
1 parent 68585ae commit ce37b6a

15 files changed

+299
-275
lines changed
+7-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import { FlagValue, ResolutionDetails } from '@openfeature/web-sdk';
1+
import type { FlagMetadata, FlagValue, ResolutionDetails } from '@openfeature/web-sdk';
22
import { ResolutionError } from './resolution-error';
33

44
/**
5-
* FlagCache is a type representing the internal cache of the flags.
5+
* Cache of flag values from bulk evaluation.
66
*/
77
export type FlagCache = { [key: string]: ResolutionDetails<FlagValue> | ResolutionError };
8+
9+
/**
10+
* Cache of metadata from bulk evaluation.
11+
*/
12+
export type MetadataCache = FlagMetadata;

Diff for: libs/providers/ofrep-web/src/lib/model/resolution-error.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { ResolutionReason } from '@openfeature/web-sdk';
2-
import { EvaluationFailureErrorCode } from '@openfeature/ofrep-core';
1+
import { ErrorCode, ResolutionReason } from '@openfeature/web-sdk';
32

43
export type ResolutionError = {
54
reason: ResolutionReason;
6-
errorCode: EvaluationFailureErrorCode;
5+
errorCode: ErrorCode;
76
errorDetails?: string;
87
};
98

Diff for: libs/providers/ofrep-web/src/lib/ofrep-web-provider.spec.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import TestLogger from '../../test/test-logger';
33
// eslint-disable-next-line @nx/enforce-module-boundaries
44
import { server } from '../../../../shared/ofrep-core/src/test/mock-service-worker';
55
import { ClientProviderEvents, ClientProviderStatus, OpenFeature } from '@openfeature/web-sdk';
6+
// eslint-disable-next-line @nx/enforce-module-boundaries
7+
import { TEST_FLAG_METADATA, TEST_FLAG_SET_METADATA } from '../../../../shared/ofrep-core/src/test/test-constants';
68

79
describe('OFREPWebProvider', () => {
810
beforeAll(() => server.listen());
@@ -144,7 +146,7 @@ describe('OFREPWebProvider', () => {
144146
expect(client.providerStatus).toBe(ClientProviderStatus.ERROR);
145147
});
146148

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

159162
it('should return EvaluationDetails if the flag exists', async () => {
@@ -169,7 +172,7 @@ describe('OFREPWebProvider', () => {
169172
flagKey,
170173
value: true,
171174
variant: 'variantA',
172-
flagMetadata: { context: defaultContext },
175+
flagMetadata: TEST_FLAG_METADATA,
173176
reason: 'STATIC',
174177
});
175178
});
@@ -187,7 +190,7 @@ describe('OFREPWebProvider', () => {
187190
flagKey,
188191
value: false,
189192
errorCode: 'PARSE_ERROR',
190-
errorMessage: 'parse error for flag key parse-error: custom error details',
193+
errorMessage: 'Flag or flag configuration could not be parsed',
191194
reason: 'ERROR',
192195
flagMetadata: {},
193196
});

Diff for: libs/providers/ofrep-web/src/lib/ofrep-web-provider.ts

+75-61
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,46 @@
11
import {
2-
EvaluationFailureErrorCode,
32
EvaluationRequest,
43
EvaluationResponse,
54
OFREPApi,
65
OFREPApiFetchError,
76
OFREPApiTooManyRequestsError,
87
OFREPApiUnauthorizedError,
98
OFREPForbiddenError,
10-
handleEvaluationError,
119
isEvaluationFailureResponse,
1210
isEvaluationSuccessResponse,
1311
} from '@openfeature/ofrep-core';
1412
import {
1513
ClientProviderEvents,
14+
ErrorCode,
1615
EvaluationContext,
17-
FlagMetadata,
18-
FlagNotFoundError,
1916
FlagValue,
2017
GeneralError,
2118
Hook,
22-
InvalidContextError,
2319
JsonValue,
2420
Logger,
2521
OpenFeatureError,
2622
OpenFeatureEventEmitter,
27-
ParseError,
2823
Provider,
2924
ProviderFatalError,
3025
ResolutionDetails,
3126
StandardResolutionReasons,
32-
TargetingKeyMissingError,
33-
TypeMismatchError,
3427
} from '@openfeature/web-sdk';
3528
import { BulkEvaluationStatus, EvaluateFlagsResponse } from './model/evaluate-flags-response';
36-
import { FlagCache } from './model/in-memory-cache';
29+
import { FlagCache, MetadataCache } from './model/in-memory-cache';
3730
import { OFREPWebProviderOptions } from './model/ofrep-web-provider-options';
3831
import { isResolutionError } from './model/resolution-error';
3932

33+
const ErrorMessageMap: { [key in ErrorCode]: string } = {
34+
[ErrorCode.FLAG_NOT_FOUND]: 'Flag was not found',
35+
[ErrorCode.GENERAL]: 'General error',
36+
[ErrorCode.INVALID_CONTEXT]: 'Context is invalid or could be parsed',
37+
[ErrorCode.PARSE_ERROR]: 'Flag or flag configuration could not be parsed',
38+
[ErrorCode.PROVIDER_FATAL]: 'Provider is in a fatal error state',
39+
[ErrorCode.PROVIDER_NOT_READY]: 'Provider is not yet ready',
40+
[ErrorCode.TARGETING_KEY_MISSING]: 'Targeting key is missing',
41+
[ErrorCode.TYPE_MISMATCH]: 'Flag is not of expected type',
42+
};
43+
4044
export class OFREPWebProvider implements Provider {
4145
DEFAULT_POLL_INTERVAL = 30000;
4246

@@ -52,10 +56,11 @@ export class OFREPWebProvider implements Provider {
5256
// _options is the options used to configure the provider.
5357
private _options: OFREPWebProviderOptions;
5458
private _ofrepAPI: OFREPApi;
55-
private _etag: string | null;
59+
private _etag: string | null | undefined;
5660
private _pollingInterval: number;
5761
private _retryPollingAfter: Date | undefined;
5862
private _flagCache: FlagCache = {};
63+
private _flagSetMetadataCache: MetadataCache = {};
5964
private _context: EvaluationContext | undefined;
6065
private _pollingIntervalId?: number;
6166

@@ -81,7 +86,7 @@ export class OFREPWebProvider implements Provider {
8186
async initialize(context?: EvaluationContext | undefined): Promise<void> {
8287
try {
8388
this._context = context;
84-
await this._evaluateFlags(context);
89+
await this._fetchFlags(context);
8590

8691
if (this._pollingInterval > 0) {
8792
this.startPolling();
@@ -102,30 +107,29 @@ export class OFREPWebProvider implements Provider {
102107
defaultValue: boolean,
103108
context: EvaluationContext,
104109
): ResolutionDetails<boolean> {
105-
return this.evaluate(flagKey, 'boolean');
110+
return this._resolve(flagKey, 'boolean', defaultValue);
106111
}
107112
resolveStringEvaluation(
108113
flagKey: string,
109114
defaultValue: string,
110115
context: EvaluationContext,
111116
): ResolutionDetails<string> {
112-
return this.evaluate(flagKey, 'string');
117+
return this._resolve(flagKey, 'string', defaultValue);
113118
}
114119
resolveNumberEvaluation(
115120
flagKey: string,
116121
defaultValue: number,
117122
context: EvaluationContext,
118123
): ResolutionDetails<number> {
119-
return this.evaluate(flagKey, 'number');
124+
return this._resolve(flagKey, 'number', defaultValue);
120125
}
121126
resolveObjectEvaluation<T extends JsonValue>(
122127
flagKey: string,
123128
defaultValue: T,
124129
context: EvaluationContext,
125130
): ResolutionDetails<T> {
126-
return this.evaluate(flagKey, 'object');
131+
return this._resolve(flagKey, 'object', defaultValue);
127132
}
128-
/* eslint-enable @typescript-eslint/no-unused-vars */
129133

130134
/**
131135
* onContextChange is called when the context changes, it will re-evaluate the flags with the new context
@@ -143,7 +147,7 @@ export class OFREPWebProvider implements Provider {
143147
return;
144148
}
145149

146-
await this._evaluateFlags(newContext);
150+
await this._fetchFlags(newContext);
147151
} catch (error) {
148152
if (error instanceof OFREPApiTooManyRequestsError) {
149153
this.events?.emit(ClientProviderEvents.Stale, { message: `${error.name}: ${error.message}` });
@@ -172,7 +176,7 @@ export class OFREPWebProvider implements Provider {
172176
}
173177

174178
/**
175-
* _evaluateFlags is a function that will call the bulk evaluate flags endpoint to get the flags values.
179+
* _fetchFlags is a function that will call the bulk evaluate flags endpoint to get the flags values.
176180
* @param context - the context to use for the evaluation
177181
* @private
178182
* @returns EvaluationStatus if the evaluation the API returned a 304, 200.
@@ -181,7 +185,7 @@ export class OFREPWebProvider implements Provider {
181185
* @throws ParseError if the API returned a 400 with the error code ParseError
182186
* @throws GeneralError if the API returned a 400 with an unknown error code
183187
*/
184-
private async _evaluateFlags(context?: EvaluationContext | undefined): Promise<EvaluateFlagsResponse> {
188+
private async _fetchFlags(context?: EvaluationContext | undefined): Promise<EvaluateFlagsResponse> {
185189
try {
186190
const evalReq: EvaluationRequest = {
187191
context,
@@ -194,34 +198,40 @@ export class OFREPWebProvider implements Provider {
194198
}
195199

196200
if (response.httpStatus !== 200) {
197-
handleEvaluationError(response);
201+
throw new GeneralError(`Failed OFREP bulk evaluation request, status: ${response.httpStatus}`);
198202
}
199203

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

203-
bulkSuccessResp.flags?.forEach((evalResp: EvaluationResponse) => {
204-
if (isEvaluationFailureResponse(evalResp)) {
205-
newCache[evalResp.key] = {
206-
errorCode: evalResp.errorCode,
207-
errorDetails: evalResp.errorDetails,
208-
reason: StandardResolutionReasons.ERROR,
209-
};
210-
}
207+
if ('flags' in bulkSuccessResp && Array.isArray(bulkSuccessResp.flags)) {
208+
bulkSuccessResp.flags.forEach((evalResp: EvaluationResponse) => {
209+
if (isEvaluationFailureResponse(evalResp)) {
210+
newCache[evalResp.key] = {
211+
reason: StandardResolutionReasons.ERROR,
212+
flagMetadata: evalResp.metadata,
213+
errorCode: evalResp.errorCode,
214+
errorDetails: evalResp.errorDetails,
215+
};
216+
}
211217

212-
if (isEvaluationSuccessResponse(evalResp) && evalResp.key) {
213-
newCache[evalResp.key] = {
214-
value: evalResp.value,
215-
flagMetadata: evalResp.metadata as FlagMetadata,
216-
reason: evalResp.reason,
217-
variant: evalResp.variant,
218-
};
219-
}
220-
});
221-
const listUpdatedFlags = this._getListUpdatedFlags(this._flagCache, newCache);
222-
this._flagCache = newCache;
223-
this._etag = response.httpResponse?.headers.get('etag');
224-
return { status: BulkEvaluationStatus.SUCCESS_WITH_CHANGES, flags: listUpdatedFlags };
218+
if (isEvaluationSuccessResponse(evalResp) && evalResp.key) {
219+
newCache[evalResp.key] = {
220+
value: evalResp.value,
221+
variant: evalResp.variant,
222+
reason: evalResp.reason,
223+
flagMetadata: evalResp.metadata,
224+
};
225+
}
226+
});
227+
const listUpdatedFlags = this._getListUpdatedFlags(this._flagCache, newCache);
228+
this._flagCache = newCache;
229+
this._etag = response.httpResponse?.headers.get('etag');
230+
this._flagSetMetadataCache = typeof bulkSuccessResp.metadata === 'object' ? bulkSuccessResp.metadata : {};
231+
return { status: BulkEvaluationStatus.SUCCESS_WITH_CHANGES, flags: listUpdatedFlags };
232+
} else {
233+
throw new Error('No flags in OFREP bulk evaluation response');
234+
}
225235
} catch (error) {
226236
if (error instanceof OFREPApiTooManyRequestsError && error.retryAfterDate !== null) {
227237
this._retryPollingAfter = error.retryAfterDate;
@@ -260,37 +270,41 @@ export class OFREPWebProvider implements Provider {
260270
}
261271

262272
/**
263-
* Evaluate is a function retrieving the value from a flag in the cache.
273+
* _resolve is a function retrieving the value from a flag in the cache.
264274
* @param flagKey - name of the flag to retrieve
265275
* @param type - type of the flag
276+
* @param defaultValue - default value
266277
* @private
267278
*/
268-
private evaluate<T extends FlagValue>(flagKey: string, type: string): ResolutionDetails<T> {
279+
private _resolve<T extends FlagValue>(flagKey: string, type: string, defaultValue: T): ResolutionDetails<T> {
269280
const resolved = this._flagCache[flagKey];
281+
270282
if (!resolved) {
271-
throw new FlagNotFoundError(`flag key ${flagKey} not found in cache`);
283+
return {
284+
value: defaultValue,
285+
flagMetadata: this._flagSetMetadataCache,
286+
reason: StandardResolutionReasons.ERROR,
287+
errorCode: ErrorCode.FLAG_NOT_FOUND,
288+
errorMessage: ErrorMessageMap[ErrorCode.FLAG_NOT_FOUND],
289+
};
272290
}
273291

274292
if (isResolutionError(resolved)) {
275-
switch (resolved.errorCode) {
276-
case EvaluationFailureErrorCode.FlagNotFound:
277-
throw new FlagNotFoundError(`flag key ${flagKey} not found: ${resolved.errorDetails}`);
278-
case EvaluationFailureErrorCode.TargetingKeyMissing:
279-
throw new TargetingKeyMissingError(`targeting key missing for flag key ${flagKey}: ${resolved.errorDetails}`);
280-
case EvaluationFailureErrorCode.InvalidContext:
281-
throw new InvalidContextError(`invalid context for flag key ${flagKey}: ${resolved.errorDetails}`);
282-
case EvaluationFailureErrorCode.ParseError:
283-
throw new ParseError(`parse error for flag key ${flagKey}: ${resolved.errorDetails}`);
284-
case EvaluationFailureErrorCode.General:
285-
default:
286-
throw new GeneralError(
287-
`general error during flag evaluation for flag key ${flagKey}: ${resolved.errorDetails}`,
288-
);
289-
}
293+
return {
294+
...resolved,
295+
value: defaultValue,
296+
errorMessage: ErrorMessageMap[resolved.errorCode],
297+
};
290298
}
291299

292300
if (typeof resolved.value !== type) {
293-
throw new TypeMismatchError(`flag key ${flagKey} is not of type ${type}`);
301+
return {
302+
value: defaultValue,
303+
flagMetadata: resolved.flagMetadata,
304+
reason: StandardResolutionReasons.ERROR,
305+
errorCode: ErrorCode.TYPE_MISMATCH,
306+
errorMessage: ErrorMessageMap[ErrorCode.TYPE_MISMATCH],
307+
};
294308
}
295309

296310
return {
@@ -314,7 +328,7 @@ export class OFREPWebProvider implements Provider {
314328
if (this._retryPollingAfter !== undefined && this._retryPollingAfter > now) {
315329
return;
316330
}
317-
const res = await this._evaluateFlags(this._context);
331+
const res = await this._fetchFlags(this._context);
318332
if (res.status === BulkEvaluationStatus.SUCCESS_WITH_CHANGES) {
319333
this.events?.emit(ClientProviderEvents.ConfigurationChanged, {
320334
message: 'Flags updated',

0 commit comments

Comments
 (0)