Skip to content

Commit e5f6291

Browse files
Merge pull request #442 from splitio/fallback-treatment
[WIP] Fallback treatment
2 parents f0b1c5d + bd5abe3 commit e5f6291

File tree

19 files changed

+435
-19
lines changed

19 files changed

+435
-19
lines changed

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
2.8.0 (October 28, 2025)
2+
- Added new configuration for Fallback Treatments, which allows setting a treatment value and optional config to be returned in place of "control", either globally or by flag. Read more in our docs.
23
- Added `client.getStatus()` method to retrieve the client readiness status properties (`isReady`, `isReadyFromCache`, etc).
34
- Added `client.whenReady()` and `client.whenReadyFromCache()` methods to replace the deprecated `client.ready()` method, which has an issue causing the returned promise to hang when using async/await syntax if it was rejected.
45
- Updated the SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event if it hasn’t already been emitted.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@splitsoftware/splitio-commons",
3-
"version": "2.7.1",
3+
"version": "2.8.0",
44
"description": "Split JavaScript SDK common components",
55
"main": "cjs/index.js",
66
"module": "esm/index.js",
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { FallbackTreatmentsCalculator } from '../';
2+
import type { FallbackTreatmentConfiguration } from '../../../../types/splitio';
3+
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
4+
import { CONTROL } from '../../../utils/constants';
5+
6+
describe('FallbackTreatmentsCalculator' , () => {
7+
const longName = 'a'.repeat(101);
8+
9+
test('logs an error if flag name is invalid - by Flag', () => {
10+
let config: FallbackTreatmentConfiguration = {
11+
byFlag: {
12+
'feature A': { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
13+
},
14+
};
15+
new FallbackTreatmentsCalculator(loggerMock, config);
16+
expect(loggerMock.error.mock.calls[0][0]).toBe(
17+
'Fallback treatments - Discarded flag \'feature A\': Invalid flag name (max 100 chars, no spaces)'
18+
);
19+
config = {
20+
byFlag: {
21+
[longName]: { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
22+
},
23+
};
24+
new FallbackTreatmentsCalculator(loggerMock, config);
25+
expect(loggerMock.error.mock.calls[1][0]).toBe(
26+
`Fallback treatments - Discarded flag '${longName}': Invalid flag name (max 100 chars, no spaces)`
27+
);
28+
29+
config = {
30+
byFlag: {
31+
'featureB': { treatment: longName, config: '{ value: 1 }' },
32+
},
33+
};
34+
new FallbackTreatmentsCalculator(loggerMock, config);
35+
expect(loggerMock.error.mock.calls[2][0]).toBe(
36+
'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)'
37+
);
38+
39+
config = {
40+
byFlag: {
41+
// @ts-ignore
42+
'featureC': { config: '{ global: true }' },
43+
},
44+
};
45+
new FallbackTreatmentsCalculator(loggerMock, config);
46+
expect(loggerMock.error.mock.calls[3][0]).toBe(
47+
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
48+
);
49+
50+
config = {
51+
byFlag: {
52+
// @ts-ignore
53+
'featureC': { treatment: 'invalid treatment!', config: '{ global: true }' },
54+
},
55+
};
56+
new FallbackTreatmentsCalculator(loggerMock, config);
57+
expect(loggerMock.error.mock.calls[4][0]).toBe(
58+
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
59+
);
60+
});
61+
62+
test('logs an error if flag name is invalid - global', () => {
63+
let config: FallbackTreatmentConfiguration = {
64+
global: { treatment: longName, config: '{ value: 1 }' },
65+
};
66+
new FallbackTreatmentsCalculator(loggerMock, config);
67+
expect(loggerMock.error.mock.calls[2][0]).toBe(
68+
'Fallback treatments - Discarded treatment for flag \'featureB\': Invalid treatment (max 100 chars and must match pattern)'
69+
);
70+
71+
config = {
72+
// @ts-ignore
73+
global: { config: '{ global: true }' },
74+
};
75+
new FallbackTreatmentsCalculator(loggerMock, config);
76+
expect(loggerMock.error.mock.calls[3][0]).toBe(
77+
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
78+
);
79+
80+
config = {
81+
// @ts-ignore
82+
global: { treatment: 'invalid treatment!', config: '{ global: true }' },
83+
};
84+
new FallbackTreatmentsCalculator(loggerMock, config);
85+
expect(loggerMock.error.mock.calls[4][0]).toBe(
86+
'Fallback treatments - Discarded treatment for flag \'featureC\': Invalid treatment (max 100 chars and must match pattern)'
87+
);
88+
});
89+
90+
test('returns specific fallback if flag exists', () => {
91+
const config: FallbackTreatmentConfiguration = {
92+
byFlag: {
93+
'featureA': { treatment: 'TREATMENT_A', config: '{ value: 1 }' },
94+
},
95+
};
96+
const calculator = new FallbackTreatmentsCalculator(loggerMock, config);
97+
const result = calculator.resolve('featureA', 'label by flag');
98+
99+
expect(result).toEqual({
100+
treatment: 'TREATMENT_A',
101+
config: '{ value: 1 }',
102+
label: 'fallback - label by flag',
103+
});
104+
});
105+
106+
test('returns global fallback if flag is missing and global exists', () => {
107+
const config: FallbackTreatmentConfiguration = {
108+
byFlag: {},
109+
global: { treatment: 'GLOBAL_TREATMENT', config: '{ global: true }' },
110+
};
111+
const calculator = new FallbackTreatmentsCalculator(loggerMock, config);
112+
const result = calculator.resolve('missingFlag', 'label by global');
113+
114+
expect(result).toEqual({
115+
treatment: 'GLOBAL_TREATMENT',
116+
config: '{ global: true }',
117+
label: 'fallback - label by global',
118+
});
119+
});
120+
121+
test('returns control fallback if flag and global are missing', () => {
122+
const config: FallbackTreatmentConfiguration = {
123+
byFlag: {},
124+
};
125+
const calculator = new FallbackTreatmentsCalculator(loggerMock, config);
126+
const result = calculator.resolve('missingFlag', 'label by noFallback');
127+
128+
expect(result).toEqual({
129+
treatment: CONTROL,
130+
config: null,
131+
label: 'label by noFallback',
132+
});
133+
});
134+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { FallbacksSanitizer } from '../fallbackSanitizer';
2+
import { TreatmentWithConfig } from '../../../../types/splitio';
3+
import { loggerMock } from '../../../logger/__tests__/sdkLogger.mock';
4+
5+
describe('FallbacksSanitizer', () => {
6+
const validTreatment: TreatmentWithConfig = { treatment: 'on', config: '{"color":"blue"}' };
7+
const invalidTreatment: TreatmentWithConfig = { treatment: ' ', config: null };
8+
9+
beforeEach(() => {
10+
jest.spyOn(console, 'error').mockImplementation(() => {});
11+
});
12+
13+
afterEach(() => {
14+
(loggerMock.error as jest.Mock).mockRestore();
15+
});
16+
17+
describe('isValidFlagName', () => {
18+
test('returns true for a valid flag name', () => {
19+
// @ts-expect-private-access
20+
expect((FallbacksSanitizer as any).isValidFlagName('my_flag')).toBe(true);
21+
});
22+
23+
test('returns false for a name longer than 100 chars', () => {
24+
const longName = 'a'.repeat(101);
25+
expect((FallbacksSanitizer as any).isValidFlagName(longName)).toBe(false);
26+
});
27+
28+
test('returns false if the name contains spaces', () => {
29+
expect((FallbacksSanitizer as any).isValidFlagName('invalid flag')).toBe(false);
30+
});
31+
});
32+
33+
describe('isValidTreatment', () => {
34+
test('returns true for a valid treatment string', () => {
35+
expect((FallbacksSanitizer as any).isValidTreatment(validTreatment)).toBe(true);
36+
});
37+
38+
test('returns false for null or undefined', () => {
39+
expect((FallbacksSanitizer as any).isValidTreatment(null)).toBe(false);
40+
expect((FallbacksSanitizer as any).isValidTreatment(undefined)).toBe(false);
41+
});
42+
43+
test('returns false for a treatment longer than 100 chars', () => {
44+
const long = { treatment: 'a'.repeat(101) };
45+
expect((FallbacksSanitizer as any).isValidTreatment(long)).toBe(false);
46+
});
47+
48+
test('returns false if treatment does not match regex pattern', () => {
49+
const invalid = { treatment: 'invalid treatment!' };
50+
expect((FallbacksSanitizer as any).isValidTreatment(invalid)).toBe(false);
51+
});
52+
});
53+
54+
describe('sanitizeGlobal', () => {
55+
test('returns the treatment if valid', () => {
56+
expect(FallbacksSanitizer.sanitizeGlobal(loggerMock, validTreatment)).toEqual(validTreatment);
57+
expect(loggerMock.error).not.toHaveBeenCalled();
58+
});
59+
60+
test('returns undefined and logs error if invalid', () => {
61+
const result = FallbacksSanitizer.sanitizeGlobal(loggerMock, invalidTreatment);
62+
expect(result).toBeUndefined();
63+
expect(loggerMock.error).toHaveBeenCalledWith(
64+
expect.stringContaining('Fallback treatments - Discarded fallback')
65+
);
66+
});
67+
});
68+
69+
describe('sanitizeByFlag', () => {
70+
test('returns a sanitized map with valid entries only', () => {
71+
const input = {
72+
valid_flag: validTreatment,
73+
'invalid flag': validTreatment,
74+
bad_treatment: invalidTreatment,
75+
};
76+
77+
const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input);
78+
79+
expect(result).toEqual({ valid_flag: validTreatment });
80+
expect(loggerMock.error).toHaveBeenCalledTimes(2); // invalid flag + bad_treatment
81+
});
82+
83+
test('returns empty object if all invalid', () => {
84+
const input = {
85+
'invalid flag': invalidTreatment,
86+
};
87+
88+
const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input);
89+
expect(result).toEqual({});
90+
expect(loggerMock.error).toHaveBeenCalled();
91+
});
92+
93+
test('returns same object if all valid', () => {
94+
const input = {
95+
flag_one: validTreatment,
96+
flag_two: { treatment: 'valid_2', config: null },
97+
};
98+
99+
const result = FallbacksSanitizer.sanitizeByFlag(loggerMock, input);
100+
expect(result).toEqual(input);
101+
expect(loggerMock.error).not.toHaveBeenCalled();
102+
});
103+
});
104+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export enum FallbackDiscardReason {
2+
FlagName = 'Invalid flag name (max 100 chars, no spaces)',
3+
Treatment = 'Invalid treatment (max 100 chars and must match pattern)',
4+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Treatment, TreatmentWithConfig } from '../../../../types/splitio';
2+
import { ILogger } from '../../../logger/types';
3+
import { isObject, isString } from '../../../utils/lang';
4+
import { FallbackDiscardReason } from '../constants';
5+
6+
7+
export class FallbacksSanitizer {
8+
9+
private static readonly pattern = /^[0-9]+[.a-zA-Z0-9_-]*$|^[a-zA-Z]+[a-zA-Z0-9_-]*$/;
10+
11+
private static isValidFlagName(name: string): boolean {
12+
return name.length <= 100 && !name.includes(' ');
13+
}
14+
15+
private static isValidTreatment(t?: Treatment | TreatmentWithConfig): boolean {
16+
const treatment = isObject(t) ? (t as TreatmentWithConfig).treatment : t;
17+
18+
if (!isString(treatment) || treatment.length > 100) {
19+
return false;
20+
}
21+
return FallbacksSanitizer.pattern.test(treatment);
22+
}
23+
24+
static sanitizeGlobal(logger: ILogger, treatment?: Treatment | TreatmentWithConfig): Treatment | TreatmentWithConfig | undefined {
25+
if (!this.isValidTreatment(treatment)) {
26+
logger.error(
27+
`Fallback treatments - Discarded fallback: ${FallbackDiscardReason.Treatment}`
28+
);
29+
return undefined;
30+
}
31+
return treatment;
32+
}
33+
34+
static sanitizeByFlag(
35+
logger: ILogger,
36+
byFlagFallbacks: Record<string, Treatment | TreatmentWithConfig>
37+
): Record<string, Treatment | TreatmentWithConfig> {
38+
const sanitizedByFlag: Record<string, Treatment | TreatmentWithConfig> = {};
39+
40+
const entries = Object.keys(byFlagFallbacks);
41+
entries.forEach((flag) => {
42+
const t = byFlagFallbacks[flag];
43+
if (!this.isValidFlagName(flag)) {
44+
logger.error(
45+
`Fallback treatments - Discarded flag '${flag}': ${FallbackDiscardReason.FlagName}`
46+
);
47+
return;
48+
}
49+
50+
if (!this.isValidTreatment(t)) {
51+
logger.error(
52+
`Fallback treatments - Discarded treatment for flag '${flag}': ${FallbackDiscardReason.Treatment}`
53+
);
54+
return;
55+
}
56+
57+
sanitizedByFlag[flag] = t;
58+
});
59+
60+
return sanitizedByFlag;
61+
}
62+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { FallbackTreatmentConfiguration, Treatment, TreatmentWithConfig } from '../../../types/splitio';
2+
import { FallbacksSanitizer } from './fallbackSanitizer';
3+
import { CONTROL } from '../../utils/constants';
4+
import { isString } from '../../utils/lang';
5+
import { ILogger } from '../../logger/types';
6+
7+
export type IFallbackTreatmentsCalculator = {
8+
resolve(flagName: string, label: string): TreatmentWithConfig & { label: string };
9+
}
10+
11+
export const FALLBACK_PREFIX = 'fallback - ';
12+
13+
export class FallbackTreatmentsCalculator implements IFallbackTreatmentsCalculator {
14+
private readonly fallbacks: FallbackTreatmentConfiguration;
15+
16+
constructor(logger: ILogger, fallbacks?: FallbackTreatmentConfiguration) {
17+
const sanitizedGlobal = fallbacks?.global ? FallbacksSanitizer.sanitizeGlobal(logger, fallbacks.global) : undefined;
18+
const sanitizedByFlag = fallbacks?.byFlag ? FallbacksSanitizer.sanitizeByFlag(logger, fallbacks.byFlag) : {};
19+
this.fallbacks = {
20+
global: sanitizedGlobal,
21+
byFlag: sanitizedByFlag
22+
};
23+
}
24+
25+
resolve(flagName: string, label: string): TreatmentWithConfig & { label: string } {
26+
const treatment = this.fallbacks.byFlag?.[flagName];
27+
if (treatment) {
28+
return this.copyWithLabel(treatment, label);
29+
}
30+
31+
if (this.fallbacks.global) {
32+
return this.copyWithLabel(this.fallbacks.global, label);
33+
}
34+
35+
return {
36+
treatment: CONTROL,
37+
config: null,
38+
label,
39+
};
40+
}
41+
42+
private copyWithLabel(fallback: Treatment | TreatmentWithConfig, label: string): TreatmentWithConfig & { label: string } {
43+
if (isString(fallback)) {
44+
return {
45+
treatment: fallback,
46+
config: null,
47+
label: `${FALLBACK_PREFIX}${label}`,
48+
};
49+
}
50+
51+
return {
52+
treatment: fallback.treatment,
53+
config: fallback.config,
54+
label: `${FALLBACK_PREFIX}${label}`,
55+
};
56+
}
57+
}

0 commit comments

Comments
 (0)