Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {
readFallbackDirective,
readGoodbyeFallbackDirective,
} from '../../../src/datasource/fdv2/fallbackDirective';

function makeHeaders(map: Record<string, string>): { get(name: string): string | null } {
const lower: Record<string, string> = {};
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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,84 +22,103 @@ 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';
state: SourceState;
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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading