Skip to content

Commit 31cc62b

Browse files
committed
fix(flags): preserve invalid rules errors
1 parent 82c174d commit 31cc62b

3 files changed

Lines changed: 127 additions & 16 deletions

File tree

packages/core/src/flags/configuration/__tests__/rules.test.ts

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('rules configuration', () => {
6666
).toBe(true);
6767
});
6868

69-
it('omits a flag that uses an unsupported operator', () => {
69+
it('preserves a flag with an unsupported operator and reports PARSE_ERROR', () => {
7070
const source = buildRulesConfiguration();
7171
const condition =
7272
source.flags['dynamic-flag'].allocations[0].rules?.[0]
@@ -83,10 +83,25 @@ describe('rules configuration', () => {
8383
if (prepared.status !== 'ready') {
8484
throw new Error(prepared.errorMessage);
8585
}
86-
expect(prepared.configuration.flags).toEqual({});
86+
expect(prepared.configuration.flags).toHaveProperty('dynamic-flag');
87+
expect(
88+
flaggingCoreRulesEngine.evaluate({
89+
configuration: prepared.configuration,
90+
type: 'boolean',
91+
flagKey: 'dynamic-flag',
92+
defaultValue: false,
93+
context: { targetingKey: 'user-1' },
94+
logger: getNoopRulesLogger()
95+
})
96+
).toMatchObject({
97+
value: false,
98+
reason: 'ERROR',
99+
errorCode: 'PARSE_ERROR',
100+
errorMessage: expect.stringContaining('FUTURE_OPERATOR')
101+
});
87102
});
88103

89-
it('omits a flag that contains an invalid regular expression', () => {
104+
it('reports PARSE_ERROR for a flag with an invalid regular expression', () => {
90105
const source = buildRulesConfiguration();
91106
const conditions =
92107
source.flags['dynamic-flag'].allocations[0].rules?.[0].conditions;
@@ -105,10 +120,22 @@ describe('rules configuration', () => {
105120
if (prepared.status !== 'ready') {
106121
throw new Error(prepared.errorMessage);
107122
}
108-
expect(prepared.configuration.flags).toEqual({});
123+
expect(
124+
flaggingCoreRulesEngine.evaluate({
125+
configuration: prepared.configuration,
126+
type: 'boolean',
127+
flagKey: 'dynamic-flag',
128+
defaultValue: false,
129+
context: { targetingKey: 'user-1' },
130+
logger: getNoopRulesLogger()
131+
})
132+
).toMatchObject({
133+
errorCode: 'PARSE_ERROR',
134+
errorMessage: 'A regular expression condition is not valid.'
135+
});
109136
});
110137

111-
it('omits a flag whose split points to an absent variation', () => {
138+
it('reports PARSE_ERROR when a split points to an absent variation', () => {
112139
const source = buildRulesConfiguration();
113140
source.flags['dynamic-flag'].allocations[0].splits[0].variationKey =
114141
'absent';
@@ -119,10 +146,22 @@ describe('rules configuration', () => {
119146
if (prepared.status !== 'ready') {
120147
throw new Error(prepared.errorMessage);
121148
}
122-
expect(prepared.configuration.flags).toEqual({});
149+
expect(
150+
flaggingCoreRulesEngine.evaluate({
151+
configuration: prepared.configuration,
152+
type: 'boolean',
153+
flagKey: 'dynamic-flag',
154+
defaultValue: false,
155+
context: { targetingKey: 'user-1' },
156+
logger: getNoopRulesLogger()
157+
})
158+
).toMatchObject({
159+
errorCode: 'PARSE_ERROR',
160+
errorMessage: 'A split has an invalid variation key.'
161+
});
123162
});
124163

125-
it('keeps valid flags when it omits an invalid flag', () => {
164+
it('keeps valid flags usable when another flag has a parse error', () => {
126165
const source = buildRulesConfiguration();
127166
const validFlag = buildRulesConfiguration().flags['dynamic-flag'];
128167
validFlag.key = 'valid-flag';
@@ -143,8 +182,46 @@ describe('rules configuration', () => {
143182
throw new Error(prepared.errorMessage);
144183
}
145184
expect(Object.keys(prepared.configuration.flags)).toEqual([
185+
'dynamic-flag',
146186
'valid-flag'
147187
]);
188+
expect(
189+
flaggingCoreRulesEngine.evaluate({
190+
configuration: prepared.configuration,
191+
type: 'boolean',
192+
flagKey: 'valid-flag',
193+
defaultValue: false,
194+
context: { targetingKey: 'user-1', country: 'US' },
195+
logger: getNoopRulesLogger()
196+
})
197+
).toMatchObject({ value: true, errorCode: undefined });
198+
});
199+
200+
// TODO(FFL-2837): Replace this legacy JSON compatibility test with a
201+
// generated protobuf fixture after a flagging-core release contains
202+
// DataDog/openfeature-js-client#344 at or after `be0d886`.
203+
it('keeps supported known data when an unknown field is present', () => {
204+
const source = buildRulesConfiguration();
205+
(source.flags['dynamic-flag'] as typeof source.flags['dynamic-flag'] & {
206+
futureField: string;
207+
}).futureField = 'ignored';
208+
209+
const prepared = prepareRulesConfiguration(source);
210+
211+
expect(prepared.status).toBe('ready');
212+
if (prepared.status !== 'ready') {
213+
throw new Error(prepared.errorMessage);
214+
}
215+
expect(
216+
flaggingCoreRulesEngine.evaluate({
217+
configuration: prepared.configuration,
218+
type: 'boolean',
219+
flagKey: 'dynamic-flag',
220+
defaultValue: false,
221+
context: { targetingKey: 'user-1', country: 'US' },
222+
logger: getNoopRulesLogger()
223+
})
224+
).toMatchObject({ value: true, errorCode: undefined });
148225
});
149226

150227
it('normalizes a real flagging-core evaluation', () => {

packages/core/src/flags/configuration/rules.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import type { EvaluationContext, JsonValue, PrimitiveValue } from '../types';
1515
// TODO(FFL-2837): Replace this legacy UFC v1 alias with
1616
// `NonNullable<FlagsConfiguration['rules']>['response']` after a flagging-core
1717
// release contains DataDog/openfeature-js-client#344. Keep the
18-
// `FlagsConfiguration` type import on the flagging-core package root.
18+
// `FlagsConfiguration` type import on the flagging-core package root. PR #344
19+
// now preserves invalid flags and reports their stored errors during evaluation.
1920
type RulesConfigurationResponse = UniversalFlagConfigurationV1;
2021

2122
export type RulesValueType = 'boolean' | 'string' | 'number' | 'object';
@@ -125,6 +126,15 @@ export const toRulesEvaluationContext = (
125126
const hasOwn = (value: object, key: PropertyKey): boolean =>
126127
Object.prototype.hasOwnProperty.call(value, key);
127128

129+
// TODO(FFL-2837): Delete this compatibility error store after a flagging-core
130+
// release contains DataDog/openfeature-js-client#344 at or after `ba1dbaf`.
131+
// The generated protobuf parser uses the same per-configuration error model,
132+
// and its evaluator returns `PARSE_ERROR` with the stored validation message.
133+
const errorsByConfiguration = new WeakMap<
134+
RulesConfigurationResponse,
135+
ReadonlyMap<string, string>
136+
>();
137+
128138
const isRecord = (value: unknown): value is Record<string, unknown> =>
129139
typeof value === 'object' && value !== null && !Array.isArray(value);
130140

@@ -431,24 +441,31 @@ export const prepareRulesConfiguration = (
431441

432442
// TODO(FFL-2837): Delete this legacy JSON clone and validator after a
433443
// flagging-core release contains upstream PR #344. That implementation
434-
// decodes a generated Protobuf-ES response and omits unsupported or invalid
435-
// flags. Do not adapt this validator to the generated response type.
444+
// decodes a generated Protobuf-ES response, preserves invalid flags, and
445+
// records per-flag errors for evaluation. Do not adapt this validator to
446+
// the generated response type.
436447
const errorMessage = validateRulesConfigurationEnvelope(clone);
437448
if (errorMessage) {
438449
return { status: 'error', errorMessage };
439450
}
440451

441-
const flags = (clone as RulesConfigurationResponse).flags;
452+
const configuration = clone as RulesConfigurationResponse;
453+
const flags = configuration.flags;
454+
const errors = new Map<string, string>();
442455
for (const [flagKey, flag] of Object.entries(flags)) {
443-
if (validateFlag(flag)) {
444-
delete flags[flagKey];
456+
const flagError = validateFlag(flag);
457+
if (flagError) {
458+
errors.set(flagKey, flagError);
445459
}
446460
}
447461

448462
freezeValue(clone);
463+
if (errors.size > 0) {
464+
errorsByConfiguration.set(configuration, errors);
465+
}
449466
return {
450467
status: 'ready',
451-
configuration: clone as RulesConfigurationResponse
468+
configuration
452469
};
453470
};
454471

@@ -514,6 +531,21 @@ export const flaggingCoreRulesEngine: RulesEngine = {
514531
};
515532
}
516533

534+
// TODO(FFL-2837): Delete this compatibility check with the local error
535+
// store after the published PR #344 evaluator reports parser errors.
536+
const configurationError = errorsByConfiguration
537+
.get(request.configuration)
538+
?.get(request.flagKey);
539+
if (configurationError) {
540+
return {
541+
value: request.defaultValue,
542+
reason: 'ERROR',
543+
errorCode: 'PARSE_ERROR',
544+
errorMessage: configurationError,
545+
metadata: {}
546+
};
547+
}
548+
517549
const result = evaluateRules(
518550
request.configuration,
519551
request.type,

packages/core/src/flags/configuration/wire.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import type {
2828
// evaluator on the package root. Use `FlagsConfiguration.rules`. The distribution
2929
// layer must put one base64 encoding of the raw dd-source#34959 protobuf response
3030
// in the version 1 `rules.response` field. Do not add that service transport or
31-
// envelope construction here.
31+
// envelope construction here. PR #344 preserves invalid protobuf flags and
32+
// reports their validation errors when the flag is evaluated.
3233
type PendingRulesConfiguration = FlagsConfiguration & {
3334
rulesBased?: {
3435
response: UniversalFlagConfigurationV1;
@@ -77,7 +78,8 @@ export const configurationFromString = (source: string): FlagsConfiguration => {
7778
// pending types above. The upstream parser decodes `rules.response` as a
7879
// generated Protobuf-ES message. Do not adapt this shim to decode a raw
7980
// service response or to add a base64 layer. Do not copy the strict base64
80-
// validator that PR #344 removed in favor of the Protobuf-ES decoder.
81+
// validator that PR #344 removed in favor of the Protobuf-ES decoder. The
82+
// published parser must also include PR #344's unknown-field tolerance.
8183
const pendingRules = readPendingRulesWire(source);
8284
if (pendingRules) {
8385
try {

0 commit comments

Comments
 (0)