Skip to content
2 changes: 1 addition & 1 deletion .github/workflows/sdk-client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ jobs:
target_file: 'packages/shared/sdk-client/dist/esm/index.mjs'
package_name: '@launchdarkly/js-client-sdk-common'
pr_number: ${{ github.event.number }}
size_limit: 39300
size_limit: 41500
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,15 @@ function raceTimeout<T>(promise: Promise<T>, ms: number): Promise<T | typeof DID
function makeInterrupted(): FDv2SourceResult {
return interrupted(
{ kind: DataSourceErrorKind.NetworkError, message: 'test error', time: Date.now() },
false,
{ fdv1Fallback: false },
);
}

function makeChangeSet(): FDv2SourceResult {
return changeSet({ version: 1, state: 'test-state', type: 'full', updates: [] }, false);
return changeSet(
{ version: 1, state: 'test-state', type: 'full', updates: [] },
{ fdv1Fallback: false },
);
}

// -- fallback condition --
Expand Down Expand Up @@ -101,7 +104,7 @@ it('fallback condition does not start timer on terminal error', async () => {
condition.inform(
terminalError(
{ kind: DataSourceErrorKind.ErrorResponse, message: 'unauthorized', time: Date.now() },
false,
{ fdv1Fallback: false },
),
);
expect(await raceTimeout(condition.promise, 50)).toBe(DID_NOT_RESOLVE);
Expand All @@ -117,14 +120,14 @@ it('fallback condition does not start timer on shutdown', async () => {

it('fallback condition does not start timer on goodbye', async () => {
const condition = createFallbackCondition(10);
condition.inform(goodbye('server-requested', false));
condition.inform(goodbye('server-requested', { fdv1Fallback: false }));
expect(await raceTimeout(condition.promise, 50)).toBe(DID_NOT_RESOLVE);
condition.close();
});

it('fallback condition changeSet without active timer does not cause issues', async () => {
const condition = createFallbackCondition(10);
// changeSet without a prior interrupted should be safe
// changeSet without a prior interrupted, should be safe
condition.inform(makeChangeSet());
expect(await raceTimeout(condition.promise, 50)).toBe(DID_NOT_RESOLVE);
condition.close();
Expand Down Expand Up @@ -165,7 +168,7 @@ it('recovery condition does not fire after close', async () => {
it('recovery condition close after timer fires does not cause error', async () => {
const condition = createRecoveryCondition(10);
expect(await condition.promise).toBe('recovery');
// Close after timer already fired should not throw
// Close after timer already fired, should not throw
condition.close();
});

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ it('creates a changeSet result with a payload', () => {
type: 'full' as const,
updates: [],
};
const result = changeSet(payload, false, 'env-123');
const result = changeSet(payload, { fdv1Fallback: false }, 'env-123');

expect(result.type).toBe('changeSet');
expect(result).toEqual({
Expand All @@ -33,7 +33,7 @@ it('creates a changeSet result with a payload', () => {

it('creates a changeSet result with fdv1Fallback flag', () => {
const payload = { version: 1, type: 'full' as const, updates: [] };
const result = changeSet(payload, true);
const result = changeSet(payload, { fdv1Fallback: true });

expect(result.type).toBe('changeSet');
if (result.type === 'changeSet') {
Expand All @@ -47,7 +47,7 @@ it('creates an interrupted status result', () => {
message: 'connection reset',
time: 1000,
};
const result = interrupted(errorInfo, false);
const result = interrupted(errorInfo, { fdv1Fallback: false });

expect(result).toEqual({
type: 'status',
Expand All @@ -74,7 +74,7 @@ it('creates a terminal error status result', () => {
statusCode: 401,
time: 2000,
};
const result = terminalError(errorInfo, true);
const result = terminalError(errorInfo, { fdv1Fallback: true });

expect(result).toEqual({
type: 'status',
Expand All @@ -85,7 +85,7 @@ it('creates a terminal error status result', () => {
});

it('creates a goodbye status result', () => {
const result = goodbye('server-shutdown', false);
const result = goodbye('server-shutdown', { fdv1Fallback: false });

expect(result).toEqual({
type: 'status',
Expand All @@ -96,7 +96,7 @@ it('creates a goodbye status result', () => {
});

it('creates a goodbye status result with fdv1Fallback and a TTL', () => {
const result = goodbye('server-shutdown', true, 5000);
const result = goodbye('server-shutdown', { fdv1Fallback: true, fdv1FallbackTtlMs: 5000 });

expect(result).toEqual({
type: 'status',
Expand All @@ -108,7 +108,7 @@ it('creates a goodbye status result with fdv1Fallback and a TTL', () => {
});

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

expect(result).toEqual({
type: 'status',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,23 @@ describe('given a goodbye event in the response', () => {
expect(result.reason).toBe('server-shutdown');
}
});

it('emits terminal_error when the response header signals fallback', async () => {
const body = makeFDv2Body([{ event: 'goodbye', data: { reason: 'bye' } }]);
const requestor = makeRequestor({
status: 200,
headers: makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '45' }),
body,
});

const result = await poll(requestor, undefined, logger);

expect(result.type).toBe('status');
if (result.type !== 'status') return;
expect(result.state).toBe('terminal_error');
expect(result.fdv1Fallback).toBe(true);
expect((result as any).fdv1FallbackTtlMs).toBe(45000);
});
});

describe('given a server error event in the response', () => {
Expand Down Expand Up @@ -468,3 +485,59 @@ describe('given a delete-object event', () => {
}
});
});

it('reads TTL in seconds and converts to ms on a changeSet', async () => {
const body = makeFullPayloadBody({ flagA: { value: true } });
const requestor = makeRequestor({
status: 200,
headers: makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '60' }),
body,
});

const result = await poll(requestor, undefined, logger);

expect(result.fdv1Fallback).toBe(true);
expect((result as any).fdv1FallbackTtlMs).toBe(60000);
});

it('treats TTL "0" as indefinite (0 ms)', async () => {
const body = makeFullPayloadBody({ flagA: { value: true } });
const requestor = makeRequestor({
status: 200,
headers: makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '0' }),
body,
});

const result = await poll(requestor, undefined, logger);

expect(result.fdv1Fallback).toBe(true);
expect((result as any).fdv1FallbackTtlMs).toBe(0);
});

it('leaves TTL undefined when the ttl header is absent but fallback is true', async () => {
const body = makeFullPayloadBody({ flagA: { value: true } });
const requestor = makeRequestor({
status: 200,
headers: makeHeaders({ 'x-ld-fd-fallback': 'true' }),
body,
});

const result = await poll(requestor, undefined, logger);

expect(result.fdv1Fallback).toBe(true);
expect((result as any).fdv1FallbackTtlMs).toBeUndefined();
});

it('stamps TTL on a non-success error response', async () => {
const requestor = makeRequestor({
status: 503,
headers: makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '30' }),
body: null,
});

const result = await poll(requestor, undefined, logger);

expect(result.fdv1Fallback).toBe(true);
expect((result as any).fdv1FallbackTtlMs).toBe(30000);
});

Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,27 @@ it('exhausts retries on recoverable error then returns terminal error', async ()
expect(sleep).toHaveBeenCalledTimes(3);
});

it('preserves the fallback directive and TTL when retries exhaust', async () => {
const requestor: FDv2Requestor = {
poll: jest.fn().mockResolvedValue({
status: 500,
headers: makeHeaders({ 'x-ld-fd-fallback': 'true', 'x-ld-fd-fallback-ttl': '300' }),
body: null,
}),
};

const initializer = createPollingInitializer(requestor, logger, () => undefined);
const result = await initializer.run();

expect(result.type).toBe('status');
if (result.type === 'status') {
expect(result.state).toBe('terminal_error');
expect(result.fdv1Fallback).toBe(true);
expect(result.fdv1FallbackTtlMs).toBe(300000);
}
expect(requestor.poll).toHaveBeenCalledTimes(4);
});

it('exhausts retries on network error then returns terminal error', async () => {
const requestor: FDv2Requestor = {
poll: jest.fn().mockRejectedValue(new Error('network failure')),
Expand Down
Loading
Loading