Skip to content

Commit 96ca311

Browse files
aarsilvclaude
andcommitted
Add validation for offlineInit() configuration
- Add validateFlagsConfiguration() to ensure all required fields exist - Add validateBanditsConfiguration() to validate bandit config structure - Update offlineInit() to validate configs before loading: - If validation fails and throwOnFailedInitialization=true: throw error - If validation fails and throwOnFailedInitialization=false: log warning and use empty config (all assignments return defaults) - Update test helper to include banditReferences field This ensures invalid configurations are caught early and provides clear error messages about what fields are missing or invalid. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f6cb8fd commit 96ca311

2 files changed

Lines changed: 122 additions & 51 deletions

File tree

src/index.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -979,13 +979,21 @@ describe('offlineInit', () => {
979979
// Helper to create a full configuration JSON string
980980
const createFlagsConfigJson = (
981981
flags: Record<string, Flag>,
982-
options: { createdAt?: string; format?: string } = {},
982+
options: {
983+
createdAt?: string;
984+
format?: string;
985+
banditReferences?: Record<
986+
string,
987+
{ modelVersion: string; flagVariations: BanditVariation[] }
988+
>;
989+
} = {},
983990
): string => {
984991
return JSON.stringify({
985992
createdAt: options.createdAt ?? '2024-04-17T19:40:53.716Z',
986993
format: options.format ?? 'SERVER',
987994
environment: { name: 'Test' },
988995
flags,
996+
banditReferences: options.banditReferences ?? {},
989997
});
990998
};
991999

src/index.ts

Lines changed: 113 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,62 @@ interface BanditsConfigurationResponse {
9494
*/
9595
const DEFAULT_ASSIGNMENT_CACHE_SIZE = 50_000;
9696

97+
/**
98+
* Validates that the parsed flags configuration has all required fields.
99+
* Returns an array of validation error messages, or empty array if valid.
100+
*/
101+
function validateFlagsConfiguration(config: unknown): string[] {
102+
const errors: string[] = [];
103+
104+
if (!config || typeof config !== 'object') {
105+
errors.push('Configuration must be an object');
106+
return errors;
107+
}
108+
109+
const cfg = config as Record<string, unknown>;
110+
111+
if (typeof cfg.createdAt !== 'string') {
112+
errors.push('Missing or invalid "createdAt" field');
113+
}
114+
if (typeof cfg.format !== 'string') {
115+
errors.push('Missing or invalid "format" field');
116+
}
117+
if (!cfg.environment || typeof cfg.environment !== 'object') {
118+
errors.push('Missing or invalid "environment" field');
119+
} else if (typeof (cfg.environment as Record<string, unknown>).name !== 'string') {
120+
errors.push('Missing or invalid "environment.name" field');
121+
}
122+
if (!cfg.flags || typeof cfg.flags !== 'object') {
123+
errors.push('Missing or invalid "flags" field');
124+
}
125+
if (!cfg.banditReferences || typeof cfg.banditReferences !== 'object') {
126+
errors.push('Missing or invalid "banditReferences" field');
127+
}
128+
129+
return errors;
130+
}
131+
132+
/**
133+
* Validates that the parsed bandits configuration has all required fields.
134+
* Returns an array of validation error messages, or empty array if valid.
135+
*/
136+
function validateBanditsConfiguration(config: unknown): string[] {
137+
const errors: string[] = [];
138+
139+
if (!config || typeof config !== 'object') {
140+
errors.push('Configuration must be an object');
141+
return errors;
142+
}
143+
144+
const cfg = config as Record<string, unknown>;
145+
146+
if (!cfg.bandits || typeof cfg.bandits !== 'object') {
147+
errors.push('Missing or invalid "bandits" field');
148+
}
149+
150+
return errors;
151+
}
152+
97153
/**
98154
* @deprecated Eppo has discontinued eventing support. Event tracking will be handled by Datadog SDKs.
99155
*/
@@ -308,53 +364,49 @@ export function offlineInit(config: IOfflineClientConfig): EppoClient {
308364
throwOnFailedInitialization = true,
309365
} = config;
310366

367+
// Create memory-only configuration stores
368+
flagConfigurationStore = new MemoryOnlyConfigurationStore<Flag>();
369+
banditVariationConfigurationStore = new MemoryOnlyConfigurationStore<BanditVariation[]>();
370+
banditModelConfigurationStore = new MemoryOnlyConfigurationStore<BanditParameters>();
371+
311372
try {
312-
// Parse the flags configuration JSON
313-
const flagsConfigResponse = JSON.parse(flagsConfiguration) as {
314-
createdAt?: string;
315-
format?: string;
316-
environment?: { name: string };
317-
flags: Record<string, Flag>;
318-
banditReferences?: Record<
319-
string,
320-
{
321-
modelVersion: string;
322-
flagVariations: BanditVariation[];
323-
}
324-
>;
325-
};
326-
327-
// Create memory-only configuration stores
328-
flagConfigurationStore = new MemoryOnlyConfigurationStore<Flag>();
329-
banditVariationConfigurationStore = new MemoryOnlyConfigurationStore<BanditVariation[]>();
330-
banditModelConfigurationStore = new MemoryOnlyConfigurationStore<BanditParameters>();
331-
332-
// Set format from the configuration (default to SERVER)
333-
const format = (flagsConfigResponse.format as FormatEnum) ?? FormatEnum.SERVER;
334-
flagConfigurationStore.setFormat(format);
335-
336-
// Load flag configurations into store
337-
// Note: setEntries is async but MemoryOnlyConfigurationStore performs synchronous operations internally,
338-
// so there's no race condition. We add .catch() for defensive error handling, matching JS client SDK pattern.
339-
flagConfigurationStore
340-
.setEntries(flagsConfigResponse.flags ?? {})
341-
.catch((err) =>
342-
applicationLogger.warn(`Error setting flags for memory-only configuration store: ${err}`),
373+
// Parse and validate the flags configuration JSON
374+
const parsedFlagsConfig = JSON.parse(flagsConfiguration);
375+
const flagsValidationErrors = validateFlagsConfiguration(parsedFlagsConfig);
376+
377+
if (flagsValidationErrors.length > 0) {
378+
const errorMessage = `Invalid flags configuration: ${flagsValidationErrors.join(', ')}`;
379+
if (throwOnFailedInitialization) {
380+
throw new Error(errorMessage);
381+
}
382+
applicationLogger.warn(
383+
`${errorMessage}. Using empty configuration - all assignments will return default values.`,
343384
);
385+
// Skip loading flags config, stores remain empty
386+
} else {
387+
// Cast to typed response after validation
388+
const flagsConfigResponse = parsedFlagsConfig as FlagsConfigurationResponse;
389+
390+
// Set format from the configuration
391+
flagConfigurationStore.setFormat(flagsConfigResponse.format);
392+
393+
// Load flag configurations into store
394+
// Note: setEntries is async but MemoryOnlyConfigurationStore performs synchronous operations internally,
395+
// so there's no race condition. We add .catch() for defensive error handling, matching JS client SDK pattern.
396+
flagConfigurationStore
397+
.setEntries(flagsConfigResponse.flags)
398+
.catch((err) =>
399+
applicationLogger.warn(`Error setting flags for memory-only configuration store: ${err}`),
400+
);
344401

345-
// Set configuration timestamp if available
346-
if (flagsConfigResponse.createdAt) {
402+
// Set configuration timestamp
347403
flagConfigurationStore.setConfigPublishedAt(flagsConfigResponse.createdAt);
348-
}
349404

350-
// Set environment if available
351-
if (flagsConfigResponse.environment) {
405+
// Set environment
352406
flagConfigurationStore.setEnvironment(flagsConfigResponse.environment);
353-
}
354407

355-
// Process bandit references from the flags configuration
356-
// Index by flag key for quick lookup (instead of by bandit key)
357-
if (flagsConfigResponse.banditReferences) {
408+
// Process bandit references from the flags configuration
409+
// Index by flag key for quick lookup (instead of by bandit key)
358410
const banditVariationsByFlagKey: Record<string, BanditVariation[]> = {};
359411
for (const banditReference of Object.values(flagsConfigResponse.banditReferences)) {
360412
for (const flagVariation of banditReference.flagVariations) {
@@ -376,17 +428,28 @@ export function offlineInit(config: IOfflineClientConfig): EppoClient {
376428

377429
// Parse and load bandit models if provided
378430
if (banditsConfiguration) {
379-
const banditsConfigResponse = JSON.parse(banditsConfiguration) as {
380-
updatedAt?: string;
381-
bandits: Record<string, BanditParameters>;
382-
};
383-
banditModelConfigurationStore
384-
.setEntries(banditsConfigResponse.bandits ?? {})
385-
.catch((err) =>
386-
applicationLogger.warn(
387-
`Error setting bandit models for memory-only configuration store: ${err}`,
388-
),
431+
const parsedBanditsConfig = JSON.parse(banditsConfiguration);
432+
const banditsValidationErrors = validateBanditsConfiguration(parsedBanditsConfig);
433+
434+
if (banditsValidationErrors.length > 0) {
435+
const errorMessage = `Invalid bandits configuration: ${banditsValidationErrors.join(', ')}`;
436+
if (throwOnFailedInitialization) {
437+
throw new Error(errorMessage);
438+
}
439+
applicationLogger.warn(
440+
`${errorMessage}. Skipping bandit configuration - bandit assignments will not work.`,
389441
);
442+
// Skip loading bandits config, store remains empty
443+
} else {
444+
const banditsConfigResponse = parsedBanditsConfig as BanditsConfigurationResponse;
445+
banditModelConfigurationStore
446+
.setEntries(banditsConfigResponse.bandits)
447+
.catch((err) =>
448+
applicationLogger.warn(
449+
`Error setting bandit models for memory-only configuration store: ${err}`,
450+
),
451+
);
452+
}
390453
}
391454

392455
// Create client without request parameters (offline mode - no polling)

0 commit comments

Comments
 (0)