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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createFDv2RecoveryTimer } from '../../../src/datasource/fdv2/FDv2RecoveryTimer';

function wait(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}

it('has no pending promise before anything is scheduled', () => {
const timer = createFDv2RecoveryTimer();
expect(timer.promise).toBeUndefined();
timer.close();
});

it('resolves the pending promise once the scheduled ttl elapses', async () => {
const timer = createFDv2RecoveryTimer();
timer.schedule(10);
const pending = timer.promise;
expect(pending).toBeDefined();
await expect(pending).resolves.toBeUndefined();
timer.close();
});

it('keeps the resolved promise available until it is cleared', async () => {
const timer = createFDv2RecoveryTimer();
timer.schedule(1);
await timer.promise;

// A caller that was not waiting when the timer fired can still observe it.
expect(timer.promise).toBeDefined();
await timer.promise;

timer.clear();
expect(timer.promise).toBeUndefined();
});

it('remains usable for a new schedule after clear', async () => {
const timer = createFDv2RecoveryTimer();
timer.schedule(1);
await timer.promise;
timer.clear();

timer.schedule(5);
await expect(timer.promise).resolves.toBeUndefined();
timer.close();
});

it('replaces a pending schedule with the newest ttl', async () => {
const timer = createFDv2RecoveryTimer();
timer.schedule(60000);
const superseded = timer.promise;
timer.schedule(5);
expect(timer.promise).not.toBe(superseded);
await expect(timer.promise).resolves.toBeUndefined();
timer.close();
});

it('cancels a pending timer on clear', async () => {
const timer = createFDv2RecoveryTimer();
timer.schedule(5);
const captured = timer.promise;
timer.clear();
expect(timer.promise).toBeUndefined();

const outcome = await Promise.race([
captured!.then(() => 'fired' as const),
wait(30).then(() => 'timeout' as const),
]);
expect(outcome).toBe('timeout');
timer.close();
});

it('cancels a pending timer on close', async () => {
const timer = createFDv2RecoveryTimer();
timer.schedule(5);
const captured = timer.promise;
timer.close();
expect(timer.promise).toBeUndefined();

const outcome = await Promise.race([
captured!.then(() => 'fired' as const),
wait(30).then(() => 'timeout' as const),
]);
expect(outcome).toBe('timeout');
});

it('ignores schedule after close', () => {
const timer = createFDv2RecoveryTimer();
timer.close();
timer.schedule(5);
expect(timer.promise).toBeUndefined();
});

it('clears the underlying timeout on close', () => {
const timer = createFDv2RecoveryTimer();
timer.schedule(60000);
// @ts-ignore
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
timer.close();
expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});

it('close is idempotent', () => {
const timer = createFDv2RecoveryTimer();
timer.schedule(60000);
timer.close();
timer.close();
expect(timer.promise).toBeUndefined();
});
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ it('creates a goodbye status result with fdv1Fallback and a TTL', () => {
});
});

it('creates a goodbye status result with TTL 0 (indefinite fallback)', () => {
it('creates a goodbye status result carrying the TTL it was handed', () => {
const result = goodbye('server-shutdown', { fdv1Fallback: true, fdv1FallbackTtlMs: 0 });

expect(result).toEqual({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DataSourceErrorKind } from '@launchdarkly/js-sdk-common';

import { poll } from '../../../src/datasource/fdv2/PollingBase';
import { DEFAULT_FDV1_FALLBACK_TTL_MS } from '../../../src/datasource/fdv2/fallbackDirective';
import {
makeErrorRequestor,
makeFDv2Body,
Expand Down Expand Up @@ -500,7 +501,7 @@ it('reads TTL in seconds and converts to ms on a changeSet', async () => {
expect((result as any).fdv1FallbackTtlMs).toBe(60000);
});

it('treats TTL "0" as indefinite (0 ms)', async () => {
it('applies the default TTL for a TTL of "0"', async () => {
const body = makeFullPayloadBody({ flagA: { value: true } });
const requestor = makeRequestor({
status: 200,
Expand All @@ -511,10 +512,13 @@ it('treats TTL "0" as indefinite (0 ms)', async () => {
const result = await poll(requestor, undefined, logger);

expect(result.fdv1Fallback).toBe(true);
expect((result as any).fdv1FallbackTtlMs).toBe(0);
expect((result as any).fdv1FallbackTtlMs).toBeGreaterThanOrEqual(
DEFAULT_FDV1_FALLBACK_TTL_MS / 2,
);
expect((result as any).fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('leaves TTL undefined when the ttl header is absent but fallback is true', async () => {
it('applies the default TTL when the ttl header is absent but fallback is true', async () => {
const body = makeFullPayloadBody({ flagA: { value: true } });
const requestor = makeRequestor({
status: 200,
Expand All @@ -525,7 +529,10 @@ it('leaves TTL undefined when the ttl header is absent but fallback is true', as
const result = await poll(requestor, undefined, logger);

expect(result.fdv1Fallback).toBe(true);
expect((result as any).fdv1FallbackTtlMs).toBeUndefined();
expect((result as any).fdv1FallbackTtlMs).toBeGreaterThanOrEqual(
DEFAULT_FDV1_FALLBACK_TTL_MS / 2,
);
expect((result as any).fdv1FallbackTtlMs).toBeLessThanOrEqual(DEFAULT_FDV1_FALLBACK_TTL_MS);
});

it('stamps TTL on a non-success error response', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { sleep } from '@launchdarkly/js-sdk-common';

import { FDv2PollResponse, FDv2Requestor } from '../../../src/datasource/fdv2/FDv2Requestor';
import { createPollingInitializer } from '../../../src/datasource/fdv2/PollingInitializer';
import { DEFAULT_FDV1_FALLBACK_TTL_MS } from '../../../src/datasource/fdv2/fallbackDirective';
import { makeFDv2Body, makeHeaders, makeLogger, makeSuccessResponse } from './testHelpers';

jest.mock('@launchdarkly/js-sdk-common', () => ({
Expand Down Expand Up @@ -190,7 +191,8 @@ it('returns a terminal error immediately when a fallback directive accompanies a
if (result.type === 'status') {
expect(result.state).toBe('terminal_error');
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);
}
expect(requestor.poll).toHaveBeenCalledTimes(1);
expect(sleep).not.toHaveBeenCalled();
Expand Down
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/datasource/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);
}
});
Loading
Loading