Skip to content

Commit 8a2a9c3

Browse files
committed
feat: support metadata in errors in OFREP
Signed-off-by: Todd Baert <[email protected]>
1 parent 9de707a commit 8a2a9c3

16 files changed

+278
-253
lines changed

Diff for: libs/hooks/open-telemetry/src/lib/otel-hook.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FlagMetadata, Logger } from '@openfeature/server-sdk';
1+
import type { FlagMetadata, Logger } from '@openfeature/server-sdk';
22
import { Attributes } from '@opentelemetry/api';
33

44
export type AttributeMapper = (flagMetadata: FlagMetadata) => Attributes;

Diff for: libs/providers/ofrep-web/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@
1313
},
1414
"dependencies": {
1515
"undici": "^5.0.0",
16-
"@openfeature/ofrep-core": "0.2.0"
16+
"@openfeature/ofrep-core": "1.0.0"
1717
}
1818
}
+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

+8-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 'libs/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,9 @@ describe('OFREPWebProvider', () => {
187190
flagKey,
188191
value: false,
189192
errorCode: 'PARSE_ERROR',
190-
errorMessage: 'parse error for flag key parse-error: custom error details',
193+
// TODO: there's a bug in the web SDK which calls `instantiateErrorByErrorCode` without a message, so our message is lost :(
194+
// errorMessage: 'Flag or flag configuration could not be parsed',
195+
errorMessage: '',
191196
reason: 'ERROR',
192197
flagMetadata: {},
193198
});

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

+76-60
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,45 @@
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,
20-
GeneralError,
2117
Hook,
22-
InvalidContextError,
2318
JsonValue,
2419
Logger,
2520
OpenFeatureError,
2621
OpenFeatureEventEmitter,
27-
ParseError,
2822
Provider,
2923
ProviderFatalError,
3024
ResolutionDetails,
3125
StandardResolutionReasons,
32-
TargetingKeyMissingError,
33-
TypeMismatchError,
3426
} from '@openfeature/web-sdk';
3527
import { BulkEvaluationStatus, EvaluateFlagsResponse } from './model/evaluate-flags-response';
36-
import { FlagCache } from './model/in-memory-cache';
28+
import { FlagCache, MetadataCache } from './model/in-memory-cache';
3729
import { OFREPWebProviderOptions } from './model/ofrep-web-provider-options';
3830
import { isResolutionError } from './model/resolution-error';
3931

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

@@ -52,10 +55,11 @@ export class OFREPWebProvider implements Provider {
5255
// _options is the options used to configure the provider.
5356
private _options: OFREPWebProviderOptions;
5457
private _ofrepAPI: OFREPApi;
55-
private _etag: string | null;
58+
private _etag: string | null | undefined;
5659
private _pollingInterval: number;
5760
private _retryPollingAfter: Date | undefined;
5861
private _flagCache: FlagCache = {};
62+
private _flagSetMetadataCache: MetadataCache = {};
5963
private _context: EvaluationContext | undefined;
6064
private _pollingIntervalId?: number;
6165

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

8690
if (this._pollingInterval > 0) {
8791
this.startPolling();
@@ -102,28 +106,28 @@ export class OFREPWebProvider implements Provider {
102106
defaultValue: boolean,
103107
context: EvaluationContext,
104108
): ResolutionDetails<boolean> {
105-
return this.evaluate(flagKey, 'boolean');
109+
return this.evaluate(flagKey, 'boolean', defaultValue);
106110
}
107111
resolveStringEvaluation(
108112
flagKey: string,
109113
defaultValue: string,
110114
context: EvaluationContext,
111115
): ResolutionDetails<string> {
112-
return this.evaluate(flagKey, 'string');
116+
return this.evaluate(flagKey, 'string', defaultValue);
113117
}
114118
resolveNumberEvaluation(
115119
flagKey: string,
116120
defaultValue: number,
117121
context: EvaluationContext,
118122
): ResolutionDetails<number> {
119-
return this.evaluate(flagKey, 'number');
123+
return this.evaluate(flagKey, 'number', defaultValue);
120124
}
121125
resolveObjectEvaluation<T extends JsonValue>(
122126
flagKey: string,
123127
defaultValue: T,
124128
context: EvaluationContext,
125129
): ResolutionDetails<T> {
126-
return this.evaluate(flagKey, 'object');
130+
return this.evaluate(flagKey, 'object', defaultValue);
127131
}
128132
/* eslint-enable @typescript-eslint/no-unused-vars */
129133

@@ -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,42 @@ export class OFREPWebProvider implements Provider {
194198
}
195199

196200
if (response.httpStatus !== 200) {
197-
handleEvaluationError(response);
201+
throw new Error(`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 ('metadata' in bulkSuccessResp && typeof bulkSuccessResp.flags === 'object') {
208+
this._flagSetMetadataCache = bulkSuccessResp.metadata || {};
209+
}
211210

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 };
211+
if ('flags' in bulkSuccessResp && typeof bulkSuccessResp.flags === 'object') {
212+
bulkSuccessResp.flags?.forEach((evalResp: EvaluationResponse) => {
213+
if (isEvaluationFailureResponse(evalResp)) {
214+
newCache[evalResp.key] = {
215+
errorCode: evalResp.errorCode,
216+
errorDetails: evalResp.errorDetails,
217+
reason: StandardResolutionReasons.ERROR,
218+
};
219+
}
220+
221+
if (isEvaluationSuccessResponse(evalResp) && evalResp.key) {
222+
newCache[evalResp.key] = {
223+
value: evalResp.value,
224+
flagMetadata: evalResp.metadata,
225+
reason: evalResp.reason,
226+
variant: evalResp.variant,
227+
};
228+
}
229+
});
230+
const listUpdatedFlags = this._getListUpdatedFlags(this._flagCache, newCache);
231+
this._flagCache = newCache;
232+
this._etag = response.httpResponse?.headers.get('etag');
233+
return { status: BulkEvaluationStatus.SUCCESS_WITH_CHANGES, flags: listUpdatedFlags };
234+
} else {
235+
throw new Error('No flags in OFREP bulk evaluation response');
236+
}
225237
} catch (error) {
226238
if (error instanceof OFREPApiTooManyRequestsError && error.retryAfterDate !== null) {
227239
this._retryPollingAfter = error.retryAfterDate;
@@ -263,34 +275,38 @@ export class OFREPWebProvider implements Provider {
263275
* Evaluate is a function retrieving the value from a flag in the cache.
264276
* @param flagKey - name of the flag to retrieve
265277
* @param type - type of the flag
278+
* @param defaultValue - default value
266279
* @private
267280
*/
268-
private evaluate<T extends FlagValue>(flagKey: string, type: string): ResolutionDetails<T> {
281+
private evaluate<T extends FlagValue>(flagKey: string, type: string, defaultValue: T): ResolutionDetails<T> {
269282
const resolved = this._flagCache[flagKey];
283+
270284
if (!resolved) {
271-
throw new FlagNotFoundError(`flag key ${flagKey} not found in cache`);
285+
return {
286+
value: defaultValue,
287+
flagMetadata: this._flagSetMetadataCache,
288+
reason: 'ERROR',
289+
errorCode: ErrorCode.FLAG_NOT_FOUND,
290+
errorMessage: ERROR_TO_MESSAGE[ErrorCode.FLAG_NOT_FOUND],
291+
};
272292
}
273293

274294
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-
}
295+
return {
296+
...resolved,
297+
value: defaultValue,
298+
errorMessage: ERROR_TO_MESSAGE[resolved.errorCode],
299+
};
290300
}
291301

292302
if (typeof resolved.value !== type) {
293-
throw new TypeMismatchError(`flag key ${flagKey} is not of type ${type}`);
303+
return {
304+
value: defaultValue,
305+
flagMetadata: this._flagSetMetadataCache,
306+
reason: 'ERROR',
307+
errorCode: ErrorCode.TYPE_MISMATCH,
308+
errorMessage: ERROR_TO_MESSAGE[ErrorCode.TYPE_MISMATCH],
309+
};
294310
}
295311

296312
return {
@@ -314,7 +330,7 @@ export class OFREPWebProvider implements Provider {
314330
if (this._retryPollingAfter !== undefined && this._retryPollingAfter > now) {
315331
return;
316332
}
317-
const res = await this._evaluateFlags(this._context);
333+
const res = await this._fetchFlags(this._context);
318334
if (res.status === BulkEvaluationStatus.SUCCESS_WITH_CHANGES) {
319335
this.events?.emit(ClientProviderEvents.ConfigurationChanged, {
320336
message: 'Flags updated',

Diff for: libs/providers/ofrep/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
"@openfeature/server-sdk": "^1.6.0"
1313
},
1414
"dependencies": {
15-
"@openfeature/ofrep-core": "0.2.0"
15+
"@openfeature/ofrep-core": "1.0.0"
1616
}
1717
}

0 commit comments

Comments
 (0)