Skip to content
Draft
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
@@ -1,6 +1,8 @@
import {
DEFAULT_FDV1_FALLBACK_TTL_MS,
readFallbackDirective,
readGoodbyeFallbackDirective,
resolveFallbackTtlMs,
} from '../../../src/internal/fdv2/fallbackDirective';

function makeHeaders(map: Record<string, string>): { get(name: string): string | null } {
Expand Down Expand Up @@ -29,10 +31,11 @@ it('matches "true" case-insensitively', () => {
expect(result.fdv1Fallback).toBe(true);
});

it('returns fdv1Fallback true with undefined TTL when x-ld-fd-fallback-ttl is absent', () => {
it('applies the jittered default 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();
expect(result.fdv1FallbackTtlMs).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(result.fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('converts a TTL of "60" seconds to 60000 ms', () => {
Expand All @@ -43,28 +46,46 @@ it('converts a TTL of "60" seconds to 60000 ms', () => {
expect(result.fdv1FallbackTtlMs).toBe(60000);
});

it('converts TTL "0" to 0 ms (indefinite fallback)', () => {
it('applies the default TTL for a TTL of "0"', () => {
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);
expect(result.fdv1FallbackTtlMs).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(result.fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('returns undefined TTL for a non-numeric x-ld-fd-fallback-ttl value', () => {
it('applies the default 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();
expect(result.fdv1FallbackTtlMs).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(result.fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('clamps negative TTL seconds to 0 ms (treated as indefinite)', () => {
it('applies the default TTL for a negative TTL', () => {
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);
expect(result.fdv1FallbackTtlMs).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(result.fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('accepts a TTL of exactly one hour', () => {
const result = readFallbackDirective(
makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '3600' }),
);
expect(result.fdv1FallbackTtlMs).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('applies the default TTL for a TTL longer than one hour', () => {
const result = readFallbackDirective(
makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '7200' }),
);
expect(result.fdv1FallbackTtlMs).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(result.fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('header lookup is case-insensitive', () => {
Expand Down Expand Up @@ -101,16 +122,42 @@ it('readGoodbyeFallbackDirective: converts a protocolFallbackTTL of 60 seconds t
expect(result.fdv1FallbackTtlMs).toBe(60000);
});

it('readGoodbyeFallbackDirective: converts protocolFallbackTTL 0 to 0 ms (indefinite fallback)', () => {
it('readGoodbyeFallbackDirective: applies the default TTL for a protocolFallbackTTL of 0', () => {
const result = readGoodbyeFallbackDirective({ reason: 'falling back', protocolFallbackTTL: 0 });
expect(result.fdv1Fallback).toBe(true);
expect(result.fdv1FallbackTtlMs).toBe(0);
expect(result.fdv1FallbackTtlMs).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(result.fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('readGoodbyeFallbackDirective: clamps negative protocolFallbackTTL to 0 ms', () => {
it('readGoodbyeFallbackDirective: applies the default TTL for a negative protocolFallbackTTL', () => {
const result = readGoodbyeFallbackDirective({ reason: 'falling back', protocolFallbackTTL: -5 });
expect(result.fdv1Fallback).toBe(true);
expect(result.fdv1FallbackTtlMs).toBe(0);
expect(result.fdv1FallbackTtlMs).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(result.fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('readGoodbyeFallbackDirective: accepts a protocolFallbackTTL of exactly one hour', () => {
const result = readGoodbyeFallbackDirective({
reason: 'falling back',
protocolFallbackTTL: 3600,
});
expect(result.fdv1FallbackTtlMs).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('readGoodbyeFallbackDirective: applies the default TTL for a protocolFallbackTTL over one hour', () => {
const result = readGoodbyeFallbackDirective({
reason: 'falling back',
protocolFallbackTTL: 7200,
});
expect(result.fdv1FallbackTtlMs).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(result.fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('readGoodbyeFallbackDirective: truncates a fractional protocolFallbackTTL to whole seconds before the range check', () => {
const result = readGoodbyeFallbackDirective({ reason: 'falling back', protocolFallbackTTL: 0.001 });
expect(result.fdv1Fallback).toBe(true);
expect(result.fdv1FallbackTtlMs).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(result.fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('readGoodbyeFallbackDirective: returns fdv1Fallback false for a non-numeric protocolFallbackTTL', () => {
Expand All @@ -130,3 +177,44 @@ it('readGoodbyeFallbackDirective: returns fdv1Fallback false for a non-finite pr
expect(result.fdv1Fallback).toBe(false);
expect(result.fdv1FallbackTtlMs).toBeUndefined();
});

it('resolveFallbackTtlMs: converts a whole number of seconds to milliseconds without jitter', () => {
expect(resolveFallbackTtlMs(60, () => 1)).toBe(60000);
});

it('resolveFallbackTtlMs: accepts a TTL of exactly one hour unchanged', () => {
expect(resolveFallbackTtlMs(3600, () => 1)).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('resolveFallbackTtlMs: uses the default for a TTL greater than one hour', () => {
expect(resolveFallbackTtlMs(3601, () => 0)).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('resolveFallbackTtlMs: uses the default for a TTL of zero', () => {
expect(resolveFallbackTtlMs(0, () => 0)).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('resolveFallbackTtlMs: uses the default for a negative TTL', () => {
expect(resolveFallbackTtlMs(-5, () => 0)).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('resolveFallbackTtlMs: uses the default for an absent TTL', () => {
expect(resolveFallbackTtlMs(undefined, () => 0)).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('resolveFallbackTtlMs: uses the default for a TTL that is not a number', () => {
expect(resolveFallbackTtlMs(NaN, () => 0)).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('resolveFallbackTtlMs: subtracts jitter of up to half the default TTL', () => {
expect(resolveFallbackTtlMs(undefined, () => 0.5)).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS * 0.75);
expect(resolveFallbackTtlMs(undefined, () => 1)).toBe(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
});

it('resolveFallbackTtlMs: defaults to Math.random for jitter and stays within bounds', () => {
for (let i = 0; i < 50; i += 1) {
const ttl = resolveFallbackTtlMs(undefined);
expect(ttl).toBeGreaterThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS / 2);
expect(ttl).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
}
});
90 changes: 63 additions & 27 deletions packages/shared/common/src/internal/fdv2/fallbackDirective.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,58 @@
/**
* 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.
* Default time to remain on FDv1 after a fallback directive that carried no
* usable TTL: 1 hour. This is also the upper bound of the range the server may
* ask for; anything larger is replaced with this default.
*/
export const DEFAULT_FDV1_FALLBACK_TTL_MS = 60 * 60 * 1000;

/** Jitter is subtracted from the default TTL, up to half of it. */
const DEFAULT_TTL_JITTER_RATIO = 0.5;

/**
* Normalizes a fallback TTL expressed in whole seconds into milliseconds.
*
* A TTL is only honored when it falls in the range `(0, 1 hour]`. An absent,
* unparseable, zero, negative, or too-large TTL is replaced with the default
* of 1 hour, minus a jitter value drawn uniformly from `[0, half the default]`
* so that a fleet of SDKs that fell back together does not retry FDv2 in
* lockstep. A TTL supplied by the server is already jittered by the server, so
* it is used exactly as given. Fallback is therefore never indefinite.
*
* @param ttlSeconds The TTL carried by the directive, in seconds, or
* `undefined` when the directive carried none.
* @param random Source of randomness for the jitter. Injectable for tests.
*/
export function resolveFallbackTtlMs(
ttlSeconds: number | undefined,
random: () => number = Math.random,
): number {
const ttlMs = ttlSeconds === undefined ? undefined : ttlSeconds * 1000;
if (
ttlMs === undefined ||
!Number.isFinite(ttlMs) ||
ttlMs <= 0 ||
ttlMs > DEFAULT_FDV1_FALLBACK_TTL_MS
) {
return (
DEFAULT_FDV1_FALLBACK_TTL_MS -
Math.trunc(random() * DEFAULT_TTL_JITTER_RATIO * DEFAULT_FDV1_FALLBACK_TTL_MS)
);
}
return ttlMs;
}

/**
* The FDv1 fallback directive parsed from a connection's response headers or
* from a `goodbye` message. 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.
* `fdv1FallbackTtlMs` is how long to remain on FDv1 before retrying FDv2. It is
* always set when `fdv1Fallback` is true: a missing, unparseable, or
* out-of-range TTL is replaced with the jittered default, so fallback is never
* indefinite.
*
* This is the single place that interprets `x-ld-fd-fallback` and
* `x-ld-fd-fallback-ttl`.
* This is the single place that interprets `x-ld-fd-fallback`,
* `x-ld-fd-fallback-ttl`, and a goodbye message's `protocolFallbackTTL`.
*/
export interface FallbackDirective {
fdv1Fallback: boolean;
Expand All @@ -33,31 +76,24 @@ export function readFallbackDirective(headers: {
}

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 };
}
const seconds = raw === null ? undefined : parseInt(raw, 10);

// 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 };
// A missing, unparseable, or out-of-range TTL becomes the jittered default,
// so the directive always carries a concrete deadline for retrying FDv2.
return { fdv1Fallback: true, fdv1FallbackTtlMs: resolveFallbackTtlMs(seconds) };
}

/**
* 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 }`.
* 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, including the replacement of
* an out-of-range value with the jittered default. 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).
Expand All @@ -69,5 +105,5 @@ export function readGoodbyeFallbackDirective(data: unknown): FallbackDirective {
return { fdv1Fallback: false };
}

return { fdv1Fallback: true, fdv1FallbackTtlMs: Math.max(0, rawTtl) * 1000 };
return { fdv1Fallback: true, fdv1FallbackTtlMs: resolveFallbackTtlMs(Math.trunc(rawTtl)) };
}
9 changes: 8 additions & 1 deletion packages/shared/common/src/internal/fdv2/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { fdv1PayloadAdaptor as FDv1PayloadAdaptor } from './FDv1PayloadAdaptor';
import { readFallbackDirective, readGoodbyeFallbackDirective } from './fallbackDirective';
import {
DEFAULT_FDV1_FALLBACK_TTL_MS,
readFallbackDirective,
readGoodbyeFallbackDirective,
resolveFallbackTtlMs,
} from './fallbackDirective';
import type { FallbackDirective } from './fallbackDirective';
import { PayloadProcessor } from './payloadProcessor';
import { PayloadStreamReader } from './payloadStreamReader';
Expand All @@ -19,11 +24,13 @@ import type {

export {
createProtocolHandler,
DEFAULT_FDV1_FALLBACK_TTL_MS,
FDv1PayloadAdaptor,
PayloadProcessor,
PayloadStreamReader,
readFallbackDirective,
readGoodbyeFallbackDirective,
resolveFallbackTtlMs,
};

export type {
Expand Down
Loading
Loading