diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2SourceResult.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2SourceResult.test.ts index 39104f4e6c..3d828c9046 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2SourceResult.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/FDv2SourceResult.test.ts @@ -95,6 +95,30 @@ it('creates a goodbye status result', () => { }); }); +it('creates a goodbye status result with fdv1Fallback and a TTL', () => { + const result = goodbye('server-shutdown', true, 5000); + + expect(result).toEqual({ + type: 'status', + state: 'goodbye', + reason: 'server-shutdown', + fdv1Fallback: true, + fdv1FallbackTtlMs: 5000, + }); +}); + +it('creates a goodbye status result with TTL 0 (indefinite fallback)', () => { + const result = goodbye('server-shutdown', true, 0); + + expect(result).toEqual({ + type: 'status', + state: 'goodbye', + reason: 'server-shutdown', + fdv1Fallback: true, + fdv1FallbackTtlMs: 0, + }); +}); + it('creates error info from an HTTP status code', () => { const info = errorInfoFromHttpError(503); diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/fallbackDirective.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/fallbackDirective.test.ts new file mode 100644 index 0000000000..d8b0aa1097 --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/fallbackDirective.test.ts @@ -0,0 +1,132 @@ +import { + readFallbackDirective, + readGoodbyeFallbackDirective, +} from '../../../src/datasource/fdv2/fallbackDirective'; + +function makeHeaders(map: Record): { get(name: string): string | null } { + const lower: Record = {}; + Object.entries(map).forEach(([k, v]) => { + lower[k.toLowerCase()] = v; + }); + return { + get: (name: string) => lower[name.toLowerCase()] ?? null, + }; +} + +it('returns fdv1Fallback false when x-ld-fd-fallback header is absent', () => { + const result = readFallbackDirective(makeHeaders({})); + expect(result.fdv1Fallback).toBe(false); + expect(result.fdv1FallbackTtlMs).toBeUndefined(); +}); + +it('returns fdv1Fallback false when x-ld-fd-fallback is not "true"', () => { + const result = readFallbackDirective(makeHeaders({ 'x-ld-fd-fallback': 'false' })); + expect(result.fdv1Fallback).toBe(false); +}); + +it('matches "true" case-insensitively', () => { + const result = readFallbackDirective(makeHeaders({ 'x-ld-fd-fallback': 'True' })); + expect(result.fdv1Fallback).toBe(true); +}); + +it('returns fdv1Fallback true with undefined TTL when x-ld-fd-fallback-ttl is absent', () => { + const result = readFallbackDirective(makeHeaders({ 'x-ld-fd-fallback': 'true' })); + expect(result.fdv1Fallback).toBe(true); + expect(result.fdv1FallbackTtlMs).toBeUndefined(); +}); + +it('converts a TTL of "60" seconds to 60000 ms', () => { + const result = readFallbackDirective( + makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '60' }), + ); + expect(result.fdv1Fallback).toBe(true); + expect(result.fdv1FallbackTtlMs).toBe(60000); +}); + +it('converts TTL "0" to 0 ms (indefinite fallback)', () => { + const result = readFallbackDirective( + makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '0' }), + ); + expect(result.fdv1Fallback).toBe(true); + expect(result.fdv1FallbackTtlMs).toBe(0); +}); + +it('returns undefined TTL for a non-numeric x-ld-fd-fallback-ttl value', () => { + const result = readFallbackDirective( + makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': 'soon' }), + ); + expect(result.fdv1Fallback).toBe(true); + expect(result.fdv1FallbackTtlMs).toBeUndefined(); +}); + +it('clamps negative TTL seconds to 0 ms (treated as indefinite)', () => { + const result = readFallbackDirective( + makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '-5' }), + ); + expect(result.fdv1Fallback).toBe(true); + expect(result.fdv1FallbackTtlMs).toBe(0); +}); + +it('header lookup is case-insensitive', () => { + // Verify the makeHeaders helper lowercases keys, and readFallbackDirective + // passes lowercase names to headers.get() as documented. + const result = readFallbackDirective( + makeHeaders({ 'X-LD-FD-FALLBACK': 'true', 'X-LD-FD-FALLBACK-TTL': '30' }), + ); + expect(result.fdv1Fallback).toBe(true); + expect(result.fdv1FallbackTtlMs).toBe(30000); +}); + +it('readGoodbyeFallbackDirective: returns fdv1Fallback false when protocolFallbackTTL is absent', () => { + const result = readGoodbyeFallbackDirective({ reason: 'bye' }); + expect(result.fdv1Fallback).toBe(false); + expect(result.fdv1FallbackTtlMs).toBeUndefined(); +}); + +it('readGoodbyeFallbackDirective: returns fdv1Fallback false when data is null', () => { + const result = readGoodbyeFallbackDirective(null); + expect(result.fdv1Fallback).toBe(false); + expect(result.fdv1FallbackTtlMs).toBeUndefined(); +}); + +it('readGoodbyeFallbackDirective: returns fdv1Fallback false when data is undefined', () => { + const result = readGoodbyeFallbackDirective(undefined); + expect(result.fdv1Fallback).toBe(false); + expect(result.fdv1FallbackTtlMs).toBeUndefined(); +}); + +it('readGoodbyeFallbackDirective: converts a protocolFallbackTTL of 60 seconds to 60000 ms', () => { + const result = readGoodbyeFallbackDirective({ reason: 'falling back', protocolFallbackTTL: 60 }); + expect(result.fdv1Fallback).toBe(true); + expect(result.fdv1FallbackTtlMs).toBe(60000); +}); + +it('readGoodbyeFallbackDirective: converts protocolFallbackTTL 0 to 0 ms (indefinite fallback)', () => { + const result = readGoodbyeFallbackDirective({ reason: 'falling back', protocolFallbackTTL: 0 }); + expect(result.fdv1Fallback).toBe(true); + expect(result.fdv1FallbackTtlMs).toBe(0); +}); + +it('readGoodbyeFallbackDirective: clamps negative protocolFallbackTTL to 0 ms', () => { + const result = readGoodbyeFallbackDirective({ reason: 'falling back', protocolFallbackTTL: -5 }); + expect(result.fdv1Fallback).toBe(true); + expect(result.fdv1FallbackTtlMs).toBe(0); +}); + +it('readGoodbyeFallbackDirective: returns fdv1Fallback false for a non-numeric protocolFallbackTTL', () => { + const result = readGoodbyeFallbackDirective({ + reason: 'falling back', + protocolFallbackTTL: 'soon', + }); + expect(result.fdv1Fallback).toBe(false); + expect(result.fdv1FallbackTtlMs).toBeUndefined(); +}); + +it('readGoodbyeFallbackDirective: returns fdv1Fallback false for a non-finite protocolFallbackTTL', () => { + const result = readGoodbyeFallbackDirective({ + reason: 'falling back', + protocolFallbackTTL: Infinity, + }); + expect(result.fdv1Fallback).toBe(false); + expect(result.fdv1FallbackTtlMs).toBeUndefined(); +}); diff --git a/packages/shared/sdk-client/src/datasource/fdv2/FDv2SourceResult.ts b/packages/shared/sdk-client/src/datasource/fdv2/FDv2SourceResult.ts index 7b62e3b424..b742897591 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/FDv2SourceResult.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/FDv2SourceResult.ts @@ -13,7 +13,7 @@ import DataSourceStatusErrorInfo from '../DataSourceStatusErrorInfo'; export type SourceState = 'interrupted' | 'shutdown' | 'terminal_error' | 'goodbye'; /** - * A change set result containing a processed FDv2 payload. + * A successfully processed FDv2 payload ready for delivery to the flag store. */ export interface ChangeSetResult { type: 'changeSet'; @@ -22,10 +22,16 @@ export interface ChangeSetResult { environmentId?: string; /** Freshness timestamp from cache, if this result originated from cached data. */ freshness?: number; + /** + * When `fdv1Fallback` is true, how long (ms) to remain on FDv1 before + * attempting FDv2 recovery. `undefined` means no TTL was provided (caller + * uses a default); `0` means indefinite (no recovery). + */ + fdv1FallbackTtlMs?: number; } /** - * A status result indicating a state transition (error, shutdown, goodbye). + * A state-transition result (error, shutdown, or goodbye) with no payload. */ export interface StatusResult { type: 'status'; @@ -33,73 +39,86 @@ export interface StatusResult { errorInfo?: DataSourceStatusErrorInfo; reason?: string; fdv1Fallback: boolean; + /** + * When `fdv1Fallback` is true, how long (ms) to remain on FDv1 before + * attempting FDv2 recovery. `undefined` means no TTL was provided (caller + * uses a default); `0` means indefinite (no recovery). + */ + fdv1FallbackTtlMs?: number; } /** * The result type for FDv2 initializers and synchronizers. * - * An initializer produces a single result, while a synchronizer produces a - * stream of results. Each result is either a change set (containing a payload - * of flag data) or a status (indicating a state transition like an error or - * shutdown). + * Initializers produce a single result; synchronizers produce a stream. + * The orchestrator in {@link FDv2DataSource} drives the control flow based + * on which variant is returned. */ export type FDv2SourceResult = ChangeSetResult | StatusResult; /** - * Creates a change set result containing processed flag data. + * Wraps a processed FDv2 payload in a {@link ChangeSetResult}. */ export function changeSet( payload: internal.Payload, fdv1Fallback: boolean, environmentId?: string, freshness?: number, + fdv1FallbackTtlMs?: number, ): FDv2SourceResult { - return { type: 'changeSet', payload, fdv1Fallback, environmentId, freshness }; + return { type: 'changeSet', payload, fdv1Fallback, environmentId, freshness, fdv1FallbackTtlMs }; } /** - * Creates an interrupted status result. Indicates a transient error; the - * synchronizer will attempt to recover automatically. - * - * When used with an initializer, this is still a terminal state. + * Signals a transient error. Synchronizers retry automatically; for an + * initializer this is terminal since there is no retry loop. */ export function interrupted( errorInfo: DataSourceStatusErrorInfo, fdv1Fallback: boolean, + fdv1FallbackTtlMs?: number, ): FDv2SourceResult { - return { type: 'status', state: 'interrupted', errorInfo, fdv1Fallback }; + return { type: 'status', state: 'interrupted', errorInfo, fdv1Fallback, fdv1FallbackTtlMs }; } /** - * Creates a shutdown status result. Indicates the data source was closed - * gracefully and will not produce any further results. + * Signals a graceful close initiated by the local caller (not the server). */ export function shutdown(): FDv2SourceResult { return { type: 'status', state: 'shutdown', fdv1Fallback: false }; } /** - * Creates a terminal error status result. Indicates an unrecoverable error; - * the data source will not produce any further results. + * Signals an unrecoverable error. The orchestrator will block this source + * and move on; it will not retry. */ export function terminalError( errorInfo: DataSourceStatusErrorInfo, fdv1Fallback: boolean, + fdv1FallbackTtlMs?: number, ): FDv2SourceResult { - return { type: 'status', state: 'terminal_error', errorInfo, fdv1Fallback }; + return { type: 'status', state: 'terminal_error', errorInfo, fdv1Fallback, fdv1FallbackTtlMs }; } /** - * Creates a goodbye status result. Indicates the server has instructed the - * client to disconnect. + * Signals a server-initiated disconnect. Unlike `terminal_error`, the + * synchronizer will reconnect; the orchestrator does not block this source. + * + * @param reason Human-readable description of why the server closed the stream. + * @param fdv1Fallback Whether the server directed the client to fall back to FDv1. + * @param fdv1FallbackTtlMs How long (ms) to remain on FDv1 before attempting FDv2 + * recovery. Omit to use the caller's default; pass `0` for indefinite fallback. + * Same semantics as {@link StatusResult.fdv1FallbackTtlMs}. */ -export function goodbye(reason: string, fdv1Fallback: boolean): FDv2SourceResult { - return { type: 'status', state: 'goodbye', reason, fdv1Fallback }; +export function goodbye( + reason: string, + fdv1Fallback: boolean, + fdv1FallbackTtlMs?: number, +): FDv2SourceResult { + return { type: 'status', state: 'goodbye', reason, fdv1Fallback, fdv1FallbackTtlMs }; } -/** - * Helper to create a {@link DataSourceStatusErrorInfo} from an HTTP status code. - */ +/** Builds {@link DataSourceStatusErrorInfo} for an unexpected HTTP status. */ export function errorInfoFromHttpError(statusCode: number): DataSourceStatusErrorInfo { return { kind: DataSourceErrorKind.ErrorResponse, @@ -109,9 +128,7 @@ export function errorInfoFromHttpError(statusCode: number): DataSourceStatusErro }; } -/** - * Helper to create a {@link DataSourceStatusErrorInfo} from a network error. - */ +/** Builds {@link DataSourceStatusErrorInfo} for a network-level failure. */ export function errorInfoFromNetworkError(message: string): DataSourceStatusErrorInfo { return { kind: DataSourceErrorKind.NetworkError, @@ -120,9 +137,7 @@ export function errorInfoFromNetworkError(message: string): DataSourceStatusErro }; } -/** - * Helper to create a {@link DataSourceStatusErrorInfo} from invalid data. - */ +/** Builds {@link DataSourceStatusErrorInfo} for a malformed or unexpected payload. */ export function errorInfoFromInvalidData(message: string): DataSourceStatusErrorInfo { return { kind: DataSourceErrorKind.InvalidData, @@ -131,9 +146,7 @@ export function errorInfoFromInvalidData(message: string): DataSourceStatusError }; } -/** - * Helper to create a {@link DataSourceStatusErrorInfo} for unknown errors. - */ +/** Builds {@link DataSourceStatusErrorInfo} when the error kind is not otherwise classifiable. */ export function errorInfoFromUnknown(message: string): DataSourceStatusErrorInfo { return { kind: DataSourceErrorKind.Unknown, diff --git a/packages/shared/sdk-client/src/datasource/fdv2/fallbackDirective.ts b/packages/shared/sdk-client/src/datasource/fdv2/fallbackDirective.ts new file mode 100644 index 0000000000..bca2464a27 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/fdv2/fallbackDirective.ts @@ -0,0 +1,73 @@ +/** + * The FDv1 fallback directive parsed from a connection's response headers. + * Its presence (`fdv1Fallback === true`) means the server asked the SDK to + * fall back to FDv1. + * + * `fdv1FallbackTtlMs` is how long to remain on FDv1 before retrying FDv2: + * - `undefined`: the server gave no TTL header (caller uses a 1-hour default). + * - `0`: indefinite fallback (no automatic recovery). + * - `> 0`: milliseconds to wait before attempting FDv2 recovery. + * + * This is the single place that interprets `x-ld-fd-fallback` and + * `x-ld-fd-fallback-ttl`, shared by the streaming and polling sources. + */ +export interface FallbackDirective { + fdv1Fallback: boolean; + fdv1FallbackTtlMs?: number; +} + +/** + * Reads the FDv1 fallback directive from response headers. Returns + * `{ fdv1Fallback: false }` when `x-ld-fd-fallback` is absent or not `"true"`. + * + * @param headers Header accessor. The `get` method must accept header names + * in any casing; both streaming and polling callers normalize to lowercase + * before calling this function. + */ +export function readFallbackDirective(headers: { + get(name: string): string | null; +}): FallbackDirective { + const fallback = headers.get('x-ld-fd-fallback'); + if (fallback === null || fallback.toLowerCase() !== 'true') { + return { fdv1Fallback: false }; + } + + const raw = headers.get('x-ld-fd-fallback-ttl'); + if (raw === null) { + return { fdv1Fallback: true }; + } + + const seconds = parseInt(raw, 10); + if (Number.isNaN(seconds)) { + return { fdv1Fallback: true }; + } + + // Clamp negative values to 0 (treated as indefinite, same as TTL=0). + // Prevents a malicious server from sending a large-negative TTL to trigger + // immediate recovery instead of the intended long wait. + return { fdv1Fallback: true, fdv1FallbackTtlMs: Math.max(0, seconds) * 1000 }; +} + +/** + * Reads the FDv1 fallback directive from an FDv2 `goodbye` event's data. + * + * SDKs that cannot read streaming response headers (e.g. browsers using the + * native `EventSource` API) receive the fallback directive in-band via the + * goodbye message's `protocolFallbackTTL` field. + * Presence of a finite numeric `protocolFallbackTTL` signals FDv1 fallback; + * the value carries the same semantics as the `x-ld-fd-fallback-ttl` header + * (`0` indicates indefinite fallback). A missing, non-numeric, or non-finite + * value is not a fallback signal and yields `{ fdv1Fallback: false }`. + * + * @param data The raw, parsed goodbye event data (typed `unknown` because the + * caller has not narrowed it). + */ +export function readGoodbyeFallbackDirective(data: unknown): FallbackDirective { + const rawTtl = (data as { protocolFallbackTTL?: unknown } | null | undefined) + ?.protocolFallbackTTL; + if (typeof rawTtl !== 'number' || !Number.isFinite(rawTtl)) { + return { fdv1Fallback: false }; + } + + return { fdv1Fallback: true, fdv1FallbackTtlMs: Math.max(0, rawTtl) * 1000 }; +}