Skip to content

Commit 2694cae

Browse files
Todd AndersonTodd Anderson
authored andcommitted
chore: now utilizes FDv2 basis param and supports FDv1 fallback
1 parent 80e6c3e commit 2694cae

25 files changed

+642
-193
lines changed

packages/sdk/server-node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"dependencies": {
4848
"@launchdarkly/js-server-sdk-common": "2.15.1",
4949
"https-proxy-agent": "^5.0.1",
50-
"launchdarkly-eventsource": "2.1.0"
50+
"launchdarkly-eventsource": "2.2.0"
5151
},
5252
"devDependencies": {
5353
"@trivago/prettier-plugin-sort-imports": "^4.1.1",

packages/shared/common/__tests__/internal/fdv2/PayloadStreamReader.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ it('it sets basis to true when intent code is xfer-full', () => {
2525
});
2626

2727
mockStream.simulateEvent('server-intent', {
28-
data: '{"payloads": [{"code": "xfer-full", "id": "mockId"}]}',
28+
data: '{"payloads": [{"intentCode": "xfer-full", "id": "mockId"}]}',
2929
});
3030
mockStream.simulateEvent('payload-transferred', {
3131
data: '{"state": "mockState", "version": 1}',
@@ -47,7 +47,7 @@ it('it sets basis to false when intent code is xfer-changes', () => {
4747
});
4848

4949
mockStream.simulateEvent('server-intent', {
50-
data: '{"payloads": [{"code": "xfer-changes", "id": "mockId"}]}',
50+
data: '{"payloads": [{"intentCode": "xfer-changes", "id": "mockId"}]}',
5151
});
5252
mockStream.simulateEvent('payload-transferred', {
5353
data: '{"state": "mockState", "version": 1}',
@@ -69,7 +69,7 @@ it('it sets basis to false and emits empty payload when intent code is none', ()
6969
});
7070

7171
mockStream.simulateEvent('server-intent', {
72-
data: '{"payloads": [{"code": "none", "id": "mockId", "target": 42}]}',
72+
data: '{"payloads": [{"intentCode": "none", "id": "mockId", "target": 42}]}',
7373
});
7474
expect(receivedPayloads.length).toEqual(1);
7575
expect(receivedPayloads[0].id).toEqual('mockId');
@@ -88,7 +88,7 @@ it('it handles xfer-full then xfer-changes', () => {
8888
});
8989

9090
mockStream.simulateEvent('server-intent', {
91-
data: '{"payloads": [{"code": "xfer-full", "id": "mockId"}]}',
91+
data: '{"payloads": [{"intentCode": "xfer-full", "id": "mockId"}]}',
9292
});
9393
mockStream.simulateEvent('put-object', {
9494
data: '{"kind": "mockKind", "key": "flagA", "version": 123, "object": {"objectFieldA": "objectValueA"}}',
@@ -130,7 +130,7 @@ it('it includes multiple types of updates in payload', () => {
130130
});
131131

132132
mockStream.simulateEvent('server-intent', {
133-
data: '{"payloads": [{"code": "xfer-full", "id": "mockId"}]}',
133+
data: '{"payloads": [{"intentCode": "xfer-full", "id": "mockId"}]}',
134134
});
135135
mockStream.simulateEvent('put-object', {
136136
data: '{"kind": "mockKind", "key": "flagA", "version": 123, "object": {"objectFieldA": "objectValueA"}}',
@@ -171,7 +171,7 @@ it('it does not include messages thats are not between server-intent and payload
171171
data: '{"kind": "mockKind", "key": "flagShouldIgnore", "version": 123, "object": {"objectFieldShouldIgnore": "objectValueShouldIgnore"}}',
172172
});
173173
mockStream.simulateEvent('server-intent', {
174-
data: '{"payloads": [{"code": "xfer-full", "id": "mockId"}]}',
174+
data: '{"payloads": [{"intentCode": "xfer-full", "id": "mockId"}]}',
175175
});
176176
mockStream.simulateEvent('put-object', {
177177
data: '{"kind": "mockKind", "key": "flagA", "version": 123, "object": {"objectFieldA": "objectValueA"}}',
@@ -237,7 +237,7 @@ it('logs prescribed message when error event is encountered', () => {
237237
});
238238

239239
mockStream.simulateEvent('server-intent', {
240-
data: '{"payloads": [{"code": "xfer-full", "id": "mockId"}]}',
240+
data: '{"payloads": [{"intentCode": "xfer-full", "id": "mockId"}]}',
241241
});
242242
mockStream.simulateEvent('put-object', {
243243
data: '{"kind": "mockKind", "key": "flagA", "version": 123, "object": {"objectFieldA": "objectValueA"}}',
@@ -279,7 +279,7 @@ it('discards partially transferred data when an error is encountered', () => {
279279
});
280280

281281
mockStream.simulateEvent('server-intent', {
282-
data: '{"payloads": [{"code": "xfer-full", "id": "mockId"}]}',
282+
data: '{"payloads": [{"intentCode": "xfer-full", "id": "mockId"}]}',
283283
});
284284
mockStream.simulateEvent('put-object', {
285285
data: '{"kind": "mockKind", "key": "flagA", "version": 123, "object": {"objectFieldA": "objectValueA"}}',
@@ -294,7 +294,7 @@ it('discards partially transferred data when an error is encountered', () => {
294294
data: '{"state": "mockState", "version": 1}',
295295
});
296296
mockStream.simulateEvent('server-intent', {
297-
data: '{"payloads": [{"code": "xfer-full", "id": "mockId2"}]}',
297+
data: '{"payloads": [{"intentCode": "xfer-full", "id": "mockId2"}]}',
298298
});
299299
mockStream.simulateEvent('put-object', {
300300
data: '{"kind": "mockKind", "key": "flagX", "version": 123, "object": {"objectFieldX": "objectValueX"}}',
@@ -338,7 +338,7 @@ it('silently ignores unrecognized kinds', () => {
338338
});
339339

340340
mockStream.simulateEvent('server-intent', {
341-
data: '{"payloads": [{"code": "xfer-full", "id": "mockId"}]}',
341+
data: '{"payloads": [{"intentCode": "xfer-full", "id": "mockId"}]}',
342342
});
343343
mockStream.simulateEvent('put-object', {
344344
data: '{"kind": "mockKind", "key": "flagA", "version": 123, "object": {"objectFieldA": "objectValueA"}}',
@@ -368,7 +368,7 @@ it('ignores additional payloads beyond the first payload in the server-intent me
368368
});
369369

370370
mockStream.simulateEvent('server-intent', {
371-
data: '{"payloads": [{"code": "xfer-full", "id": "mockId"},{"code": "IShouldBeIgnored", "id": "IShouldBeIgnored"}]}',
371+
data: '{"payloads": [{"intentCode": "xfer-full", "id": "mockId"},{"intentCode": "IShouldBeIgnored", "id": "IShouldBeIgnored"}]}',
372372
});
373373
mockStream.simulateEvent('put-object', {
374374
data: '{"kind": "mockKind", "key": "flagA", "version": 123, "object": {"objectFieldA": "objectValueA"}}',

packages/shared/common/__tests__/subsystem/DataSystem/CompositeDataSource.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
CompositeDataSource,
99
TransitionConditions,
1010
} from '../../../src/datasource/CompositeDataSource';
11+
import { DataSourceErrorKind } from '../../../src/datasource/DataSourceErrorKinds';
12+
import { LDFlagDeliveryFallbackError } from '../../../src/datasource/errors';
1113

1214
function makeDataSourceFactory(internal: DataSource): LDDataSourceFactory {
1315
return () => internal;
@@ -75,6 +77,7 @@ it('handles initializer getting basis, switching to synchronizer', async () => {
7577
const underTest = new CompositeDataSource(
7678
[makeDataSourceFactory(mockInitializer1)],
7779
[makeDataSourceFactory(mockSynchronizer1)],
80+
[],
7881
undefined,
7982
makeTestTransitionConditions(),
8083
makeZeroBackoff(),
@@ -169,6 +172,7 @@ it('handles initializer getting basis, switches to synchronizer 1, falls back to
169172
const underTest = new CompositeDataSource(
170173
[makeDataSourceFactory(mockInitializer1)],
171174
[makeDataSourceFactory(mockSynchronizer1), makeDataSourceFactory(mockSynchronizer2)],
175+
[],
172176
undefined,
173177
makeTestTransitionConditions(),
174178
makeZeroBackoff(),
@@ -270,6 +274,7 @@ it('removes synchronizer that reports unrecoverable error and loops on remaining
270274
const underTest = new CompositeDataSource(
271275
[makeDataSourceFactory(mockInitializer1)],
272276
[makeDataSourceFactory(mockSynchronizer1), makeDataSourceFactory(mockSynchronizer2)],
277+
[],
273278
undefined,
274279
makeTestTransitionConditions(),
275280
makeZeroBackoff(),
@@ -306,6 +311,117 @@ it('removes synchronizer that reports unrecoverable error and loops on remaining
306311
expect(statusCallback).toHaveBeenNthCalledWith(10, DataSourceState.Valid, undefined); // sync1 valid
307312
});
308313

314+
it('falls back to FDv1 synchronizers when FDv1 fallback error is reported', async () => {
315+
const mockInitializer1: DataSource = {
316+
start: jest
317+
.fn()
318+
.mockImplementation(
319+
(
320+
_dataCallback: (basis: boolean, data: any) => void,
321+
_statusCallback: (status: DataSourceState, err?: any) => void,
322+
) => {
323+
_statusCallback(DataSourceState.Initializing);
324+
_statusCallback(DataSourceState.Valid);
325+
_dataCallback(true, { key: 'init1' });
326+
_statusCallback(DataSourceState.Closed);
327+
},
328+
),
329+
stop: jest.fn(),
330+
};
331+
332+
const mockSynchronizer1 = {
333+
start: jest
334+
.fn()
335+
.mockImplementation(
336+
(
337+
_dataCallback: (basis: boolean, data: any) => void,
338+
_statusCallback: (status: DataSourceState, err?: any) => void,
339+
) => {
340+
_statusCallback(DataSourceState.Initializing);
341+
_statusCallback(
342+
DataSourceState.Closed,
343+
new LDFlagDeliveryFallbackError(
344+
DataSourceErrorKind.ErrorResponse,
345+
`Response header indicates to fallback to FDv1`,
346+
403,
347+
),
348+
);
349+
},
350+
),
351+
stop: jest.fn(),
352+
};
353+
354+
const mockSynchronizer2 = {
355+
start: jest
356+
.fn()
357+
.mockImplementation(
358+
(
359+
_dataCallback: (basis: boolean, data: any) => void,
360+
_statusCallback: (status: DataSourceState, err?: any) => void,
361+
) => {
362+
_statusCallback(DataSourceState.Initializing);
363+
_statusCallback(DataSourceState.Closed, {
364+
name: 'Error',
365+
message: 'I should NOT be called due to FDv1 Fallback',
366+
});
367+
},
368+
),
369+
stop: jest.fn(),
370+
};
371+
372+
const mockFDv1Data = { key: 'FDv1Data' };
373+
const mockFDv1Synchronizer = {
374+
start: jest
375+
.fn()
376+
.mockImplementation(
377+
(
378+
_dataCallback: (basis: boolean, data: any) => void,
379+
_statusCallback: (status: DataSourceState, err?: any) => void,
380+
) => {
381+
_statusCallback(DataSourceState.Initializing);
382+
_statusCallback(DataSourceState.Valid, null); // this should lead to recovery
383+
_dataCallback(false, mockFDv1Data);
384+
},
385+
),
386+
stop: jest.fn(),
387+
};
388+
389+
const underTest = new CompositeDataSource(
390+
[makeDataSourceFactory(mockInitializer1)],
391+
[makeDataSourceFactory(mockSynchronizer1), makeDataSourceFactory(mockSynchronizer2)],
392+
[makeDataSourceFactory(mockFDv1Synchronizer)],
393+
undefined,
394+
makeTestTransitionConditions(),
395+
makeZeroBackoff(),
396+
);
397+
398+
let dataCallback;
399+
const statusCallback = jest.fn();
400+
await new Promise<void>((resolve) => {
401+
dataCallback = jest.fn((_: boolean, data: any) => {
402+
if (data === mockFDv1Data) {
403+
resolve();
404+
}
405+
});
406+
407+
underTest.start(dataCallback, statusCallback);
408+
});
409+
410+
expect(mockInitializer1.start).toHaveBeenCalledTimes(1);
411+
expect(mockSynchronizer1.start).toHaveBeenCalledTimes(1);
412+
expect(mockSynchronizer2.start).toHaveBeenCalledTimes(0); // this synchronizer should not be called because we fall back to FDv1 synchronizers instead
413+
expect(mockFDv1Synchronizer.start).toHaveBeenCalledTimes(1);
414+
expect(dataCallback).toHaveBeenCalledTimes(2);
415+
expect(dataCallback).toHaveBeenNthCalledWith(1, true, { key: 'init1' });
416+
expect(dataCallback).toHaveBeenNthCalledWith(2, false, { key: 'FDv1Data' });
417+
expect(statusCallback).toHaveBeenCalledTimes(5);
418+
expect(statusCallback).toHaveBeenNthCalledWith(1, DataSourceState.Initializing, undefined);
419+
expect(statusCallback).toHaveBeenNthCalledWith(2, DataSourceState.Valid, undefined); // initializer got data
420+
expect(statusCallback).toHaveBeenNthCalledWith(3, DataSourceState.Interrupted, undefined); // initializer closed
421+
expect(statusCallback).toHaveBeenNthCalledWith(4, DataSourceState.Interrupted, expect.anything()); // sync1 fdv1 fallback error
422+
expect(statusCallback).toHaveBeenNthCalledWith(5, DataSourceState.Valid, undefined); // sync1 valid
423+
});
424+
309425
it('reports error when all initializers fail', async () => {
310426
const mockInitializer1Error = {
311427
name: 'Error',
@@ -348,6 +464,7 @@ it('reports error when all initializers fail', async () => {
348464
const underTest = new CompositeDataSource(
349465
[makeDataSourceFactory(mockInitializer1), makeDataSourceFactory(mockInitializer2)],
350466
[], // no synchronizers for this test
467+
[],
351468
undefined,
352469
makeTestTransitionConditions(),
353470
makeZeroBackoff(),
@@ -445,6 +562,7 @@ it('it reports DataSourceState Closed when all synchronizers report Closed with
445562
const underTest = new CompositeDataSource(
446563
[makeDataSourceFactory(mockInitializer1)],
447564
[makeDataSourceFactory(mockSynchronizer1), makeDataSourceFactory(mockSynchronizer2)],
565+
[],
448566
undefined,
449567
makeTestTransitionConditions(),
450568
makeZeroBackoff(),
@@ -508,6 +626,7 @@ it('can be stopped when in thrashing synchronizer fallback loop', async () => {
508626
const underTest = new CompositeDataSource(
509627
[makeDataSourceFactory(mockInitializer1)],
510628
[makeDataSourceFactory(mockSynchronizer1)], // will continuously fallback onto itself
629+
[],
511630
undefined,
512631
makeTestTransitionConditions(),
513632
makeZeroBackoff(),
@@ -579,6 +698,7 @@ it('can be stopped and restarted', async () => {
579698
const underTest = new CompositeDataSource(
580699
[makeDataSourceFactory(mockInitializer1)],
581700
[makeDataSourceFactory(mockSynchronizer1)],
701+
[],
582702
undefined,
583703
makeTestTransitionConditions(),
584704
makeZeroBackoff(),
@@ -625,6 +745,7 @@ it('can be stopped and restarted', async () => {
625745

626746
it('is well behaved with no initializers and no synchronizers configured', async () => {
627747
const underTest = new CompositeDataSource(
748+
[],
628749
[],
629750
[],
630751
undefined,
@@ -671,6 +792,7 @@ it('is well behaved with no initializer and synchronizer configured', async () =
671792
const underTest = new CompositeDataSource(
672793
[],
673794
[makeDataSourceFactory(mockSynchronizer1)],
795+
[],
674796
undefined,
675797
makeTestTransitionConditions(),
676798
makeZeroBackoff(),
@@ -712,6 +834,7 @@ it('is well behaved with an initializer and no synchronizers configured', async
712834
const underTest = new CompositeDataSource(
713835
[makeDataSourceFactory(mockInitializer1)],
714836
[],
837+
[],
715838
undefined,
716839
makeTestTransitionConditions(),
717840
makeZeroBackoff(),
@@ -774,6 +897,7 @@ it('consumes cancellation tokens correctly', async () => {
774897
const underTest = new CompositeDataSource(
775898
[makeDataSourceFactory(mockInitializer1)],
776899
[makeDataSourceFactory(mockSynchronizer1)],
900+
[],
777901
undefined,
778902
{
779903
// pass in transition condition so that it will thrash, generating cancellation tokens repeatedly

packages/shared/common/src/api/platform/Requests.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,5 @@ export interface Requests {
134134
export interface HttpErrorResponse {
135135
message: string;
136136
status?: number;
137+
headers?: Record<string, string>;
137138
}

packages/shared/common/src/api/subsystem/DataSystem/DataSource.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ export interface DataSource {
1616
* @param dataCallback that will be called when data arrives, may be called multiple times.
1717
* @param statusCallback that will be called when data source state changes or an unrecoverable error
1818
* has been encountered.
19+
* @param selectorGetter that can be invoked to provide the FDv2 selector/basis if one exists
1920
*/
2021
start(
2122
dataCallback: (basis: boolean, data: any) => void,
2223
statusCallback: (status: DataSourceState, err?: any) => void,
24+
selectorGetter?: () => string | undefined,
2325
): void;
2426

2527
/**

0 commit comments

Comments
 (0)