From 83d5d767babb75c83dd42b546de28af742de3c4d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:22:03 -0700 Subject: [PATCH 1/3] feat: add retry logic to FDv2 polling initializer The polling initializer now retries up to 3 times on recoverable errors with a 1-second delay between attempts, matching the FDv1 behavior. Removes the oneShot parameter from poll() since the initializer was its only consumer and retry logic now handles error classification at the initializer level. --- .../datasource/SourceFactoryProvider.test.ts | 4 +- .../datasource/fdv2/PollingBase.test.ts | 101 +++---------- .../fdv2/PollingInitializer.test.ts | 140 +++++++++++++++++- .../src/datasource/SourceFactoryProvider.ts | 2 +- .../src/datasource/fdv2/PollingBase.ts | 37 ++--- .../src/datasource/fdv2/PollingInitializer.ts | 67 +++++++-- .../datasource/fdv2/PollingSynchronizer.ts | 2 +- 7 files changed, 223 insertions(+), 130 deletions(-) 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..c5039b6322 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; From 13c04b6e16c6e1689032d0bac12546e5015dcc6e Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:27:02 -0700 Subject: [PATCH 2/3] fix: replace unicode arrow with ASCII in comments --- .../sdk-client/src/datasource/fdv2/PollingInitializer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/sdk-client/src/datasource/fdv2/PollingInitializer.ts b/packages/shared/sdk-client/src/datasource/fdv2/PollingInitializer.ts index c5039b6322..bddc56afa5 100644 --- a/packages/shared/sdk-client/src/datasource/fdv2/PollingInitializer.ts +++ b/packages/shared/sdk-client/src/datasource/fdv2/PollingInitializer.ts @@ -50,7 +50,7 @@ export function createPollingInitializer( return result; } - // Non-retryable status (terminal_error, goodbye) → return immediately + // Non-retryable status (terminal_error, goodbye) -> return immediately if (result.state !== 'interrupted') { return result; } @@ -70,7 +70,7 @@ export function createPollingInitializer( } } - // Convert final interrupted → terminal_error + // Convert final interrupted -> terminal_error const status = lastResult as StatusResult; return terminalError(status.errorInfo!, status.fdv1Fallback); }, From 1dd41509f8ba1e2a938f9351114ba622dbf53c99 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:32:36 -0700 Subject: [PATCH 3/3] fix: update BrowserClient mock to return valid FDv2 responses The mock fetch headers.get() returned undefined instead of null, causing a TypeError in getFallback() that triggered retries. Also adds a valid FDv2 response for /sdk/poll/eval URLs. --- .../browser/__tests__/BrowserClient.mocks.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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