Skip to content

Commit c76ec63

Browse files
committed
feat: add FDv1 fallback directive parsing and TTL data model
1 parent 6822048 commit c76ec63

3 files changed

Lines changed: 240 additions & 32 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import {
2+
readFallbackDirective,
3+
readGoodbyeFallbackDirective,
4+
} from '../../../src/datasource/fdv2/fallbackDirective';
5+
6+
function makeHeaders(map: Record<string, string>): { get(name: string): string | null } {
7+
const lower: Record<string, string> = {};
8+
Object.entries(map).forEach(([k, v]) => {
9+
lower[k.toLowerCase()] = v;
10+
});
11+
return {
12+
get: (name: string) => lower[name.toLowerCase()] ?? null,
13+
};
14+
}
15+
16+
it('returns fdv1Fallback false when x-ld-fd-fallback header is absent', () => {
17+
const result = readFallbackDirective(makeHeaders({}));
18+
expect(result.fdv1Fallback).toBe(false);
19+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
20+
});
21+
22+
it('returns fdv1Fallback false when x-ld-fd-fallback is not "true"', () => {
23+
const result = readFallbackDirective(makeHeaders({ 'x-ld-fd-fallback': 'false' }));
24+
expect(result.fdv1Fallback).toBe(false);
25+
});
26+
27+
it('matches "true" case-insensitively', () => {
28+
const result = readFallbackDirective(makeHeaders({ 'x-ld-fd-fallback': 'True' }));
29+
expect(result.fdv1Fallback).toBe(true);
30+
});
31+
32+
it('returns fdv1Fallback true with undefined TTL when x-ld-fd-fallback-ttl is absent', () => {
33+
const result = readFallbackDirective(makeHeaders({ 'x-ld-fd-fallback': 'true' }));
34+
expect(result.fdv1Fallback).toBe(true);
35+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
36+
});
37+
38+
it('converts a TTL of "60" seconds to 60000 ms', () => {
39+
const result = readFallbackDirective(
40+
makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '60' }),
41+
);
42+
expect(result.fdv1Fallback).toBe(true);
43+
expect(result.fdv1FallbackTtlMs).toBe(60000);
44+
});
45+
46+
it('converts TTL "0" to 0 ms (indefinite fallback)', () => {
47+
const result = readFallbackDirective(
48+
makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '0' }),
49+
);
50+
expect(result.fdv1Fallback).toBe(true);
51+
expect(result.fdv1FallbackTtlMs).toBe(0);
52+
});
53+
54+
it('returns undefined TTL for a non-numeric x-ld-fd-fallback-ttl value', () => {
55+
const result = readFallbackDirective(
56+
makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': 'soon' }),
57+
);
58+
expect(result.fdv1Fallback).toBe(true);
59+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
60+
});
61+
62+
it('clamps negative TTL seconds to 0 ms (treated as indefinite)', () => {
63+
const result = readFallbackDirective(
64+
makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '-5' }),
65+
);
66+
expect(result.fdv1Fallback).toBe(true);
67+
expect(result.fdv1FallbackTtlMs).toBe(0);
68+
});
69+
70+
it('header lookup is case-insensitive', () => {
71+
// Verify the makeHeaders helper lowercases keys, and readFallbackDirective
72+
// passes lowercase names to headers.get() as documented.
73+
const result = readFallbackDirective(
74+
makeHeaders({ 'X-LD-FD-FALLBACK': 'true', 'X-LD-FD-FALLBACK-TTL': '30' }),
75+
);
76+
expect(result.fdv1Fallback).toBe(true);
77+
expect(result.fdv1FallbackTtlMs).toBe(30000);
78+
});
79+
80+
it('readGoodbyeFallbackDirective: returns fdv1Fallback false when protocolFallbackTTL is absent', () => {
81+
const result = readGoodbyeFallbackDirective({ reason: 'bye' });
82+
expect(result.fdv1Fallback).toBe(false);
83+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
84+
});
85+
86+
it('readGoodbyeFallbackDirective: returns fdv1Fallback false when data is null', () => {
87+
const result = readGoodbyeFallbackDirective(null);
88+
expect(result.fdv1Fallback).toBe(false);
89+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
90+
});
91+
92+
it('readGoodbyeFallbackDirective: returns fdv1Fallback false when data is undefined', () => {
93+
const result = readGoodbyeFallbackDirective(undefined);
94+
expect(result.fdv1Fallback).toBe(false);
95+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
96+
});
97+
98+
it('readGoodbyeFallbackDirective: converts a protocolFallbackTTL of 60 seconds to 60000 ms', () => {
99+
const result = readGoodbyeFallbackDirective({ reason: 'falling back', protocolFallbackTTL: 60 });
100+
expect(result.fdv1Fallback).toBe(true);
101+
expect(result.fdv1FallbackTtlMs).toBe(60000);
102+
});
103+
104+
it('readGoodbyeFallbackDirective: converts protocolFallbackTTL 0 to 0 ms (indefinite fallback)', () => {
105+
const result = readGoodbyeFallbackDirective({ reason: 'falling back', protocolFallbackTTL: 0 });
106+
expect(result.fdv1Fallback).toBe(true);
107+
expect(result.fdv1FallbackTtlMs).toBe(0);
108+
});
109+
110+
it('readGoodbyeFallbackDirective: clamps negative protocolFallbackTTL to 0 ms', () => {
111+
const result = readGoodbyeFallbackDirective({ reason: 'falling back', protocolFallbackTTL: -5 });
112+
expect(result.fdv1Fallback).toBe(true);
113+
expect(result.fdv1FallbackTtlMs).toBe(0);
114+
});
115+
116+
it('readGoodbyeFallbackDirective: returns fdv1Fallback false for a non-numeric protocolFallbackTTL', () => {
117+
const result = readGoodbyeFallbackDirective({
118+
reason: 'falling back',
119+
protocolFallbackTTL: 'soon',
120+
});
121+
expect(result.fdv1Fallback).toBe(false);
122+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
123+
});
124+
125+
it('readGoodbyeFallbackDirective: returns fdv1Fallback false for a non-finite protocolFallbackTTL', () => {
126+
const result = readGoodbyeFallbackDirective({
127+
reason: 'falling back',
128+
protocolFallbackTTL: Infinity,
129+
});
130+
expect(result.fdv1Fallback).toBe(false);
131+
expect(result.fdv1FallbackTtlMs).toBeUndefined();
132+
});

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

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import DataSourceStatusErrorInfo from '../DataSourceStatusErrorInfo';
1313
export type SourceState = 'interrupted' | 'shutdown' | 'terminal_error' | 'goodbye';
1414

1515
/**
16-
* A change set result containing a processed FDv2 payload.
16+
* A successfully processed FDv2 payload ready for delivery to the flag store.
1717
*/
1818
export interface ChangeSetResult {
1919
type: 'changeSet';
@@ -22,84 +22,93 @@ export interface ChangeSetResult {
2222
environmentId?: string;
2323
/** Freshness timestamp from cache, if this result originated from cached data. */
2424
freshness?: number;
25+
/**
26+
* When `fdv1Fallback` is true, how long (ms) to remain on FDv1 before
27+
* attempting FDv2 recovery. `undefined` means no TTL was provided (caller
28+
* uses a default); `0` means indefinite (no recovery).
29+
*/
30+
fdv1FallbackTtlMs?: number;
2531
}
2632

2733
/**
28-
* A status result indicating a state transition (error, shutdown, goodbye).
34+
* A state-transition result (error, shutdown, or goodbye) with no payload.
2935
*/
3036
export interface StatusResult {
3137
type: 'status';
3238
state: SourceState;
3339
errorInfo?: DataSourceStatusErrorInfo;
3440
reason?: string;
3541
fdv1Fallback: boolean;
42+
/**
43+
* When `fdv1Fallback` is true, how long (ms) to remain on FDv1 before
44+
* attempting FDv2 recovery. `undefined` means no TTL was provided (caller
45+
* uses a default); `0` means indefinite (no recovery).
46+
*/
47+
fdv1FallbackTtlMs?: number;
3648
}
3749

3850
/**
3951
* The result type for FDv2 initializers and synchronizers.
4052
*
41-
* An initializer produces a single result, while a synchronizer produces a
42-
* stream of results. Each result is either a change set (containing a payload
43-
* of flag data) or a status (indicating a state transition like an error or
44-
* shutdown).
53+
* Initializers produce a single result; synchronizers produce a stream.
54+
* The orchestrator in {@link FDv2DataSource} drives the control flow based
55+
* on which variant is returned.
4556
*/
4657
export type FDv2SourceResult = ChangeSetResult | StatusResult;
4758

4859
/**
49-
* Creates a change set result containing processed flag data.
60+
* Wraps a processed FDv2 payload in a {@link ChangeSetResult}.
5061
*/
5162
export function changeSet(
5263
payload: internal.Payload,
5364
fdv1Fallback: boolean,
5465
environmentId?: string,
5566
freshness?: number,
67+
fdv1FallbackTtlMs?: number,
5668
): FDv2SourceResult {
57-
return { type: 'changeSet', payload, fdv1Fallback, environmentId, freshness };
69+
return { type: 'changeSet', payload, fdv1Fallback, environmentId, freshness, fdv1FallbackTtlMs };
5870
}
5971

6072
/**
61-
* Creates an interrupted status result. Indicates a transient error; the
62-
* synchronizer will attempt to recover automatically.
63-
*
64-
* When used with an initializer, this is still a terminal state.
73+
* Signals a transient error. Synchronizers retry automatically; for an
74+
* initializer this is terminal since there is no retry loop.
6575
*/
6676
export function interrupted(
6777
errorInfo: DataSourceStatusErrorInfo,
6878
fdv1Fallback: boolean,
79+
fdv1FallbackTtlMs?: number,
6980
): FDv2SourceResult {
70-
return { type: 'status', state: 'interrupted', errorInfo, fdv1Fallback };
81+
return { type: 'status', state: 'interrupted', errorInfo, fdv1Fallback, fdv1FallbackTtlMs };
7182
}
7283

7384
/**
74-
* Creates a shutdown status result. Indicates the data source was closed
75-
* gracefully and will not produce any further results.
85+
* Signals a graceful close initiated by the local caller (not the server).
7686
*/
7787
export function shutdown(): FDv2SourceResult {
7888
return { type: 'status', state: 'shutdown', fdv1Fallback: false };
7989
}
8090

8191
/**
82-
* Creates a terminal error status result. Indicates an unrecoverable error;
83-
* the data source will not produce any further results.
92+
* Signals an unrecoverable error. The orchestrator will block this source
93+
* and move on; it will not retry.
8494
*/
8595
export function terminalError(
8696
errorInfo: DataSourceStatusErrorInfo,
8797
fdv1Fallback: boolean,
98+
fdv1FallbackTtlMs?: number,
8899
): FDv2SourceResult {
89-
return { type: 'status', state: 'terminal_error', errorInfo, fdv1Fallback };
100+
return { type: 'status', state: 'terminal_error', errorInfo, fdv1Fallback, fdv1FallbackTtlMs };
90101
}
91102

92103
/**
93-
* Creates a goodbye status result. Indicates the server has instructed the
94-
* client to disconnect.
104+
* Signals a server-initiated disconnect. Unlike terminal_error, this is
105+
* expected and the synchronizer handles reconnection internally.
95106
*/
96107
export function goodbye(reason: string, fdv1Fallback: boolean): FDv2SourceResult {
97108
return { type: 'status', state: 'goodbye', reason, fdv1Fallback };
98109
}
99110

100-
/**
101-
* Helper to create a {@link DataSourceStatusErrorInfo} from an HTTP status code.
102-
*/
111+
/** Builds {@link DataSourceStatusErrorInfo} for an unexpected HTTP status. */
103112
export function errorInfoFromHttpError(statusCode: number): DataSourceStatusErrorInfo {
104113
return {
105114
kind: DataSourceErrorKind.ErrorResponse,
@@ -109,9 +118,7 @@ export function errorInfoFromHttpError(statusCode: number): DataSourceStatusErro
109118
};
110119
}
111120

112-
/**
113-
* Helper to create a {@link DataSourceStatusErrorInfo} from a network error.
114-
*/
121+
/** Builds {@link DataSourceStatusErrorInfo} for a network-level failure. */
115122
export function errorInfoFromNetworkError(message: string): DataSourceStatusErrorInfo {
116123
return {
117124
kind: DataSourceErrorKind.NetworkError,
@@ -120,9 +127,7 @@ export function errorInfoFromNetworkError(message: string): DataSourceStatusErro
120127
};
121128
}
122129

123-
/**
124-
* Helper to create a {@link DataSourceStatusErrorInfo} from invalid data.
125-
*/
130+
/** Builds {@link DataSourceStatusErrorInfo} for a malformed or unexpected payload. */
126131
export function errorInfoFromInvalidData(message: string): DataSourceStatusErrorInfo {
127132
return {
128133
kind: DataSourceErrorKind.InvalidData,
@@ -131,9 +136,7 @@ export function errorInfoFromInvalidData(message: string): DataSourceStatusError
131136
};
132137
}
133138

134-
/**
135-
* Helper to create a {@link DataSourceStatusErrorInfo} for unknown errors.
136-
*/
139+
/** Builds {@link DataSourceStatusErrorInfo} when the error kind is not otherwise classifiable. */
137140
export function errorInfoFromUnknown(message: string): DataSourceStatusErrorInfo {
138141
return {
139142
kind: DataSourceErrorKind.Unknown,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* The FDv1 fallback directive parsed from a connection's response headers.
3+
* Its presence (`fdv1Fallback === true`) means the server asked the SDK to
4+
* fall back to FDv1.
5+
*
6+
* `fdv1FallbackTtlMs` is how long to remain on FDv1 before retrying FDv2:
7+
* - `undefined`: the server gave no TTL header (caller uses a 1-hour default).
8+
* - `0`: indefinite fallback (no automatic recovery).
9+
* - `> 0`: milliseconds to wait before attempting FDv2 recovery.
10+
*
11+
* This is the single place that interprets `x-ld-fd-fallback` and
12+
* `x-ld-fd-fallback-ttl`, shared by the streaming and polling sources.
13+
*/
14+
export interface FallbackDirective {
15+
fdv1Fallback: boolean;
16+
fdv1FallbackTtlMs?: number;
17+
}
18+
19+
/**
20+
* Reads the FDv1 fallback directive from response headers. Returns
21+
* `{ fdv1Fallback: false }` when `x-ld-fd-fallback` is absent or not `"true"`.
22+
*
23+
* @param headers Header accessor. The `get` method must accept header names
24+
* in any casing; both streaming and polling callers normalize to lowercase
25+
* before calling this function.
26+
*/
27+
export function readFallbackDirective(headers: {
28+
get(name: string): string | null;
29+
}): FallbackDirective {
30+
const fallback = headers.get('x-ld-fd-fallback');
31+
if (fallback === null || fallback.toLowerCase() !== 'true') {
32+
return { fdv1Fallback: false };
33+
}
34+
35+
const raw = headers.get('x-ld-fd-fallback-ttl');
36+
if (raw === null) {
37+
return { fdv1Fallback: true };
38+
}
39+
40+
const seconds = parseInt(raw, 10);
41+
if (Number.isNaN(seconds)) {
42+
return { fdv1Fallback: true };
43+
}
44+
45+
// Clamp negative values to 0 (treated as indefinite, same as TTL=0).
46+
// Prevents a malicious server from sending a large-negative TTL to trigger
47+
// immediate recovery instead of the intended long wait.
48+
return { fdv1Fallback: true, fdv1FallbackTtlMs: Math.max(0, seconds) * 1000 };
49+
}
50+
51+
/**
52+
* Reads the FDv1 fallback directive from an FDv2 `goodbye` event's data.
53+
*
54+
* SDKs that cannot read streaming response headers (e.g. browsers using the
55+
* native `EventSource` API) receive the fallback directive in-band via the
56+
* goodbye message's `protocolFallbackTTL` field.
57+
* Presence of a finite numeric `protocolFallbackTTL` signals FDv1 fallback;
58+
* the value carries the same semantics as the `x-ld-fd-fallback-ttl` header
59+
* (`0` indicates indefinite fallback). A missing, non-numeric, or non-finite
60+
* value is not a fallback signal and yields `{ fdv1Fallback: false }`.
61+
*
62+
* @param data The raw, parsed goodbye event data (typed `unknown` because the
63+
* caller has not narrowed it).
64+
*/
65+
export function readGoodbyeFallbackDirective(data: unknown): FallbackDirective {
66+
const rawTtl = (data as { protocolFallbackTTL?: unknown } | null | undefined)
67+
?.protocolFallbackTTL;
68+
if (typeof rawTtl !== 'number' || !Number.isFinite(rawTtl)) {
69+
return { fdv1Fallback: false };
70+
}
71+
72+
return { fdv1Fallback: true, fdv1FallbackTtlMs: Math.max(0, rawTtl) * 1000 };
73+
}

0 commit comments

Comments
 (0)