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
67 changes: 67 additions & 0 deletions packages/sdk/browser/__tests__/BrowserDataManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,4 +527,71 @@ describe('given a BrowserDataManager with mocked dependencies', () => {
'[BrowserDataManager] Identify called after data manager was closed.',
);
});

it('retries initial polling until it succeeds', async () => {
jest.useFakeTimers();
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
flagManager.loadCached.mockResolvedValue(false);

// Mock fetch to fail twice with 500 error, then succeed
let callCount = 0;
const mockedFetch = jest.fn().mockImplementation(() => {
callCount += 1;
if (callCount <= 2) {
return mockResponse('', 500);
}
return mockResponse('{"flagA": true}', 200);
});

platform.requests.fetch = mockedFetch as typeof platform.requests.fetch;

const identifyResolve = jest.fn();
const identifyReject = jest.fn();

const identifyPromise = dataManager.identify(identifyResolve, identifyReject, context);

// Fast-forward through the retry delays (2 retries * 1000ms each)
await jest.advanceTimersByTimeAsync(2000);

await identifyPromise;

expect(mockedFetch).toHaveBeenCalledTimes(3);
expect(identifyResolve).toHaveBeenCalled();
expect(identifyReject).not.toHaveBeenCalled();
expect(flagManager.init).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ flagA: { flag: true, version: undefined } }),
);

jest.useRealTimers();
});

it('throws an error when initial polling reaches max retry limit', async () => {
jest.useFakeTimers();
const context = Context.fromLDContext({ kind: 'user', key: 'test-user' });
flagManager.loadCached.mockResolvedValue(false);

// Mock fetch to always fail with 500 error
const mockedFetch = jest.fn().mockImplementation(() => mockResponse('', 500));

platform.requests.fetch = mockedFetch as typeof platform.requests.fetch;

const identifyResolve = jest.fn();
const identifyReject = jest.fn();

const identifyPromise = dataManager.identify(identifyResolve, identifyReject, context);

// Fast-forward through the retry delays (4 retries * 1000ms each)
await jest.advanceTimersByTimeAsync(4000);

await identifyPromise;

// Should attempt initial request + 3 retries = 4 total attempts
expect(mockedFetch).toHaveBeenCalledTimes(4);
expect(identifyResolve).not.toHaveBeenCalled();
expect(identifyReject).toHaveBeenCalled();
expect(identifyReject).toHaveBeenCalledWith(expect.objectContaining({ status: 500 }));

jest.useRealTimers();
});
});
68 changes: 54 additions & 14 deletions packages/sdk/browser/src/BrowserDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ import {
DataSourcePaths,
DataSourceState,
FlagManager,
httpErrorMessage,
internal,
LDEmitter,
LDHeaders,
LDIdentifyOptions,
makeRequestor,
Platform,
shouldRetry,
sleep,
} from '@launchdarkly/js-client-sdk-common';

import { readFlagsFromBootstrap } from './bootstrap';
Expand Down Expand Up @@ -102,6 +105,56 @@ export default class BrowserDataManager extends BaseDataManager {
this._updateStreamingState();
}

/**
* A helper function for the initial poll request. This is mainly here to facilitate
* the retry logic.
*
* @param context - LDContext to request payload for.
* @returns Payload as a string.
*/
private async _requestPayload(context: Context): Promise<string> {
const plainContextString = JSON.stringify(Context.toLDContext(context));
const pollingRequestor = makeRequestor(
plainContextString,
this.config.serviceEndpoints,
this.getPollingPaths(),
this.platform.requests,
this.platform.encoding!,
this.baseHeaders,
[],
this.config.withReasons,
this.config.useReport,
this._secureModeHash,
);

// NOTE: We are currently hardcoding in 3 retries for the initial
// poll. We can make this configurable in the future.
const maxRetries = 3;

let lastError: any;

for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
// eslint-disable-next-line no-await-in-loop
return await pollingRequestor.requestPayload();
} catch (e: any) {
if (!shouldRetry(e)) {
throw e;
}
lastError = e;
// NOTE: current we are hardcoding the retry interval to 1 second.
// We can make this configurable in the future.
if (attempt < maxRetries) {
this._debugLog(httpErrorMessage(e, 'initial poll request', 'will retry'));
// eslint-disable-next-line no-await-in-loop
await sleep(1000);
}
}
}

throw lastError;
}

private async _finishIdentifyFromPoll(
context: Context,
identifyResolve: () => void,
Expand All @@ -110,21 +163,8 @@ export default class BrowserDataManager extends BaseDataManager {
try {
this.dataSourceStatusManager.requestStateUpdate(DataSourceState.Initializing);

const plainContextString = JSON.stringify(Context.toLDContext(context));
const pollingRequestor = makeRequestor(
plainContextString,
this.config.serviceEndpoints,
this.getPollingPaths(),
this.platform.requests,
this.platform.encoding!,
this.baseHeaders,
[],
this.config.withReasons,
this.config.useReport,
this._secureModeHash,
);
const payload = await this._requestPayload(context);

const payload = await pollingRequestor.requestPayload();
try {
const listeners = this.createStreamListeners(context, identifyResolve);
const putListener = listeners.get('put');
Expand Down