Skip to content

Commit 9d6f7f9

Browse files
committed
chore: pr comments
1 parent ff0a597 commit 9d6f7f9

10 files changed

Lines changed: 302 additions & 217 deletions

File tree

packages/shared/sdk-client/__tests__/datasource/fdv2/Conditions.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,15 @@ function raceTimeout<T>(promise: Promise<T>, ms: number): Promise<T | typeof DID
3333
function makeInterrupted(): FDv2SourceResult {
3434
return interrupted(
3535
{ kind: DataSourceErrorKind.NetworkError, message: 'test error', time: Date.now() },
36-
false,
36+
{ fdv1Fallback: false },
3737
);
3838
}
3939

4040
function makeChangeSet(): FDv2SourceResult {
41-
return changeSet({ version: 1, state: 'test-state', type: 'full', updates: [] }, false);
41+
return changeSet(
42+
{ version: 1, state: 'test-state', type: 'full', updates: [] },
43+
{ fdv1Fallback: false },
44+
);
4245
}
4346

4447
// -- fallback condition --
@@ -101,7 +104,7 @@ it('fallback condition does not start timer on terminal error', async () => {
101104
condition.inform(
102105
terminalError(
103106
{ kind: DataSourceErrorKind.ErrorResponse, message: 'unauthorized', time: Date.now() },
104-
false,
107+
{ fdv1Fallback: false },
105108
),
106109
);
107110
expect(await raceTimeout(condition.promise, 50)).toBe(DID_NOT_RESOLVE);
@@ -117,14 +120,14 @@ it('fallback condition does not start timer on shutdown', async () => {
117120

118121
it('fallback condition does not start timer on goodbye', async () => {
119122
const condition = createFallbackCondition(10);
120-
condition.inform(goodbye('server-requested', false));
123+
condition.inform(goodbye('server-requested', { fdv1Fallback: false }));
121124
expect(await raceTimeout(condition.promise, 50)).toBe(DID_NOT_RESOLVE);
122125
condition.close();
123126
});
124127

125128
it('fallback condition changeSet without active timer does not cause issues', async () => {
126129
const condition = createFallbackCondition(10);
127-
// changeSet without a prior interrupted should be safe
130+
// changeSet without a prior interrupted, should be safe
128131
condition.inform(makeChangeSet());
129132
expect(await raceTimeout(condition.promise, 50)).toBe(DID_NOT_RESOLVE);
130133
condition.close();
@@ -165,7 +168,7 @@ it('recovery condition does not fire after close', async () => {
165168
it('recovery condition close after timer fires does not cause error', async () => {
166169
const condition = createRecoveryCondition(10);
167170
expect(await condition.promise).toBe('recovery');
168-
// Close after timer already fired should not throw
171+
// Close after timer already fired, should not throw
169172
condition.close();
170173
});
171174

packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2DataSource.test.ts

Lines changed: 63 additions & 63 deletions
Large diffs are not rendered by default.

packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2SourceResult.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ it('creates a changeSet result with a payload', () => {
2020
type: 'full' as const,
2121
updates: [],
2222
};
23-
const result = changeSet(payload, false, 'env-123');
23+
const result = changeSet(payload, { fdv1Fallback: false }, 'env-123');
2424

2525
expect(result.type).toBe('changeSet');
2626
expect(result).toEqual({
@@ -33,7 +33,7 @@ it('creates a changeSet result with a payload', () => {
3333

3434
it('creates a changeSet result with fdv1Fallback flag', () => {
3535
const payload = { version: 1, type: 'full' as const, updates: [] };
36-
const result = changeSet(payload, true);
36+
const result = changeSet(payload, { fdv1Fallback: true });
3737

3838
expect(result.type).toBe('changeSet');
3939
if (result.type === 'changeSet') {
@@ -47,7 +47,7 @@ it('creates an interrupted status result', () => {
4747
message: 'connection reset',
4848
time: 1000,
4949
};
50-
const result = interrupted(errorInfo, false);
50+
const result = interrupted(errorInfo, { fdv1Fallback: false });
5151

5252
expect(result).toEqual({
5353
type: 'status',
@@ -74,7 +74,7 @@ it('creates a terminal error status result', () => {
7474
statusCode: 401,
7575
time: 2000,
7676
};
77-
const result = terminalError(errorInfo, true);
77+
const result = terminalError(errorInfo, { fdv1Fallback: true });
7878

7979
expect(result).toEqual({
8080
type: 'status',
@@ -85,7 +85,7 @@ it('creates a terminal error status result', () => {
8585
});
8686

8787
it('creates a goodbye status result', () => {
88-
const result = goodbye('server-shutdown', false);
88+
const result = goodbye('server-shutdown', { fdv1Fallback: false });
8989

9090
expect(result).toEqual({
9191
type: 'status',
@@ -96,7 +96,7 @@ it('creates a goodbye status result', () => {
9696
});
9797

9898
it('creates a goodbye status result with fdv1Fallback and a TTL', () => {
99-
const result = goodbye('server-shutdown', true, 5000);
99+
const result = goodbye('server-shutdown', { fdv1Fallback: true, fdv1FallbackTtlMs: 5000 });
100100

101101
expect(result).toEqual({
102102
type: 'status',
@@ -108,7 +108,7 @@ it('creates a goodbye status result with fdv1Fallback and a TTL', () => {
108108
});
109109

110110
it('creates a goodbye status result with TTL 0 (indefinite fallback)', () => {
111-
const result = goodbye('server-shutdown', true, 0);
111+
const result = goodbye('server-shutdown', { fdv1Fallback: true, fdv1FallbackTtlMs: 0 });
112112

113113
expect(result).toEqual({
114114
type: 'status',

packages/shared/sdk-client/__tests__/datasource/fdv2/StreamingFDv2Base.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,37 @@ it('emits terminal_error for a goodbye when a deferred open directive is pending
608608
base.close();
609609
});
610610

611+
it('prefers the in-band goodbye directive TTL over a pending onopen-deferred TTL', async () => {
612+
// When onopen defers a fallback directive AND the goodbye event itself carries
613+
// its own protocolFallbackTTL, the in-band directive wins: it uses its own TTL
614+
// (not the pending one) and still clears the pending state.
615+
const mockEventSource = createMockEventSource();
616+
const mockRequests = createMockRequests(mockEventSource);
617+
const base = createBase(mockRequests, logger);
618+
base.start();
619+
620+
// Open with fallback headers (defers a directive with TTL 45s)
621+
mockEventSource.onopen({
622+
type: 'open',
623+
headers: { 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '45' },
624+
});
625+
626+
// Goodbye fires with its own, different TTL (60s) before any payload
627+
simulateEvent(mockEventSource, 'goodbye', {
628+
reason: 'falling back',
629+
protocolFallbackTTL: 60,
630+
});
631+
632+
const result = await base.takeResult();
633+
expect(result.type).toBe('status');
634+
if (result.type !== 'status') return;
635+
expect(result.state).toBe('terminal_error');
636+
expect(result.fdv1Fallback).toBe(true);
637+
expect(result.fdv1FallbackTtlMs).toBe(60000);
638+
639+
base.close();
640+
});
641+
611642
it('clears a pending fallback directive when onopen fires without the fallback header', async () => {
612643
const mockEventSource = createMockEventSource();
613644
const mockRequests = createMockRequests(mockEventSource);
@@ -854,6 +885,40 @@ it('backfills the deferred TTL into a ping-triggered poll result that already si
854885
base.close();
855886
});
856887

888+
it('does not mutate the ping handler result object when applying a deferred fallback', async () => {
889+
const mockEventSource = createMockEventSource();
890+
const mockRequests = createMockRequests(mockEventSource);
891+
const pingResult = {
892+
type: 'changeSet' as const,
893+
payload: { events: [], selector: undefined },
894+
fdv1Fallback: false,
895+
};
896+
const pingHandler: PingHandler = {
897+
handlePing: jest.fn().mockResolvedValue(pingResult),
898+
};
899+
const base = createBase(mockRequests, logger, { pingHandler });
900+
base.start();
901+
902+
mockEventSource.onopen({
903+
type: 'open',
904+
headers: { 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '75' },
905+
});
906+
907+
const { calls } = mockEventSource.addEventListener.mock;
908+
const pingListener = calls.find((c: any[]) => c[0] === 'ping')?.[1];
909+
await pingListener();
910+
911+
const result = await base.takeResult();
912+
// The queued result carries the deferred fallback...
913+
expect(result.fdv1Fallback).toBe(true);
914+
expect(result.fdv1FallbackTtlMs).toBe(75000);
915+
// ...but the object handed back by the ping handler is left untouched.
916+
expect(pingResult.fdv1Fallback).toBe(false);
917+
expect((pingResult as any).fdv1FallbackTtlMs).toBeUndefined();
918+
919+
base.close();
920+
});
921+
857922
it('surfaces a deferred fallback directive when a ping-triggered poll throws', async () => {
858923
const mockEventSource = createMockEventSource();
859924
const mockRequests = createMockRequests(mockEventSource);

packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,13 @@ async function loadFromCache(config: CacheInitializerConfig): Promise<FDv2Source
4343

4444
if (!storage) {
4545
logger?.debug('No storage available for cache initializer');
46-
return changeSet({ version: 0, type: 'none', updates: [] }, false);
46+
return changeSet({ version: 0, type: 'none', updates: [] }, { fdv1Fallback: false });
4747
}
4848

4949
const cached = await loadCachedFlags(storage, crypto, environmentNamespace, context, logger);
5050
if (!cached) {
5151
logger?.debug('Cache miss for context');
52-
return changeSet({ version: 0, type: 'none', updates: [] }, false);
52+
return changeSet({ version: 0, type: 'none', updates: [] }, { fdv1Fallback: false });
5353
}
5454

5555
const updates: internal.Update[] = Object.entries(cached.flags).map(
@@ -72,7 +72,7 @@ async function loadFromCache(config: CacheInitializerConfig): Promise<FDv2Source
7272
const freshness = await readFreshness(storage, crypto, environmentNamespace, context, logger);
7373

7474
logger?.debug('Loaded cached flag evaluations via cache initializer');
75-
return changeSet(payload, false, undefined, freshness);
75+
return changeSet(payload, { fdv1Fallback: false }, undefined, freshness);
7676
}
7777

7878
/**
@@ -91,7 +91,7 @@ async function loadFromCache(config: CacheInitializerConfig): Promise<FDv2Source
9191
export function createCacheInitializerFactory(config: CacheInitializerConfig): InitializerFactory {
9292
return {
9393
isCache: true,
94-
// The selectorGetter is ignored -- cache data has no selector.
94+
// The selectorGetter is ignored: cache data has no selector.
9595
create(_selectorGetter: () => string | undefined): Initializer {
9696
let shutdownResolve: ((result: FDv2SourceResult) => void) | undefined;
9797
const shutdownPromise = new Promise<FDv2SourceResult>((resolve) => {

packages/shared/sdk-client/src/datasource/fdv2/FDv1PollingSynchronizer.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ function flagsToPayload(flags: Flags): internal.Payload {
2424
const updates: internal.Update[] = Object.entries(flags).map(([key, flag]) => ({
2525
kind: 'flag-eval',
2626
key,
27+
// The envelope requires a numeric version; default to 1 if FDv1 omits it
2728
version: flag.version ?? 1,
2829
object: flag,
2930
}));
@@ -69,6 +70,8 @@ export function createFDv1PollingSynchronizer(
6970

7071
function scheduleNextPoll(startTime: number): void {
7172
if (!stopped) {
73+
// Subtract time already spent on the request so the poll cadence stays
74+
// fixed to pollIntervalMs instead of drifting later with each slow response.
7275
const elapsed = Date.now() - startTime;
7376
const sleepFor = Math.min(Math.max(pollIntervalMs - elapsed, 0), pollIntervalMs);
7477
// eslint-disable-next-line @typescript-eslint/no-use-before-define
@@ -84,6 +87,9 @@ export function createFDv1PollingSynchronizer(
8487
logger?.debug('Polling FDv1 endpoint for feature flag updates');
8588
const startTime = Date.now();
8689

90+
// Results below always carry fdv1Fallback: false; this synchronizer only
91+
// runs once already on the FDv1 fallback path, so there's no further
92+
// fallback for it to signal.
8793
try {
8894
const body = await requestor.requestPayload();
8995

@@ -107,7 +113,7 @@ export function createFDv1PollingSynchronizer(
107113
return;
108114
}
109115

110-
resultQueue.put(changeSet(payload, false));
116+
resultQueue.put(changeSet(payload, { fdv1Fallback: false }));
111117
} catch (err) {
112118
if (stopped) {
113119
return;
@@ -118,7 +124,9 @@ export function createFDv1PollingSynchronizer(
118124
if (!isHttpRecoverable(requestError.status)) {
119125
logger?.error(httpErrorMessage(err as HttpErrorResponse, 'FDv1 polling request'));
120126
stopped = true;
121-
shutdownResolve?.(terminalError(errorInfoFromHttpError(requestError.status), false));
127+
shutdownResolve?.(
128+
terminalError(errorInfoFromHttpError(requestError.status), { fdv1Fallback: false }),
129+
);
122130
shutdownResolve = undefined;
123131
return;
124132
}

packages/shared/sdk-client/src/datasource/fdv2/FDv2SourceResult.ts

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { DataSourceErrorKind, internal } from '@launchdarkly/js-sdk-common';
22

33
import DataSourceStatusErrorInfo from '../DataSourceStatusErrorInfo';
4+
import { FallbackDirective } from './fallbackDirective';
45

56
/**
67
* Possible states for a status result from an FDv2 data source.
78
*
89
* - `interrupted`: Transient error; synchronizer will retry automatically.
910
* - `shutdown`: Graceful shutdown; no further results will be produced.
1011
* - `terminal_error`: Unrecoverable error; no further results will be produced.
11-
* - `goodbye`: Server-initiated disconnect; no further results will be produced.
12+
* - `goodbye`: Server-initiated disconnect. Synchronizers reconnect internally
13+
* and may still produce results; initializers are single-shot, so the
14+
* orchestrator just moves on to the next one.
1215
*/
1316
export type SourceState = 'interrupted' | 'shutdown' | 'terminal_error' | 'goodbye';
1417

@@ -61,12 +64,18 @@ export type FDv2SourceResult = ChangeSetResult | StatusResult;
6164
*/
6265
export function changeSet(
6366
payload: internal.Payload,
64-
fdv1Fallback: boolean,
67+
fallback: FallbackDirective,
6568
environmentId?: string,
6669
freshness?: number,
67-
fdv1FallbackTtlMs?: number,
6870
): FDv2SourceResult {
69-
return { type: 'changeSet', payload, fdv1Fallback, environmentId, freshness, fdv1FallbackTtlMs };
71+
return {
72+
type: 'changeSet',
73+
payload,
74+
fdv1Fallback: fallback.fdv1Fallback,
75+
environmentId,
76+
freshness,
77+
fdv1FallbackTtlMs: fallback.fdv1FallbackTtlMs,
78+
};
7079
}
7180

7281
/**
@@ -75,10 +84,15 @@ export function changeSet(
7584
*/
7685
export function interrupted(
7786
errorInfo: DataSourceStatusErrorInfo,
78-
fdv1Fallback: boolean,
79-
fdv1FallbackTtlMs?: number,
87+
fallback: FallbackDirective,
8088
): FDv2SourceResult {
81-
return { type: 'status', state: 'interrupted', errorInfo, fdv1Fallback, fdv1FallbackTtlMs };
89+
return {
90+
type: 'status',
91+
state: 'interrupted',
92+
errorInfo,
93+
fdv1Fallback: fallback.fdv1Fallback,
94+
fdv1FallbackTtlMs: fallback.fdv1FallbackTtlMs,
95+
};
8296
}
8397

8498
/**
@@ -94,28 +108,36 @@ export function shutdown(): FDv2SourceResult {
94108
*/
95109
export function terminalError(
96110
errorInfo: DataSourceStatusErrorInfo,
97-
fdv1Fallback: boolean,
98-
fdv1FallbackTtlMs?: number,
111+
fallback: FallbackDirective,
99112
): FDv2SourceResult {
100-
return { type: 'status', state: 'terminal_error', errorInfo, fdv1Fallback, fdv1FallbackTtlMs };
113+
return {
114+
type: 'status',
115+
state: 'terminal_error',
116+
errorInfo,
117+
fdv1Fallback: fallback.fdv1Fallback,
118+
fdv1FallbackTtlMs: fallback.fdv1FallbackTtlMs,
119+
};
101120
}
102121

103122
/**
104123
* Signals a server-initiated disconnect. Unlike `terminal_error`, the
105124
* synchronizer will reconnect; the orchestrator does not block this source.
106125
*
107126
* @param reason Human-readable description of why the server closed the stream.
108-
* @param fdv1Fallback Whether the server directed the client to fall back to FDv1.
109-
* @param fdv1FallbackTtlMs How long (ms) to remain on FDv1 before attempting FDv2
110-
* recovery. Omit to use the caller's default; pass `0` for indefinite fallback.
111-
* Same semantics as {@link StatusResult.fdv1FallbackTtlMs}.
127+
* @param fallback The FDv1 fallback directive. `fdv1Fallback === true` means the
128+
* server directed the client to fall back to FDv1. `fdv1FallbackTtlMs` is how
129+
* long (ms) to remain on FDv1 before attempting FDv2 recovery (omit for the
130+
* caller's default; `0` for indefinite). Same semantics as
131+
* {@link StatusResult.fdv1FallbackTtlMs}.
112132
*/
113-
export function goodbye(
114-
reason: string,
115-
fdv1Fallback: boolean,
116-
fdv1FallbackTtlMs?: number,
117-
): FDv2SourceResult {
118-
return { type: 'status', state: 'goodbye', reason, fdv1Fallback, fdv1FallbackTtlMs };
133+
export function goodbye(reason: string, fallback: FallbackDirective): FDv2SourceResult {
134+
return {
135+
type: 'status',
136+
state: 'goodbye',
137+
reason,
138+
fdv1Fallback: fallback.fdv1Fallback,
139+
fdv1FallbackTtlMs: fallback.fdv1FallbackTtlMs,
140+
};
119141
}
120142

121143
/** Builds {@link DataSourceStatusErrorInfo} for an unexpected HTTP status. */

0 commit comments

Comments
 (0)