diff --git a/packages/sdk/browser/__tests__/BrowserClient.mocks.ts b/packages/sdk/browser/__tests__/BrowserClient.mocks.ts index 08f0e66dff..460d409f18 100644 --- a/packages/sdk/browser/__tests__/BrowserClient.mocks.ts +++ b/packages/sdk/browser/__tests__/BrowserClient.mocks.ts @@ -15,7 +15,7 @@ function mockResponse(value: string, statusCode: number) { // @ts-ignore headers: { // @ts-ignore - get: jest.fn(), + get: jest.fn(() => null), // @ts-ignore keys: jest.fn(), // @ts-ignore @@ -61,6 +61,34 @@ export function makeRequests(): Requests { 200, )(); } + if (url.includes('/sdk/poll/eval')) { + return mockFetch( + JSON.stringify({ + events: [ + { + event: 'server-intent', + data: { + payloads: [{ id: 'mock', target: 1, intentCode: 'xfer-full', reason: 'mock' }], + }, + }, + { + event: 'put-object', + data: { + kind: 'flag-eval', + key: 'flagA', + version: 1, + object: { value: true, trackEvents: false }, + }, + }, + { + event: 'payload-transferred', + data: { state: 'mock-state', version: 1, id: 'mock' }, + }, + ], + }), + 200, + )(); + } return mockFetch('{ "flagA": true }', 200)(); }), // @ts-ignore diff --git a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts index 65ba95890b..c68987bd0e 100644 --- a/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/SourceFactoryProvider.test.ts @@ -339,7 +339,7 @@ it('ping handler uses the factory selector getter, not a stale reference', () => pingHandler.handlePing(); // The ping poll should use the fresh selector, not 'selector-v1' - expect(mockFdv2Poll).toHaveBeenCalledWith(expect.anything(), 'selector-v2', false, ctx.logger); + expect(mockFdv2Poll).toHaveBeenCalledWith(expect.anything(), 'selector-v2', ctx.logger); }); it('ping handler uses per-entry endpoint-overridden requestor', () => { @@ -361,5 +361,5 @@ it('ping handler uses per-entry endpoint-overridden requestor', () => { // The ping poll should use the overridden requestor, not ctx.requestor const overriddenRequestor = mockMakeFDv2Requestor.mock.results[0].value; - expect(mockFdv2Poll).toHaveBeenCalledWith(overriddenRequestor, undefined, false, ctx.logger); + expect(mockFdv2Poll).toHaveBeenCalledWith(overriddenRequestor, undefined, ctx.logger); }); diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts index 2a3ac8ae38..bed44e1eb5 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingBase.test.ts @@ -25,7 +25,7 @@ describe('given a successful FDv2 response', () => { body, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('changeSet'); if (result.type === 'changeSet') { @@ -48,7 +48,7 @@ describe('given a successful FDv2 response', () => { body, }); - await poll(requestor, 'my-basis', false, logger); + await poll(requestor, 'my-basis', logger); expect(requestor.poll).toHaveBeenCalledWith('my-basis'); }); @@ -62,7 +62,7 @@ describe('given a 304 Not Modified response', () => { body: null, }); - const result = await poll(requestor, 'some-basis', false, logger); + const result = await poll(requestor, 'some-basis', logger); expect(result.type).toBe('changeSet'); if (result.type === 'changeSet') { @@ -76,7 +76,7 @@ describe('given a network error', () => { it('returns interrupted for synchronizer mode', async () => { const requestor = makeErrorRequestor(new Error('connection reset')); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('status'); if (result.type === 'status') { @@ -85,18 +85,6 @@ describe('given a network error', () => { expect(result.errorInfo?.message).toBe('connection reset'); } }); - - it('returns terminal error for initializer mode', async () => { - const requestor = makeErrorRequestor(new Error('connection reset')); - - const result = await poll(requestor, undefined, true, logger); - - expect(result.type).toBe('status'); - if (result.type === 'status') { - expect(result.state).toBe('terminal_error'); - expect(result.errorInfo?.kind).toBe(DataSourceErrorKind.NetworkError); - } - }); }); describe('given an unrecoverable HTTP error', () => { @@ -107,7 +95,7 @@ describe('given an unrecoverable HTTP error', () => { body: null, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('status'); if (result.type === 'status') { @@ -123,7 +111,7 @@ describe('given an unrecoverable HTTP error', () => { body: null, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); if (result.type === 'status') { expect(result.state).toBe('terminal_error'); @@ -139,7 +127,7 @@ describe('given a recoverable HTTP error', () => { body: null, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('status'); if (result.type === 'status') { @@ -155,26 +143,12 @@ describe('given a recoverable HTTP error', () => { body: null, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); if (result.type === 'status') { expect(result.state).toBe('interrupted'); } }); - - it('returns terminal error for initializer mode on 500', async () => { - const requestor = makeRequestor({ - status: 500, - headers: makeHeaders(), - body: null, - }); - - const result = await poll(requestor, undefined, true, logger); - - if (result.type === 'status') { - expect(result.state).toBe('terminal_error'); - } - }); }); describe('given x-ld-fd-fallback header', () => { @@ -186,7 +160,7 @@ describe('given x-ld-fd-fallback header', () => { body, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.fdv1Fallback).toBe(true); }); @@ -199,7 +173,7 @@ describe('given x-ld-fd-fallback header', () => { body, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.fdv1Fallback).toBe(false); }); @@ -211,7 +185,7 @@ describe('given x-ld-fd-fallback header', () => { body: null, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.fdv1Fallback).toBe(true); }); @@ -226,7 +200,7 @@ describe('given x-ld-envid header', () => { body, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); if (result.type === 'changeSet') { expect(result.environmentId).toBe('env-abc-123'); @@ -240,7 +214,7 @@ describe('given x-ld-envid header', () => { body: null, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); if (result.type === 'changeSet') { expect(result.environmentId).toBe('env-abc-123'); @@ -256,28 +230,13 @@ describe('given malformed JSON response', () => { body: '{invalid json', }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); if (result.type === 'status') { expect(result.state).toBe('interrupted'); expect(result.errorInfo?.kind).toBe(DataSourceErrorKind.InvalidData); } }); - - it('returns terminal error for initializer mode', async () => { - const requestor = makeRequestor({ - status: 200, - headers: makeHeaders(), - body: '{invalid json', - }); - - const result = await poll(requestor, undefined, true, logger); - - if (result.type === 'status') { - expect(result.state).toBe('terminal_error'); - expect(result.errorInfo?.kind).toBe(DataSourceErrorKind.InvalidData); - } - }); }); describe('given valid JSON without an events array', () => { @@ -288,7 +247,7 @@ describe('given valid JSON without an events array', () => { body: '{"notEvents": true}', }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('status'); if (result.type === 'status') { @@ -297,22 +256,6 @@ describe('given valid JSON without an events array', () => { expect(result.errorInfo?.message).toContain('missing or invalid events array'); } }); - - it('returns terminal error for initializer mode', async () => { - const requestor = makeRequestor({ - status: 200, - headers: makeHeaders(), - body: '{"events": "not-an-array"}', - }); - - const result = await poll(requestor, undefined, true, logger); - - expect(result.type).toBe('status'); - if (result.type === 'status') { - expect(result.state).toBe('terminal_error'); - expect(result.errorInfo?.kind).toBe(DataSourceErrorKind.InvalidData); - } - }); }); describe('given an empty response body', () => { @@ -323,7 +266,7 @@ describe('given an empty response body', () => { body: null, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('status'); if (result.type === 'status') { @@ -352,7 +295,7 @@ describe('given a goodbye event in the response', () => { body, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('status'); if (result.type === 'status') { @@ -382,7 +325,7 @@ describe('given a server error event in the response', () => { body, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('status'); if (result.type === 'status') { @@ -408,7 +351,7 @@ describe('given a response with no payload-transferred event', () => { body, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('status'); if (result.type === 'status') { @@ -433,7 +376,7 @@ describe('given an intent with code none', () => { body, }); - const result = await poll(requestor, undefined, false, logger); + const result = await poll(requestor, undefined, logger); expect(result.type).toBe('changeSet'); if (result.type === 'changeSet') { @@ -473,7 +416,7 @@ describe('given a partial (changes) transfer', () => { body, }); - const result = await poll(requestor, 'old-state', false, logger); + const result = await poll(requestor, 'old-state', logger); expect(result.type).toBe('changeSet'); if (result.type === 'changeSet') { @@ -515,7 +458,7 @@ describe('given a delete-object event', () => { body, }); - const result = await poll(requestor, 'old-state', false, logger); + const result = await poll(requestor, 'old-state', logger); expect(result.type).toBe('changeSet'); if (result.type === 'changeSet') { diff --git a/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingInitializer.test.ts b/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingInitializer.test.ts index db22c0fdde..d53f29fb9c 100644 --- a/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingInitializer.test.ts +++ b/packages/shared/sdk-client/__tests__/datasource/fdv2/PollingInitializer.test.ts @@ -1,11 +1,19 @@ +import { sleep } from '@launchdarkly/js-sdk-common'; + import { FDv2PollResponse, FDv2Requestor } from '../../../src/datasource/fdv2/FDv2Requestor'; import { createPollingInitializer } from '../../../src/datasource/fdv2/PollingInitializer'; -import { makeHeaders, makeLogger, makeSuccessResponse } from './testHelpers'; +import { makeFDv2Body, makeHeaders, makeLogger, makeSuccessResponse } from './testHelpers'; + +jest.mock('@launchdarkly/js-sdk-common', () => ({ + ...jest.requireActual('@launchdarkly/js-sdk-common'), + sleep: jest.fn().mockResolvedValue(undefined), +})); const logger = makeLogger(); beforeEach(() => { jest.clearAllMocks(); + (sleep as jest.Mock).mockResolvedValue(undefined); }); it('returns a changeSet result on successful poll', async () => { @@ -21,6 +29,7 @@ it('returns a changeSet result on successful poll', async () => { expect(result.payload.type).toBe('full'); expect(result.payload.updates).toHaveLength(1); } + expect(requestor.poll).toHaveBeenCalledTimes(1); }); it('passes the selector from selectorGetter to the poll', async () => { @@ -61,7 +70,7 @@ it('returns shutdown when close is called before poll completes', async () => { resolveRequest(makeSuccessResponse({})); }); -it('returns terminal error on unrecoverable HTTP error', async () => { +it('returns terminal error on unrecoverable HTTP error without retrying', async () => { const requestor: FDv2Requestor = { poll: jest.fn().mockResolvedValue({ status: 401, @@ -77,9 +86,69 @@ it('returns terminal error on unrecoverable HTTP error', async () => { if (result.type === 'status') { expect(result.state).toBe('terminal_error'); } + expect(requestor.poll).toHaveBeenCalledTimes(1); +}); + +it('retries on recoverable HTTP error and succeeds', async () => { + const requestor: FDv2Requestor = { + poll: jest + .fn() + .mockResolvedValueOnce({ + status: 500, + headers: makeHeaders(), + body: null, + }) + .mockResolvedValueOnce(makeSuccessResponse({ flagA: { value: true } })), + }; + + const initializer = createPollingInitializer(requestor, logger, () => undefined); + const result = await initializer.run(); + + expect(result.type).toBe('changeSet'); + expect(requestor.poll).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + expect(sleep).toHaveBeenCalledWith(1000); }); -it('returns terminal error on network error (oneShot mode)', async () => { +it('retries on network error and succeeds', async () => { + const requestor: FDv2Requestor = { + poll: jest + .fn() + .mockRejectedValueOnce(new Error('network failure')) + .mockResolvedValueOnce(makeSuccessResponse({ flagA: { value: true } })), + }; + + const initializer = createPollingInitializer(requestor, logger, () => undefined); + const result = await initializer.run(); + + expect(result.type).toBe('changeSet'); + expect(requestor.poll).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); +}); + +it('exhausts retries on recoverable error then returns terminal error', async () => { + const requestor: FDv2Requestor = { + poll: jest.fn().mockResolvedValue({ + status: 500, + headers: makeHeaders(), + 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.errorInfo?.statusCode).toBe(500); + } + // 1 initial + 3 retries = 4 total + expect(requestor.poll).toHaveBeenCalledTimes(4); + expect(sleep).toHaveBeenCalledTimes(3); +}); + +it('exhausts retries on network error then returns terminal error', async () => { const requestor: FDv2Requestor = { poll: jest.fn().mockRejectedValue(new Error('network failure')), }; @@ -90,24 +159,79 @@ it('returns terminal error on network error (oneShot mode)', async () => { expect(result.type).toBe('status'); if (result.type === 'status') { expect(result.state).toBe('terminal_error'); + expect(result.errorInfo?.message).toBe('network failure'); } + expect(requestor.poll).toHaveBeenCalledTimes(4); }); -it('returns terminal error on recoverable HTTP error (oneShot mode)', async () => { +it('does not retry on goodbye result', async () => { + const body = makeFDv2Body([ + { + event: 'server-intent', + data: { + payloads: [{ id: 'test', target: 1, intentCode: 'xfer-full', reason: 'test' }], + }, + }, + { + event: 'goodbye', + data: { reason: 'server-shutdown', silent: false, catastrophe: false }, + }, + ]); const requestor: FDv2Requestor = { poll: jest.fn().mockResolvedValue({ - status: 500, + status: 200, headers: makeHeaders(), - body: null, + body, }), }; const initializer = createPollingInitializer(requestor, logger, () => undefined); const result = await initializer.run(); - // In oneShot mode, even recoverable errors are terminal expect(result.type).toBe('status'); if (result.type === 'status') { - expect(result.state).toBe('terminal_error'); + expect(result.state).toBe('goodbye'); + } + expect(requestor.poll).toHaveBeenCalledTimes(1); +}); + +it('returns shutdown when close is called during retry delay', async () => { + let sleepResolve!: () => void; + // sleepCalled resolves when the code enters sleep, proving the first poll failed + // and the retry delay has started — an observable effect, not a timing assumption. + let sleepCalledResolve!: () => void; + const sleepCalled = new Promise((resolve) => { + sleepCalledResolve = resolve; + }); + + (sleep as jest.Mock).mockImplementation( + () => + new Promise((resolve) => { + sleepResolve = resolve; + sleepCalledResolve(); + }), + ); + + const requestor: FDv2Requestor = { + poll: jest.fn().mockRejectedValue(new Error('network failure')), + }; + + const initializer = createPollingInitializer(requestor, logger, () => undefined); + const resultPromise = initializer.run(); + + // Wait until sleep is actually called (first poll failed, retry delay started) + await sleepCalled; + + // Close during the retry delay + initializer.close(); + + const result = await resultPromise; + + expect(result.type).toBe('status'); + if (result.type === 'status') { + expect(result.state).toBe('shutdown'); } + + // Clean up + sleepResolve(); }); diff --git a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts index 7a1b059617..94725e6aac 100644 --- a/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts +++ b/packages/shared/sdk-client/src/datasource/SourceFactoryProvider.ts @@ -103,7 +103,7 @@ function createPingHandler( logger: LDLogger, ): PingHandler { return { - handlePing: () => fdv2Poll(requestor, selectorGetter(), false, logger), + handlePing: () => fdv2Poll(requestor, selectorGetter(), logger), }; } diff --git a/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts b/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts index 5c0caed58f..6ef1dab816 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/PollingBase.ts @@ -33,7 +33,6 @@ function getEnvironmentId(headers: { get(name: string): string | null }): string */ function processEvents( events: internal.FDv2Event[], - oneShot: boolean, fdv1Fallback: boolean, environmentId: string | undefined, logger?: LDLogger, @@ -64,9 +63,7 @@ function processEvents( case 'serverError': { const errorInfo = errorInfoFromUnknown(action.reason); logger?.error(`Server error during polling: ${action.reason}`); - earlyResult = oneShot - ? terminalError(errorInfo, fdv1Fallback) - : interrupted(errorInfo, fdv1Fallback); + earlyResult = interrupted(errorInfo, fdv1Fallback); break; } case 'error': { @@ -74,9 +71,7 @@ function processEvents( if (action.kind === 'MISSING_PAYLOAD' || action.kind === 'PROTOCOL_ERROR') { const errorInfo = errorInfoFromInvalidData(action.message); logger?.warn(`Protocol error during polling: ${action.message}`); - earlyResult = oneShot - ? terminalError(errorInfo, fdv1Fallback) - : interrupted(errorInfo, fdv1Fallback); + earlyResult = interrupted(errorInfo, fdv1Fallback); } else { // Non-actionable errors (UNKNOWN_EVENT) are logged but don't stop processing logger?.warn(action.message); @@ -96,23 +91,21 @@ function processEvents( // Events didn't produce a result const errorInfo = errorInfoFromUnknown('Unexpected end of polling response'); logger?.error('Unexpected end of polling response'); - return oneShot ? terminalError(errorInfo, fdv1Fallback) : interrupted(errorInfo, fdv1Fallback); + return interrupted(errorInfo, fdv1Fallback); } /** * Performs a single FDv2 poll request, processes the protocol response, and * returns an {@link FDv2SourceResult}. * - * The `oneShot` parameter controls error handling: when true (initializer), - * all errors are terminal; when false (synchronizer), recoverable errors - * produce interrupted results. + * Recoverable errors produce interrupted results; unrecoverable HTTP errors + * produce terminal errors. * * @internal */ export async function poll( requestor: FDv2Requestor, basis: string | undefined, - oneShot: boolean, logger?: LDLogger, ): Promise { let fdv1Fallback = false; @@ -140,10 +133,6 @@ export async function poll( const errorInfo = errorInfoFromHttpError(response.status); logger?.error(`Polling request failed with HTTP error: ${response.status}`); - if (oneShot) { - return terminalError(errorInfo, fdv1Fallback); - } - const recoverable = response.status <= 0 || isHttpRecoverable(response.status); return recoverable ? interrupted(errorInfo, fdv1Fallback) @@ -154,9 +143,7 @@ export async function poll( if (!response.body) { const errorInfo = errorInfoFromInvalidData('Empty response body'); logger?.error('Polling request received empty response body'); - return oneShot - ? terminalError(errorInfo, fdv1Fallback) - : interrupted(errorInfo, fdv1Fallback); + return interrupted(errorInfo, fdv1Fallback); } let parsed: internal.FDv2EventsCollection; @@ -165,9 +152,7 @@ export async function poll( } catch { const errorInfo = errorInfoFromInvalidData('Malformed JSON data in polling response'); logger?.error('Polling request received malformed data'); - return oneShot - ? terminalError(errorInfo, fdv1Fallback) - : interrupted(errorInfo, fdv1Fallback); + return interrupted(errorInfo, fdv1Fallback); } if (!Array.isArray(parsed.events)) { @@ -175,17 +160,15 @@ export async function poll( 'Invalid polling response: missing or invalid events array', ); logger?.error('Polling response does not contain a valid events array'); - return oneShot - ? terminalError(errorInfo, fdv1Fallback) - : interrupted(errorInfo, fdv1Fallback); + return interrupted(errorInfo, fdv1Fallback); } - return processEvents(parsed.events, oneShot, fdv1Fallback, environmentId, logger); + return processEvents(parsed.events, fdv1Fallback, environmentId, logger); } catch (err: any) { // Network or other I/O error from the fetch itself const message = err?.message ?? String(err); logger?.error(`Polling request failed with network error: ${message}`); const errorInfo = errorInfoFromNetworkError(message); - return oneShot ? terminalError(errorInfo, fdv1Fallback) : interrupted(errorInfo, fdv1Fallback); + return interrupted(errorInfo, fdv1Fallback); } } diff --git a/packages/shared/sdk-client/src/datasource/fdv2/PollingInitializer.ts b/packages/shared/sdk-client/src/datasource/fdv2/PollingInitializer.ts index dd8e10780a..bddc56afa5 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/PollingInitializer.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/PollingInitializer.ts @@ -1,16 +1,23 @@ -import { LDLogger } from '@launchdarkly/js-sdk-common'; +import { LDLogger, sleep } from '@launchdarkly/js-sdk-common'; import { FDv2Requestor } from './FDv2Requestor'; -import { FDv2SourceResult, shutdown } from './FDv2SourceResult'; +import { FDv2SourceResult, shutdown, StatusResult, terminalError } from './FDv2SourceResult'; import { Initializer } from './Initializer'; import { poll } from './PollingBase'; +const SHUTDOWN = Symbol('shutdown'); + /** - * Creates a one-shot polling initializer that performs a single FDv2 poll - * request and returns the result. + * Creates a polling initializer that performs an FDv2 poll request with + * retry logic. Retries up to 3 times on recoverable errors with a 1-second + * delay between attempts. + * + * Unrecoverable errors (401, 403, etc.) are returned immediately as terminal + * errors. After exhausting retries on recoverable errors, the result is + * converted to a terminal error. * - * All errors are treated as terminal (oneShot=true). If `close()` is called - * before the poll completes, the result will be a shutdown status. + * If `close()` is called during a poll or retry delay, the result will be + * a shutdown status. * * @internal */ @@ -19,21 +26,57 @@ export function createPollingInitializer( logger: LDLogger | undefined, selectorGetter: () => string | undefined, ): Initializer { - let shutdownResolve: ((result: FDv2SourceResult) => void) | undefined; - const shutdownPromise = new Promise((resolve) => { + let shutdownResolve: ((value: typeof SHUTDOWN) => void) | undefined; + const shutdownPromise = new Promise((resolve) => { shutdownResolve = resolve; }); return { async run(): Promise { - const pollResult = poll(requestor, selectorGetter(), true, logger); + const maxRetries = 3; + const retryDelayMs = 1000; + const selector = selectorGetter(); + let lastResult: FDv2SourceResult | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + // eslint-disable-next-line no-await-in-loop + const result = await Promise.race([shutdownPromise, poll(requestor, selector, logger)]); + + if (result === SHUTDOWN) { + return shutdown(); + } + + if (result.type === 'changeSet') { + return result; + } + + // Non-retryable status (terminal_error, goodbye) -> return immediately + if (result.state !== 'interrupted') { + return result; + } + + // Recoverable error — save and potentially retry + lastResult = result; + + if (attempt < maxRetries) { + logger?.warn( + `Recoverable polling error (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${retryDelayMs}ms...`, + ); + // eslint-disable-next-line no-await-in-loop + const sleepResult = await Promise.race([shutdownPromise, sleep(retryDelayMs)]); + if (sleepResult === SHUTDOWN) { + return shutdown(); + } + } + } - // Race the poll against the shutdown signal - return Promise.race([shutdownPromise, pollResult]); + // Convert final interrupted -> terminal_error + const status = lastResult as StatusResult; + return terminalError(status.errorInfo!, status.fdv1Fallback); }, close(): void { - shutdownResolve?.(shutdown()); + shutdownResolve?.(SHUTDOWN); shutdownResolve = undefined; }, }; diff --git a/packages/shared/sdk-client/src/datasource/fdv2/PollingSynchronizer.ts b/packages/shared/sdk-client/src/datasource/fdv2/PollingSynchronizer.ts index a11dd7ed68..05bc97f84b 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/PollingSynchronizer.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/PollingSynchronizer.ts @@ -41,7 +41,7 @@ export function createPollingSynchronizer( const startTime = Date.now(); try { - const result = await poll(requestor, selectorGetter(), false, logger); + const result = await poll(requestor, selectorGetter(), logger); if (stopped) { return;