diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index 1dfbaa18c..493fc39e1 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -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(); + }); }); diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index c25b3d6a4..466ed6a8e 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -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'; @@ -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 { + 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, @@ -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');