diff --git a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts index d5e05ba7cc..3fcbd7f695 100644 --- a/packages/sdk/browser/__tests__/BrowserDataManager.test.ts +++ b/packages/sdk/browser/__tests__/BrowserDataManager.test.ts @@ -205,19 +205,26 @@ describe('given a BrowserDataManager with mocked dependencies', () => { it('should load cached flags and continue to poll to complete identify', async () => { const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; - const identifyResolve = jest.fn(); - const identifyReject = jest.fn(); - flagManager.loadCached.mockResolvedValue(true); - await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + let identifyResolve: () => void; + let identifyReject: (err: Error) => void; + await new Promise((resolve) => { + identifyResolve = jest.fn().mockImplementation(() => { + resolve(); + }); + identifyReject = jest.fn(); + + // this is the function under test + dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + }); expect(logger.debug).toHaveBeenCalledWith( '[BrowserDataManager] Identify - Flags loaded from cache. Continuing to initialize via a poll.', ); expect(flagManager.loadCached).toHaveBeenCalledWith(context); - expect(identifyResolve).toHaveBeenCalled(); + expect(identifyResolve!).toHaveBeenCalled(); expect(flagManager.init).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ flagA: { flag: true, version: undefined } }), @@ -228,19 +235,25 @@ describe('given a BrowserDataManager with mocked dependencies', () => { it('should identify from polling when there are no cached flags', async () => { const context = Context.fromLDContext({ kind: 'user', key: 'test-user' }); const identifyOptions: LDIdentifyOptions = { waitForNetworkResults: false }; - const identifyResolve = jest.fn(); - const identifyReject = jest.fn(); - flagManager.loadCached.mockResolvedValue(false); + let identifyResolve: () => void; + let identifyReject: (err: Error) => void; + await new Promise((resolve) => { + identifyResolve = jest.fn().mockImplementation(() => { + resolve(); + }); + identifyReject = jest.fn(); - await dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + // this is the function under test + dataManager.identify(identifyResolve, identifyReject, context, identifyOptions); + }); expect(logger.debug).not.toHaveBeenCalledWith( 'Identify - Flags loaded from cache. Continuing to initialize via a poll.', ); expect(flagManager.loadCached).toHaveBeenCalledWith(context); - expect(identifyResolve).toHaveBeenCalled(); + expect(identifyResolve!).toHaveBeenCalled(); expect(flagManager.init).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ flagA: { flag: true, version: undefined } }), diff --git a/packages/sdk/browser/src/BrowserDataManager.ts b/packages/sdk/browser/src/BrowserDataManager.ts index f1d121baf6..2e535c1eee 100644 --- a/packages/sdk/browser/src/BrowserDataManager.ts +++ b/packages/sdk/browser/src/BrowserDataManager.ts @@ -2,7 +2,9 @@ import { BaseDataManager, Configuration, Context, + DataSourceErrorKind, DataSourcePaths, + DataSourceState, FlagManager, getPollingUri, internal, @@ -80,11 +82,24 @@ export default class BrowserDataManager extends BaseDataManager { // TODO: Handle wait for network results in a meaningful way. SDK-707 try { + this.dataSourceStatusManager.requestStateUpdate(DataSourceState.Initializing); const payload = await requestor.requestPayload(); - const listeners = this.createStreamListeners(context, identifyResolve); - const putListener = listeners.get('put'); - putListener!.processJson(putListener!.deserializeData(payload)); + try { + const listeners = this.createStreamListeners(context, identifyResolve); + const putListener = listeners.get('put'); + putListener!.processJson(putListener!.deserializeData(payload)); + } catch (e: any) { + this.dataSourceStatusManager.reportError( + DataSourceErrorKind.InvalidData, + e.message ?? 'Could not parse poll response', + ); + } } catch (e: any) { + this.dataSourceStatusManager.reportError( + DataSourceErrorKind.NetworkError, + e.message ?? 'unexpected network error', + e.status, + ); identifyReject(e); } diff --git a/packages/shared/common/src/datasource/DataSourceErrorKinds.ts b/packages/shared/common/src/datasource/DataSourceErrorKinds.ts new file mode 100644 index 0000000000..f06043e8c3 --- /dev/null +++ b/packages/shared/common/src/datasource/DataSourceErrorKinds.ts @@ -0,0 +1,15 @@ +export enum DataSourceErrorKind { + /// An unexpected error, such as an uncaught exception, further + /// described by the error message. + Unknown = 'UNKNOWN', + + /// An I/O error such as a dropped connection. + NetworkError = 'NETWORK_ERROR', + + /// The LaunchDarkly service returned an HTTP response with an error + /// status, available in the status code. + ErrorResponse = 'ERROR_RESPONSE', + + /// The SDK received malformed data from the LaunchDarkly service. + InvalidData = 'INVALID_DATA', +} diff --git a/packages/shared/common/src/datasource/errors.ts b/packages/shared/common/src/datasource/errors.ts new file mode 100644 index 0000000000..ef804f7f15 --- /dev/null +++ b/packages/shared/common/src/datasource/errors.ts @@ -0,0 +1,37 @@ +/* eslint-disable max-classes-per-file */ +import { DataSourceErrorKind } from './DataSourceErrorKinds'; + +export class LDFileDataSourceError extends Error { + constructor(message: string) { + super(message); + this.name = 'LaunchDarklyFileDataSourceError'; + } +} + +export class LDPollingError extends Error { + public readonly kind: DataSourceErrorKind; + public readonly status?: number; + public readonly recoverable: boolean; + + constructor(kind: DataSourceErrorKind, message: string, status?: number, recoverable = true) { + super(message); + this.kind = kind; + this.status = status; + this.name = 'LaunchDarklyPollingError'; + this.recoverable = recoverable; + } +} + +export class LDStreamingError extends Error { + public readonly kind: DataSourceErrorKind; + public readonly code?: number; + public readonly recoverable: boolean; + + constructor(kind: DataSourceErrorKind, message: string, code?: number, recoverable = true) { + super(message); + this.kind = kind; + this.code = code; + this.name = 'LaunchDarklyStreamingError'; + this.recoverable = recoverable; + } +} diff --git a/packages/shared/common/src/datasource/index.ts b/packages/shared/common/src/datasource/index.ts new file mode 100644 index 0000000000..f888015fbc --- /dev/null +++ b/packages/shared/common/src/datasource/index.ts @@ -0,0 +1,4 @@ +import { DataSourceErrorKind } from './DataSourceErrorKinds'; +import { LDFileDataSourceError, LDPollingError, LDStreamingError } from './errors'; + +export { DataSourceErrorKind, LDFileDataSourceError, LDPollingError, LDStreamingError }; diff --git a/packages/shared/common/src/errors.ts b/packages/shared/common/src/errors.ts index f85a2cc92d..bfc6726683 100644 --- a/packages/shared/common/src/errors.ts +++ b/packages/shared/common/src/errors.ts @@ -2,33 +2,6 @@ // more complex, then they could be independent files. /* eslint-disable max-classes-per-file */ -export class LDFileDataSourceError extends Error { - constructor(message: string) { - super(message); - this.name = 'LaunchDarklyFileDataSourceError'; - } -} - -export class LDPollingError extends Error { - public readonly status?: number; - - constructor(message: string, status?: number) { - super(message); - this.status = status; - this.name = 'LaunchDarklyPollingError'; - } -} - -export class LDStreamingError extends Error { - public readonly code?: number; - - constructor(message: string, code?: number) { - super(message); - this.code = code; - this.name = 'LaunchDarklyStreamingError'; - } -} - export class LDUnexpectedResponseError extends Error { constructor(message: string) { super(message); diff --git a/packages/shared/common/src/index.ts b/packages/shared/common/src/index.ts index 653cde18d1..2d23590f0d 100644 --- a/packages/shared/common/src/index.ts +++ b/packages/shared/common/src/index.ts @@ -1,6 +1,12 @@ import AttributeReference from './AttributeReference'; import Context from './Context'; import ContextFilter from './ContextFilter'; +import { + DataSourceErrorKind, + LDFileDataSourceError, + LDPollingError, + LDStreamingError, +} from './datasource'; export * from './api'; export * from './validators'; @@ -11,4 +17,12 @@ export * from './utils'; export * as internal from './internal'; export * from './errors'; -export { AttributeReference, Context, ContextFilter }; +export { + AttributeReference, + Context, + ContextFilter, + DataSourceErrorKind, + LDPollingError, + LDStreamingError, + LDFileDataSourceError, +}; diff --git a/packages/shared/common/src/internal/index.ts b/packages/shared/common/src/internal/index.ts index ae8a033629..eb29517863 100644 --- a/packages/shared/common/src/internal/index.ts +++ b/packages/shared/common/src/internal/index.ts @@ -1,5 +1,5 @@ +export * from './context'; export * from './diagnostics'; export * from './evaluation'; export * from './events'; export * from './stream'; -export * from './context'; diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts index 7defe4afab..56f738da59 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.test.ts @@ -2,7 +2,8 @@ import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mock import { EventName, Info, LDLogger, ProcessStreamResponse } from '../../api'; import { LDStreamProcessor } from '../../api/subsystem'; -import { LDStreamingError } from '../../errors'; +import { DataSourceErrorKind } from '../../datasource/DataSourceErrorKinds'; +import { LDStreamingError } from '../../datasource/errors'; import { defaultHeaders } from '../../utils'; import { DiagnosticsManager } from '../diagnostics'; import StreamingProcessor from './StreamingProcessor'; @@ -260,7 +261,7 @@ describe('given a stream processor with mock event source', () => { expect(willRetry).toBeFalsy(); expect(mockErrorHandler).toBeCalledWith( - new LDStreamingError(testError.message, testError.status), + new LDStreamingError(DataSourceErrorKind.Unknown, testError.message, testError.status), ); expect(logger.error).toBeCalledWith( expect.stringMatching(new RegExp(`${status}.*permanently`)), diff --git a/packages/shared/common/src/internal/stream/StreamingProcessor.ts b/packages/shared/common/src/internal/stream/StreamingProcessor.ts index b91e103a34..aad8b5269c 100644 --- a/packages/shared/common/src/internal/stream/StreamingProcessor.ts +++ b/packages/shared/common/src/internal/stream/StreamingProcessor.ts @@ -7,7 +7,8 @@ import { Requests, } from '../../api'; import { LDStreamProcessor } from '../../api/subsystem'; -import { LDStreamingError } from '../../errors'; +import { DataSourceErrorKind } from '../../datasource/DataSourceErrorKinds'; +import { LDStreamingError } from '../../datasource/errors'; import { ClientContext } from '../../options'; import { getStreamingUri } from '../../options/ServiceEndpoints'; import { httpErrorMessage, LDHeaders, shouldRetry } from '../../utils'; @@ -22,7 +23,9 @@ const reportJsonError = ( ) => { logger?.error(`Stream received invalid data in "${type}" message`); logger?.debug(`Invalid JSON follows: ${data}`); - errorHandler?.(new LDStreamingError('Malformed JSON data in event stream')); + errorHandler?.( + new LDStreamingError(DataSourceErrorKind.InvalidData, 'Malformed JSON data in event stream'), + ); }; // TODO: SDK-156 - Move to Server SDK specific location @@ -87,7 +90,9 @@ class StreamingProcessor implements LDStreamProcessor { private retryAndHandleError(err: HttpErrorResponse) { if (!shouldRetry(err)) { this.logConnectionResult(false); - this.errorHandler?.(new LDStreamingError(err.message, err.status)); + this.errorHandler?.( + new LDStreamingError(DataSourceErrorKind.ErrorResponse, err.message, err.status), + ); this.logger?.error(httpErrorMessage(err, 'streaming request')); return false; } @@ -142,7 +147,12 @@ class StreamingProcessor implements LDStreamProcessor { } processJson(dataJson); } else { - this.errorHandler?.(new LDStreamingError('Unexpected payload from event stream')); + this.errorHandler?.( + new LDStreamingError( + DataSourceErrorKind.Unknown, + 'Unexpected payload from event stream', + ), + ); } }); }); diff --git a/packages/shared/common/src/internal/stream/types.ts b/packages/shared/common/src/internal/stream/types.ts index a2c1c42d43..4b84650e69 100644 --- a/packages/shared/common/src/internal/stream/types.ts +++ b/packages/shared/common/src/internal/stream/types.ts @@ -1,3 +1,3 @@ -import { LDStreamingError } from '../../errors'; +import { LDStreamingError } from '../../datasource/errors'; export type StreamingErrorHandler = (err: LDStreamingError) => void; diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts index 186bbafb08..863dcb5c05 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts @@ -87,11 +87,8 @@ describe('sdk-client storage', () => { expect(mockPlatform.storage.get).toHaveBeenCalledWith(flagStorageKey); - // 'change' should not have been emitted - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith(1, 'change', context, defaultFlagKeys); - expect(emitter.emit).toHaveBeenNthCalledWith( - 2, + expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys); + expect(emitter.emit).toHaveBeenCalledWith( 'error', context, expect.objectContaining({ message: 'test-error' }), @@ -139,16 +136,12 @@ describe('sdk-client storage', () => { expect.stringMatching('LaunchDarkly_1234567890123456_1234567890123456'), ); - // 'change' should not have been emitted - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith( - 1, + expect(emitter.emit).toHaveBeenCalledWith( 'change', expect.objectContaining(toMulti(context)), defaultFlagKeys, ); - expect(emitter.emit).toHaveBeenNthCalledWith( - 2, + expect(emitter.emit).toHaveBeenCalledWith( 'error', expect.objectContaining(toMulti(context)), expect.objectContaining({ message: 'test-error' }), @@ -175,15 +168,17 @@ describe('sdk-client storage', () => { // @ts-ignore emitter = ldc.emitter; - jest.spyOn(emitter as LDEmitter, 'emit'); + const spy = jest.spyOn(emitter as LDEmitter, 'emit'); // expect emission await ldc.identify(context); + expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys); - // expit no emission + // clear the spy so we can tell if change was invoked again + spy.mockClear(); + // expect no emission await ldc.identify(context); - - expect(emitter.emit).toHaveBeenCalledTimes(1); + expect(emitter.emit).not.toHaveBeenCalledWith('change', context, defaultFlagKeys); }); test('no storage, cold start from streaming', async () => { @@ -256,8 +251,8 @@ describe('sdk-client storage', () => { JSON.stringify(putResponse), ); - expect(emitter.emit).toHaveBeenNthCalledWith(1, 'change', context, defaultFlagKeys); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); }); test('syncing storage when a flag is added', async () => { @@ -296,7 +291,7 @@ describe('sdk-client storage', () => { flagStorageKey, JSON.stringify(putResponse), ); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['another-dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['another-dev-test-flag']); }); test('syncing storage when a flag is updated', async () => { @@ -319,7 +314,7 @@ describe('sdk-client storage', () => { await jest.runAllTimersAsync(); expect(ldc.allFlags()).toMatchObject({ 'dev-test-flag': false }); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); }); test('syncing storage on multiple flag operations', async () => { @@ -347,7 +342,7 @@ describe('sdk-client storage', () => { expect(ldc.allFlags()).toMatchObject({ 'dev-test-flag': false, 'another-dev-test-flag': true }); expect(ldc.allFlags()).not.toHaveProperty('moonshot-demo'); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, [ + expect(emitter.emit).toHaveBeenCalledWith('change', context, [ 'moonshot-demo', 'dev-test-flag', 'another-dev-test-flag', @@ -380,8 +375,7 @@ describe('sdk-client storage', () => { ); // we expect one change from the local storage init, but no further change from the PUT - expect(emitter.emit).toHaveBeenCalledTimes(1); - expect(emitter.emit).toHaveBeenNthCalledWith(1, 'change', context, defaultFlagKeys); + expect(emitter.emit).toHaveBeenCalledWith('change', context, defaultFlagKeys); // this is defaultPutResponse expect(ldc.allFlags()).toEqual({ @@ -423,7 +417,7 @@ describe('sdk-client storage', () => { // both previous and current are true but inExperiment has changed // so a change event should be emitted - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); }); test('patch should emit change event', async () => { @@ -452,8 +446,7 @@ describe('sdk-client storage', () => { expect(ldc.allFlags()).toMatchObject({ 'dev-test-flag': false }); expect(mockPlatform.storage.set).toHaveBeenCalledTimes(4); expect(flagsInStorage['dev-test-flag'].version).toEqual(patchResponse.version); - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); }); test('patch should add new flags', async () => { @@ -484,8 +477,7 @@ describe('sdk-client storage', () => { expect.stringContaining(JSON.stringify(patchResponse)), ); expect(flagsInStorage).toHaveProperty('another-dev-test-flag'); - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['another-dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['another-dev-test-flag']); }); test('patch should ignore older version', async () => { @@ -557,8 +549,7 @@ describe('sdk-client storage', () => { expect.stringContaining('dev-test-flag'), ); expect(flagsInStorage['dev-test-flag']).toMatchObject({ ...deleteResponse, deleted: true }); - expect(emitter.emit).toHaveBeenCalledTimes(2); - expect(emitter.emit).toHaveBeenNthCalledWith(2, 'change', context, ['dev-test-flag']); + expect(emitter.emit).toHaveBeenCalledWith('change', context, ['dev-test-flag']); }); test('delete should not delete equal version', async () => { diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index 0f48149fc8..44b169e366 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -1,6 +1,7 @@ import { AutoEnvAttributes, clone, Hasher, LDContext } from '@launchdarkly/js-sdk-common'; import { createBasicPlatform, createLogger } from '@launchdarkly/private-js-mocks'; +import { DataSourceState } from '../src/datasource/DataSourceStatus'; import LDClientImpl from '../src/LDClientImpl'; import { Flags } from '../src/types'; import * as mockResponseJson from './evaluation/mockResponse.json'; @@ -33,6 +34,19 @@ describe('sdk-client object', () => { let mockPlatform: ReturnType; let logger: ReturnType; + function onDataSourceChangePromise(numToAwait: number) { + let countdown = numToAwait; + // eslint-disable-next-line no-new + return new Promise((res) => { + ldc.on('dataSourceStatus', () => { + countdown -= 1; + if (countdown === 0) { + res(); + } + }); + }); + } + beforeEach(() => { mockPlatform = createBasicPlatform(); logger = createLogger(); @@ -315,4 +329,80 @@ describe('sdk-client object', () => { expect(logger.debug).not.toHaveBeenCalledWith('Identify completing with cached flags'); }); + + test('data source status emits valid when successful initialization', async () => { + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); + + // need reference within test to run assertions against + const mockCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateEvents('put', [{ data: JSON.stringify(defaultPutResponse) }]); + return mockEventSource; + }); + mockPlatform.requests.createEventSource = mockCreateEventSource; + + const spyListener = jest.fn(); + ldc.on('dataSourceStatus', spyListener); + const changePromise = onDataSourceChangePromise(2); + await ldc.identify(carContext); + await changePromise; + + expect(spyListener).toHaveBeenCalledTimes(2); + expect(spyListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + state: DataSourceState.Initializing, + stateSince: expect.any(Number), + lastError: undefined, + }), + ); + expect(spyListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + state: DataSourceState.Valid, + stateSince: expect.any(Number), + lastError: undefined, + }), + ); + }); + + test('data source status emits closed when initialization encounters unrecoverable error', async () => { + const carContext: LDContext = { kind: 'car', key: 'test-car' }; + + mockPlatform.crypto.randomUUID.mockReturnValue('random1'); + + // need reference within test to run assertions against + const mockCreateEventSource = jest.fn((streamUri: string = '', options: any = {}) => { + mockEventSource = new MockEventSource(streamUri, options); + mockEventSource.simulateError({ status: 404, message: 'test-error' }); // unrecoverable error + return mockEventSource; + }); + mockPlatform.requests.createEventSource = mockCreateEventSource; + + const spyListener = jest.fn(); + ldc.on('dataSourceStatus', spyListener); + const changePromise = onDataSourceChangePromise(2); + await expect(ldc.identify(carContext)).rejects.toThrow('test-error'); + await changePromise; + + expect(spyListener).toHaveBeenCalledTimes(2); + expect(spyListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + state: DataSourceState.Initializing, + stateSince: expect.any(Number), + lastError: undefined, + }), + ); + expect(spyListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + state: DataSourceState.Closed, + stateSince: expect.any(Number), + lastError: expect.anything(), + }), + ); + }); }); diff --git a/packages/shared/sdk-client/__tests__/datasource/DataSourceStatusManager.test.ts b/packages/shared/sdk-client/__tests__/datasource/DataSourceStatusManager.test.ts new file mode 100644 index 0000000000..2383568f9f --- /dev/null +++ b/packages/shared/sdk-client/__tests__/datasource/DataSourceStatusManager.test.ts @@ -0,0 +1,115 @@ +import { DataSourceErrorKind } from '@launchdarkly/js-sdk-common'; + +import { DataSourceState } from '../../src/datasource/DataSourceStatus'; +import DataSourceStatusManager from '../../src/datasource/DataSourceStatusManager'; +import LDEmitter from '../../src/LDEmitter'; + +describe('DataSourceStatusManager', () => { + test('its first state is closed', async () => { + const underTest = new DataSourceStatusManager(new LDEmitter()); + expect(underTest.status.state).toEqual(DataSourceState.Closed); + }); + + test('it stays at initializing if receives recoverable error', async () => { + const underTest = new DataSourceStatusManager(new LDEmitter()); + underTest.requestStateUpdate(DataSourceState.Initializing); + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, true); + expect(underTest.status.state).toEqual(DataSourceState.Initializing); + }); + + test('it moves to closed if receives unrecoverable error', async () => { + const underTest = new DataSourceStatusManager(new LDEmitter()); + underTest.requestStateUpdate(DataSourceState.Initializing); + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, false); + expect(underTest.status.state).toEqual(DataSourceState.Closed); + }); + + test('it updates last error time with each error, but not stateSince', async () => { + let time = 0; + const stamper: () => number = () => time; + const underTest = new DataSourceStatusManager(new LDEmitter(), stamper); + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, true); + expect(underTest.status.stateSince).toEqual(0); + expect(underTest.status.lastError?.time).toEqual(0); + + time += 1; + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, true); + expect(underTest.status.stateSince).toEqual(0); + expect(underTest.status.lastError?.time).toEqual(1); + + time += 1; + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 404, true); + expect(underTest.status.stateSince).toEqual(0); + expect(underTest.status.lastError?.time).toEqual(2); + }); + + test('it updates stateSince when transitioning', async () => { + let time = 0; + const stamper: () => number = () => time; + + const underTest = new DataSourceStatusManager(new LDEmitter(), stamper); + expect(underTest.status.state).toEqual(DataSourceState.Closed); + expect(underTest.status.stateSince).toEqual(0); + + time += 1; + underTest.requestStateUpdate(DataSourceState.Valid); + expect(underTest.status.stateSince).toEqual(1); + + time += 1; + underTest.requestStateUpdate(DataSourceState.Closed); + expect(underTest.status.stateSince).toEqual(2); + }); + + test('it notifies listeners when state changes', async () => { + let time = 0; + const stamper: () => number = () => time; + const emitter = new LDEmitter(); + const spy = jest.spyOn(emitter, 'emit'); + const underTest = new DataSourceStatusManager(emitter, stamper); + + underTest.requestStateUpdate(DataSourceState.SetOffline); + time += 1; + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 400, true); + time += 1; + underTest.reportError(DataSourceErrorKind.ErrorResponse, 'womp', 400, true); + time += 1; + underTest.requestStateUpdate(DataSourceState.Closed); + expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenNthCalledWith( + 1, + 'dataSourceStatus', + expect.objectContaining({ + state: DataSourceState.SetOffline, + stateSince: 0, + lastError: undefined, + }), + ); + expect(spy).toHaveBeenNthCalledWith( + 2, + 'dataSourceStatus', + expect.objectContaining({ + state: DataSourceState.Interrupted, + stateSince: 1, + lastError: expect.anything(), + }), + ); + expect(spy).toHaveBeenNthCalledWith( + 3, + 'dataSourceStatus', + expect.objectContaining({ + state: DataSourceState.Interrupted, + stateSince: 1, // still in state interrupted + lastError: expect.anything(), + }), + ); + expect(spy).toHaveBeenNthCalledWith( + 4, + 'dataSourceStatus', + expect.objectContaining({ + state: DataSourceState.Closed, + stateSince: 3, + lastError: expect.anything(), + }), + ); + }); +}); diff --git a/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts b/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts index fcd4a96d6c..3ace5342b1 100644 --- a/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts +++ b/packages/shared/sdk-client/__tests__/streaming/StreamingProcessor.test.ts @@ -1,4 +1,5 @@ import { + DataSourceErrorKind, defaultHeaders, Encoding, EventName, @@ -327,7 +328,7 @@ describe('given a stream processor', () => { expect(willRetry).toBeFalsy(); expect(mockErrorHandler).toBeCalledWith( - new LDStreamingError(testError.message, testError.status), + new LDStreamingError(DataSourceErrorKind.Unknown, testError.message, testError.status), ); expect(logger.error).toBeCalledWith( expect.stringMatching(new RegExp(`${status}.*permanently`)), diff --git a/packages/shared/sdk-client/src/DataManager.ts b/packages/shared/sdk-client/src/DataManager.ts index 1fb0e9c1c2..252eb0b544 100644 --- a/packages/shared/sdk-client/src/DataManager.ts +++ b/packages/shared/sdk-client/src/DataManager.ts @@ -9,11 +9,14 @@ import { ProcessStreamResponse, subsystem, } from '@launchdarkly/js-sdk-common'; +import { LDStreamProcessor } from '@launchdarkly/js-sdk-common/dist/api/subsystem'; import { LDIdentifyOptions } from './api/LDIdentifyOptions'; import { Configuration } from './configuration/Configuration'; +import DataSourceEventHandler from './datasource/DataSourceEventHandler'; +import { DataSourceState } from './datasource/DataSourceStatus'; +import DataSourceStatusManager from './datasource/DataSourceStatusManager'; import { FlagManager } from './flag-manager/FlagManager'; -import { ItemDescriptor } from './flag-manager/ItemDescriptor'; import LDEmitter from './LDEmitter'; import PollingProcessor from './polling/PollingProcessor'; import { DataSourcePaths, StreamingProcessor } from './streaming'; @@ -59,6 +62,8 @@ export abstract class BaseDataManager implements DataManager { protected updateProcessor?: subsystem.LDStreamProcessor; protected readonly logger: LDLogger; protected context?: Context; + protected readonly dataSourceStatusManager: DataSourceStatusManager; + private readonly dataSourceEventHandler: DataSourceEventHandler; constructor( protected readonly platform: Platform, @@ -72,6 +77,12 @@ export abstract class BaseDataManager implements DataManager { protected readonly diagnosticsManager?: internal.DiagnosticsManager, ) { this.logger = config.logger; + this.dataSourceStatusManager = new DataSourceStatusManager(emitter); + this.dataSourceEventHandler = new DataSourceEventHandler( + flagManager, + this.dataSourceStatusManager, + this.config.logger, + ); } abstract identify( @@ -87,7 +98,7 @@ export abstract class BaseDataManager implements DataManager { identifyResolve?: () => void, identifyReject?: (err: Error) => void, ) { - this.updateProcessor = new PollingProcessor( + const processor = new PollingProcessor( JSON.stringify(context), { credential: this.credential, @@ -101,25 +112,20 @@ export abstract class BaseDataManager implements DataManager { this.platform.requests, this.platform.encoding!, async (flags) => { - this.logger.debug(`Handling polling result: ${Object.keys(flags)}`); - - // mapping flags to item descriptors - const descriptors = Object.entries(flags).reduce( - (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { - acc[key] = { version: flag.version, flag }; - return acc; - }, - {}, - ); - - await this.flagManager.init(checkedContext, descriptors); + await this.dataSourceEventHandler.handlePut(checkedContext, flags); identifyResolve?.(); }, (err) => { - identifyReject?.(err); this.emitter.emit('error', context, err); + this.dataSourceEventHandler.handlePollingError(err); + identifyReject?.(err); }, ); + + this.updateProcessor = this.decorateProcessorWithStatusReporting( + processor, + this.dataSourceStatusManager, + ); } protected createStreamingProcessor( @@ -128,7 +134,7 @@ export abstract class BaseDataManager implements DataManager { identifyResolve?: () => void, identifyReject?: (err: Error) => void, ) { - this.updateProcessor = new StreamingProcessor( + const processor = new StreamingProcessor( JSON.stringify(context), { credential: this.credential, @@ -144,10 +150,16 @@ export abstract class BaseDataManager implements DataManager { this.platform.encoding!, this.diagnosticsManager, (e) => { - identifyReject?.(e); this.emitter.emit('error', context, e); + this.dataSourceEventHandler.handleStreamingError(e); + identifyReject?.(e); }, ); + + this.updateProcessor = this.decorateProcessorWithStatusReporting( + processor, + this.dataSourceStatusManager, + ); } protected createStreamListeners( @@ -158,18 +170,8 @@ export abstract class BaseDataManager implements DataManager { listeners.set('put', { deserializeData: JSON.parse, - processJson: async (evalResults: Flags) => { - this.logger.debug(`Stream PUT: ${Object.keys(evalResults)}`); - - // mapping flags to item descriptors - const descriptors = Object.entries(evalResults).reduce( - (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { - acc[key] = { version: flag.version, flag }; - return acc; - }, - {}, - ); - await this.flagManager.init(context, descriptors); + processJson: async (flags: Flags) => { + await this.dataSourceEventHandler.handlePut(context, flags); identifyResolve?.(); }, }); @@ -177,35 +179,38 @@ export abstract class BaseDataManager implements DataManager { listeners.set('patch', { deserializeData: JSON.parse, processJson: async (patchFlag: PatchFlag) => { - this.logger.debug(`Stream PATCH ${JSON.stringify(patchFlag, null, 2)}`); - this.flagManager.upsert(context, patchFlag.key, { - version: patchFlag.version, - flag: patchFlag, - }); + this.dataSourceEventHandler.handlePatch(context, patchFlag); }, }); listeners.set('delete', { deserializeData: JSON.parse, processJson: async (deleteFlag: DeleteFlag) => { - this.logger.debug(`Stream DELETE ${JSON.stringify(deleteFlag, null, 2)}`); - - this.flagManager.upsert(context, deleteFlag.key, { - version: deleteFlag.version, - flag: { - ...deleteFlag, - deleted: true, - // props below are set to sensible defaults. they are irrelevant - // because this flag has been deleted. - flagVersion: 0, - value: undefined, - variation: 0, - trackEvents: false, - }, - }); + this.dataSourceEventHandler.handleDelete(context, deleteFlag); }, }); return listeners; } + + private decorateProcessorWithStatusReporting( + processor: LDStreamProcessor, + statusManager: DataSourceStatusManager, + ): LDStreamProcessor { + return { + start: () => { + // update status before starting processor to ensure potential errors are reported after initializing + statusManager.requestStateUpdate(DataSourceState.Initializing); + processor.start(); + }, + stop: () => { + processor.stop(); + statusManager.requestStateUpdate(DataSourceState.Closed); + }, + close: () => { + processor.close(); + statusManager.requestStateUpdate(DataSourceState.Closed); + }, + }; + } } diff --git a/packages/shared/sdk-client/src/LDEmitter.ts b/packages/shared/sdk-client/src/LDEmitter.ts index 3703ae3227..e07752cafc 100644 --- a/packages/shared/sdk-client/src/LDEmitter.ts +++ b/packages/shared/sdk-client/src/LDEmitter.ts @@ -1,6 +1,6 @@ import { LDLogger } from '@launchdarkly/js-sdk-common'; -export type EventName = 'error' | 'change'; +export type EventName = 'error' | 'change' | 'dataSourceStatus'; /** * Implementation Note: There should not be any default listeners for change events in a client diff --git a/packages/shared/sdk-client/src/api/LDClient.ts b/packages/shared/sdk-client/src/api/LDClient.ts index 710eef8140..a89253e087 100644 --- a/packages/shared/sdk-client/src/api/LDClient.ts +++ b/packages/shared/sdk-client/src/api/LDClient.ts @@ -214,6 +214,9 @@ export interface LDClient { * The callback parameters are the context and an Error object. Errors are also output by * the {@link logger} at the error level. * + * - `"dataSourceStatus"`: Event indicating that there has been a change in the status of the + * data source. This will include the state of the data source as well any error information. + * * @param key * The name of the event for which to listen. * @param callback diff --git a/packages/shared/sdk-client/src/datasource/DataSourceEventHandler.ts b/packages/shared/sdk-client/src/datasource/DataSourceEventHandler.ts new file mode 100644 index 0000000000..d590248b5d --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/DataSourceEventHandler.ts @@ -0,0 +1,64 @@ +import { Context, LDLogger, LDPollingError, LDStreamingError } from '@launchdarkly/js-sdk-common'; + +import { FlagManager } from '../flag-manager/FlagManager'; +import { ItemDescriptor } from '../flag-manager/ItemDescriptor'; +import { DeleteFlag, Flags, PatchFlag } from '../types'; +import { DataSourceState } from './DataSourceStatus'; +import DataSourceStatusManager from './DataSourceStatusManager'; + +export default class DataSourceEventHandler { + constructor( + private readonly flagManager: FlagManager, + private readonly statusManager: DataSourceStatusManager, + private readonly logger: LDLogger, + ) {} + + async handlePut(context: Context, flags: Flags) { + this.logger.debug(`Got PUT: ${Object.keys(flags)}`); + + // mapping flags to item descriptors + const descriptors = Object.entries(flags).reduce( + (acc: { [k: string]: ItemDescriptor }, [key, flag]) => { + acc[key] = { version: flag.version, flag }; + return acc; + }, + {}, + ); + await this.flagManager.init(context, descriptors); + this.statusManager.requestStateUpdate(DataSourceState.Valid); + } + + async handlePatch(context: Context, patchFlag: PatchFlag) { + this.logger.debug(`Got PATCH ${JSON.stringify(patchFlag, null, 2)}`); + this.flagManager.upsert(context, patchFlag.key, { + version: patchFlag.version, + flag: patchFlag, + }); + } + + async handleDelete(context: Context, deleteFlag: DeleteFlag) { + this.logger.debug(`Got DELETE ${JSON.stringify(deleteFlag, null, 2)}`); + + this.flagManager.upsert(context, deleteFlag.key, { + version: deleteFlag.version, + flag: { + ...deleteFlag, + deleted: true, + // props below are set to sensible defaults. they are irrelevant + // because this flag has been deleted. + flagVersion: 0, + value: undefined, + variation: 0, + trackEvents: false, + }, + }); + } + + handleStreamingError(error: LDStreamingError) { + this.statusManager.reportError(error.kind, error.message, error.code, error.recoverable); + } + + handlePollingError(error: LDPollingError) { + this.statusManager.reportError(error.kind, error.message, error.status, error.recoverable); + } +} diff --git a/packages/shared/sdk-client/src/datasource/DataSourceStatus.ts b/packages/shared/sdk-client/src/datasource/DataSourceStatus.ts new file mode 100644 index 0000000000..bb1fcf3087 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/DataSourceStatus.ts @@ -0,0 +1,44 @@ +import DataSourceStatusErrorInfo from './DataSourceStatusErrorInfo'; + +export enum DataSourceState { + Initializing = 'INITIALIZING', + Valid = 'VALID', + Interrupted = 'INTERRUPTED', + SetOffline = 'SET_OFFLINE', + Closed = 'CLOSED', + // TODO: SDK-702 - Implement network availability behaviors + // NetworkUnavailable, +} + +export default interface DataSourceStatus { + /** + * An enumerated value representing the overall current state of the data source. + */ + readonly state: DataSourceState; + + /** + * The UNIX epoch timestamp in milliseconds that the value of State most recently changed. + * + * The meaning of this depends on the current state: + * For {@link DataSourceState.Initializing}, it is the time that the datasource started + * attempting to retrieve data. + * + * For {@link DataSourceState.Valid}, it is the time that the data source most + * recently entered a valid state, after previously having been + * {@link DataSourceStatus.Initializing} or an invalid state such as + * {@link DataSourceState.Interrupted}. + * + * - For {@link DataSourceState.interrupted}, it is the time that the data source + * most recently entered an error state, after previously having been + * {@link DataSourceState.valid}. + * + * For {@link DataSourceState.Closed}, it is the time that the data source + * encountered an unrecoverable error or that the datasource was explicitly closed. + */ + readonly stateSince: number; + + /** + * The last error encountered. May be absent after application restart. + */ + readonly lastError?: DataSourceStatusErrorInfo; +} diff --git a/packages/shared/sdk-client/src/datasource/DataSourceStatusErrorInfo.ts b/packages/shared/sdk-client/src/datasource/DataSourceStatusErrorInfo.ts new file mode 100644 index 0000000000..102c9851a3 --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/DataSourceStatusErrorInfo.ts @@ -0,0 +1,19 @@ +import { DataSourceErrorKind } from '@launchdarkly/js-sdk-common'; + +/// A description of an error condition that the data source encountered. +export default interface DataSourceStatusErrorInfo { + /// An enumerated value representing the general category of the error. + readonly kind: DataSourceErrorKind; + + /// Any additional human-readable information relevant to the error. + /// + /// The format is subject to change and should not be relied on + /// programmatically. + readonly message: string; + + /// The UNIX epoch timestamp in milliseconds that the event occurred. + readonly time: number; + + /// The HTTP status code if the error was [ErrorKind.errorResponse]. + readonly statusCode?: number; +} diff --git a/packages/shared/sdk-client/src/datasource/DataSourceStatusManager.ts b/packages/shared/sdk-client/src/datasource/DataSourceStatusManager.ts new file mode 100644 index 0000000000..c5335c5e6d --- /dev/null +++ b/packages/shared/sdk-client/src/datasource/DataSourceStatusManager.ts @@ -0,0 +1,95 @@ +import { DataSourceErrorKind } from '@launchdarkly/js-sdk-common'; + +import LDEmitter from '../LDEmitter'; +import DataSourceStatus, { DataSourceState } from './DataSourceStatus'; +import DataSourceStatusErrorInfo from './DataSourceStatusErrorInfo'; + +/** + * Tracks the current data source status and emits updates when the status changes. + */ +export default class DataSourceStatusManager { + private state: DataSourceState; + private stateSinceMillis: number; // UNIX epoch timestamp in milliseconds + private errorInfo?: DataSourceStatusErrorInfo; + private timeStamper: () => number; + + constructor( + private readonly emitter: LDEmitter, + timeStamper: () => number = () => Date.now(), + ) { + this.state = DataSourceState.Closed; + this.stateSinceMillis = timeStamper(); + this.timeStamper = timeStamper; + } + + get status(): DataSourceStatus { + return { + state: this.state, + stateSince: this.stateSinceMillis, + lastError: this.errorInfo, + }; + } + + /** + * Updates the state of the manager. + * + * @param requestedState to track + * @param isError to indicate that the state update is a result of an error occurring. + */ + private updateState(requestedState: DataSourceState, isError = false) { + const newState = + requestedState === DataSourceState.Interrupted && this.state === DataSourceState.Initializing // don't go to interrupted from initializing (recoverable errors when initializing are not noteworthy) + ? DataSourceState.Initializing + : requestedState; + + const changedState = this.state !== newState; + if (changedState) { + this.state = newState; + this.stateSinceMillis = this.timeStamper(); + } + + if (changedState || isError) { + this.emitter.emit('dataSourceStatus', this.status); + } + } + + /** + * Requests the manager move to the provided state. This request may be ignored + * if the current state cannot transition to the requested state. + * @param state that is requested + */ + requestStateUpdate(state: DataSourceState) { + this.updateState(state); + } + + /** + * Reports a datasource error to this manager. Since the {@link DataSourceStatus} includes error + * information, it is possible that that a {@link DataSourceStatus} update is emitted with + * the same {@link DataSourceState}. + * + * @param kind of the error + * @param message for the error + * @param statusCode of the error if there was one + * @param recoverable to indicate that the error is anticipated to be recoverable + */ + reportError( + kind: DataSourceErrorKind, + message: string, + statusCode?: number, + recoverable: boolean = false, + ) { + const errorInfo: DataSourceStatusErrorInfo = { + kind, + message, + statusCode, + time: this.timeStamper(), + }; + this.errorInfo = errorInfo; + this.updateState(recoverable ? DataSourceState.Interrupted : DataSourceState.Closed, true); + } + + // TODO: SDK-702 - Implement network availability behaviors + // setNetworkUnavailable() { + // this.updateState(DataSourceState.NetworkUnavailable); + // } +} diff --git a/packages/shared/sdk-client/src/index.ts b/packages/shared/sdk-client/src/index.ts index 038221ba5d..842de68eb9 100644 --- a/packages/shared/sdk-client/src/index.ts +++ b/packages/shared/sdk-client/src/index.ts @@ -1,4 +1,6 @@ import { LDClientInternalOptions } from './configuration/Configuration'; +import DataSourceStatus, { DataSourceState } from './datasource/DataSourceStatus'; +import DataSourceStatusErrorInfo from './datasource/DataSourceStatusErrorInfo'; import LDClientImpl from './LDClientImpl'; import LDEmitter from './LDEmitter'; import Requestor from './polling/Requestor'; @@ -30,4 +32,10 @@ export { DataSourcePaths } from './streaming'; export { BaseDataManager } from './DataManager'; export { Requestor }; -export { LDClientImpl, LDClientInternalOptions }; +export { + DataSourceStatus, + DataSourceStatusErrorInfo, + LDClientImpl, + LDClientInternalOptions, + DataSourceState, +}; diff --git a/packages/shared/sdk-client/src/polling/PollingProcessor.ts b/packages/shared/sdk-client/src/polling/PollingProcessor.ts index 1e4f229dd3..fc25ec8b5f 100644 --- a/packages/shared/sdk-client/src/polling/PollingProcessor.ts +++ b/packages/shared/sdk-client/src/polling/PollingProcessor.ts @@ -1,4 +1,5 @@ import { + DataSourceErrorKind, Encoding, getPollingUri, httpErrorMessage, @@ -69,7 +70,12 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { const reportJsonError = (data: string) => { this.logger?.error('Polling received invalid data'); this.logger?.debug(`Invalid JSON follows: ${data}`); - this.errorHandler?.(new LDPollingError('Malformed JSON data in polling response')); + this.errorHandler?.( + new LDPollingError( + DataSourceErrorKind.InvalidData, + 'Malformed JSON data in polling response', + ), + ); }; this.logger?.debug('Polling LaunchDarkly for feature flag updates'); @@ -91,7 +97,13 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { if (requestError.status !== undefined) { if (!isHttpRecoverable(requestError.status)) { this.logger?.error(httpErrorMessage(err as HttpErrorResponse, 'polling request')); - this.errorHandler?.(new LDPollingError(requestError.message, requestError.status)); + this.errorHandler?.( + new LDPollingError( + DataSourceErrorKind.ErrorResponse, + requestError.message, + requestError.status, + ), + ); return; } } diff --git a/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts b/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts index 7c7a083a10..03b95637fc 100644 --- a/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts +++ b/packages/shared/sdk-client/src/streaming/StreamingProcessor.ts @@ -1,4 +1,5 @@ import { + DataSourceErrorKind, Encoding, EventName, EventSource, @@ -24,7 +25,9 @@ const reportJsonError = ( ) => { logger?.error(`Stream received invalid data in "${type}" message`); logger?.debug(`Invalid JSON follows: ${data}`); - errorHandler?.(new LDStreamingError('Malformed JSON data in event stream')); + errorHandler?.( + new LDStreamingError(DataSourceErrorKind.InvalidData, 'Malformed JSON data in event stream'), + ); }; class StreamingProcessor implements subsystem.LDStreamProcessor { @@ -94,7 +97,9 @@ class StreamingProcessor implements subsystem.LDStreamProcessor { private retryAndHandleError(err: HttpErrorResponse) { if (!shouldRetry(err)) { this.logConnectionResult(false); - this.errorHandler?.(new LDStreamingError(err.message, err.status)); + this.errorHandler?.( + new LDStreamingError(DataSourceErrorKind.ErrorResponse, err.message, err.status, false), + ); this.logger?.error(httpErrorMessage(err, 'streaming request')); return false; } @@ -162,7 +167,12 @@ class StreamingProcessor implements subsystem.LDStreamProcessor { } processJson(dataJson); } else { - this.errorHandler?.(new LDStreamingError('Unexpected payload from event stream')); + this.errorHandler?.( + new LDStreamingError( + DataSourceErrorKind.InvalidData, + 'Unexpected payload from event stream', + ), + ); } }); }); diff --git a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts index a51beee7c5..5014b8eed9 100644 --- a/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts +++ b/packages/shared/sdk-server/src/data_sources/PollingProcessor.ts @@ -1,4 +1,5 @@ import { + DataSourceErrorKind, httpErrorMessage, isHttpRecoverable, LDLogger, @@ -46,7 +47,12 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { const reportJsonError = (data: string) => { this.logger?.error('Polling received invalid data'); this.logger?.debug(`Invalid JSON follows: ${data}`); - this.errorHandler?.(new LDPollingError('Malformed JSON data in polling response')); + this.errorHandler?.( + new LDPollingError( + DataSourceErrorKind.InvalidData, + 'Malformed JSON data in polling response', + ), + ); }; const startTime = Date.now(); @@ -61,7 +67,9 @@ export default class PollingProcessor implements subsystem.LDStreamProcessor { if (status && !isHttpRecoverable(status)) { const message = httpErrorMessage(err, 'polling request'); this.logger?.error(message); - this.errorHandler?.(new LDPollingError(message, status)); + this.errorHandler?.( + new LDPollingError(DataSourceErrorKind.ErrorResponse, message, status), + ); // It is not recoverable, return and do not trigger another // poll. return; diff --git a/packages/shared/sdk-server/src/data_sources/Requestor.ts b/packages/shared/sdk-server/src/data_sources/Requestor.ts index de56a0729b..9c8aa5c104 100644 --- a/packages/shared/sdk-server/src/data_sources/Requestor.ts +++ b/packages/shared/sdk-server/src/data_sources/Requestor.ts @@ -1,7 +1,8 @@ import { + DataSourceErrorKind, getPollingUri, LDHeaders, - LDStreamingError, + LDPollingError, Options, Requests, Response, @@ -77,7 +78,11 @@ export default class Requestor implements LDFeatureRequestor { try { const { res, body } = await this.requestWithETagCache(this.uri, options); if (res.status !== 200 && res.status !== 304) { - const err = new LDStreamingError(`Unexpected status code: ${res.status}`, res.status); + const err = new LDPollingError( + DataSourceErrorKind.ErrorResponse, + `Unexpected status code: ${res.status}`, + res.status, + ); return cb(err, undefined); } return cb(undefined, res.status === 304 ? null : body);