diff --git a/packages/sdk/react-native/example/tsconfig.json b/packages/sdk/react-native/example/tsconfig.json index 28d5470b23..15f31e3b95 100644 --- a/packages/sdk/react-native/example/tsconfig.json +++ b/packages/sdk/react-native/example/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "jsx": "react", "strict": true, - "typeRoots": ["./types"], + "typeRoots": ["./types"] }, - "exclude": ["e2e"], + "exclude": ["e2e"] } diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts new file mode 100644 index 0000000000..bd2832629b --- /dev/null +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.test.ts @@ -0,0 +1,90 @@ +import { type EventName } from '@launchdarkly/js-client-sdk-common'; +import { logger } from '@launchdarkly/private-js-mocks'; + +import EventSource, { backoff, jitter } from './EventSource'; + +describe('EventSource', () => { + const uri = 'https://mock.events.uri'; + let eventSource: EventSource; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + beforeEach(() => { + jest + .spyOn(Math, 'random') + .mockImplementationOnce(() => 0.888) + .mockImplementationOnce(() => 0.999); + + eventSource = new EventSource(uri, { logger }); + eventSource.open = jest.fn(); + eventSource.onretrying = jest.fn(); + }); + + afterEach(() => { + // GOTCHA: Math.random must be reset separately because of a source-map type error + // https://medium.com/orchestrated/updating-react-to-version-17-471bfbe6bfcd + jest.spyOn(Math, 'random').mockRestore(); + + jest.resetAllMocks(); + }); + + test('backoff exponentially', () => { + const delay0 = backoff(1000, 0); + const delay1 = backoff(1000, 1); + const delay2 = backoff(1000, 2); + + expect(delay0).toEqual(1000); + expect(delay1).toEqual(2000); + expect(delay2).toEqual(4000); + }); + + test('backoff returns max delay', () => { + const delay = backoff(1000, 5); + expect(delay).toEqual(30000); + }); + + test('jitter', () => { + const delay0 = jitter(1000); + const delay1 = jitter(2000); + + expect(delay0).toEqual(556); + expect(delay1).toEqual(1001); + }); + + test('getNextRetryDelay', () => { + // @ts-ignore + const delay0 = eventSource.getNextRetryDelay(); + // @ts-ignore + const delay1 = eventSource.getNextRetryDelay(); + + // @ts-ignore + expect(eventSource.retryCount).toEqual(2); + expect(delay0).toEqual(556); + expect(delay1).toEqual(1001); + }); + + test('tryConnect force no delay', () => { + // @ts-ignore + eventSource.tryConnect(true); + jest.runAllTimers(); + + expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/new connection in 0 ms/i)); + expect(eventSource.onretrying).toHaveBeenCalledWith({ type: 'retry', delayMillis: 0 }); + expect(eventSource.open).toHaveBeenCalledTimes(2); + }); + + test('tryConnect with delay', () => { + // @ts-ignore + eventSource.tryConnect(); + jest.runAllTimers(); + + expect(logger.debug).toHaveBeenNthCalledWith( + 2, + expect.stringMatching(/new connection in 556 ms/i), + ); + expect(eventSource.onretrying).toHaveBeenCalledWith({ type: 'retry', delayMillis: 556 }); + expect(eventSource.open).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts index 92ba6944a3..06ac9e452d 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts @@ -12,16 +12,27 @@ const XMLReadyStateMap = ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DO const defaultOptions: EventSourceOptions = { body: undefined, - debug: false, headers: {}, method: 'GET', - pollingInterval: 5000, timeout: 0, - timeoutBeforeConnection: 0, withCredentials: false, retryAndHandleError: undefined, + initialRetryDelayMillis: 1000, + logger: undefined, }; +const maxRetryDelay = 30 * 1000; // Maximum retry delay 30 seconds. +const jitterRatio = 0.5; // Delay should be 50%-100% of calculated time. + +export function backoff(base: number, retryCount: number) { + const delay = base * Math.pow(2, retryCount); + return Math.min(delay, maxRetryDelay); +} + +export function jitter(computedDelayMillis: number) { + return computedDelayMillis - Math.trunc(Math.random() * jitterRatio * computedDelayMillis); +} + export default class EventSource { ERROR = -1; CONNECTING = 0; @@ -41,16 +52,16 @@ export default class EventSource { private method: string; private timeout: number; - private timeoutBeforeConnection: number; private withCredentials: boolean; private headers: Record; private body: any; - private debug: boolean; private url: string; private xhr: XMLHttpRequest = new XMLHttpRequest(); private pollTimer: any; - private pollingInterval: number; private retryAndHandleError?: (err: any) => boolean; + private initialRetryDelayMillis: number = 1000; + private retryCount: number = 0; + private logger?: any; constructor(url: string, options?: EventSourceOptions) { const opts = { @@ -61,25 +72,29 @@ export default class EventSource { this.url = url; this.method = opts.method!; this.timeout = opts.timeout!; - this.timeoutBeforeConnection = opts.timeoutBeforeConnection!; this.withCredentials = opts.withCredentials!; this.headers = opts.headers!; this.body = opts.body; - this.debug = opts.debug!; - this.pollingInterval = opts.pollingInterval!; this.retryAndHandleError = opts.retryAndHandleError; + this.initialRetryDelayMillis = opts.initialRetryDelayMillis!; + this.logger = opts.logger; - this.pollAgain(this.timeoutBeforeConnection, true); + this.tryConnect(true); } - private pollAgain(time: number, allowZero: boolean) { - if (time > 0 || allowZero) { - this.logDebug(`[EventSource] Will open new connection in ${time} ms.`); - this.dispatch('retry', { type: 'retry' }); - this.pollTimer = setTimeout(() => { - this.open(); - }, time); - } + private getNextRetryDelay() { + const delay = jitter(backoff(this.initialRetryDelayMillis, this.retryCount)); + this.retryCount += 1; + return delay; + } + + private tryConnect(forceNoDelay: boolean = false) { + let delay = forceNoDelay ? 0 : this.getNextRetryDelay(); + this.logger?.debug(`[EventSource] Will open new connection in ${delay} ms.`); + this.dispatch('retry', { type: 'retry', delayMillis: delay }); + this.pollTimer = setTimeout(() => { + this.open(); + }, delay); } open() { @@ -113,7 +128,7 @@ export default class EventSource { return; } - this.logDebug( + this.logger?.debug( `[EventSource][onreadystatechange] ReadyState: ${ XMLReadyStateMap[this.xhr.readyState] || 'Unknown' }(${this.xhr.readyState}), status: ${this.xhr.status}`, @@ -128,16 +143,18 @@ export default class EventSource { if (this.xhr.status >= 200 && this.xhr.status < 400) { if (this.status === this.CONNECTING) { + this.retryCount = 0; this.status = this.OPEN; this.dispatch('open', { type: 'open' }); - this.logDebug('[EventSource][onreadystatechange][OPEN] Connection opened.'); + this.logger?.debug('[EventSource][onreadystatechange][OPEN] Connection opened.'); } + // retry from server gets set here this.handleEvent(this.xhr.responseText || ''); if (this.xhr.readyState === XMLHttpRequest.DONE) { - this.logDebug('[EventSource][onreadystatechange][DONE] Operation done.'); - this.pollAgain(this.pollingInterval, false); + this.logger?.debug('[EventSource][onreadystatechange][DONE] Operation done.'); + this.tryConnect(); } } else if (this.xhr.status !== 0) { this.status = this.ERROR; @@ -149,20 +166,20 @@ export default class EventSource { }); if (this.xhr.readyState === XMLHttpRequest.DONE) { - this.logDebug('[EventSource][onreadystatechange][ERROR] Response status error.'); + this.logger?.debug('[EventSource][onreadystatechange][ERROR] Response status error.'); if (!this.retryAndHandleError) { - // default implementation - this.pollAgain(this.pollingInterval, false); + // by default just try and reconnect if there's an error. + this.tryConnect(); } else { - // custom retry logic + // custom retry logic taking into account status codes. const shouldRetry = this.retryAndHandleError({ status: this.xhr.status, message: this.xhr.responseText, }); if (shouldRetry) { - this.pollAgain(this.pollingInterval, true); + this.tryConnect(); } } } @@ -207,13 +224,6 @@ export default class EventSource { } } - private logDebug(...msg: string[]) { - if (this.debug) { - // eslint-disable-next-line no-console - console.debug(...msg); - } - } - private handleEvent(response: string) { const parts = response.slice(this.lastIndexProcessed).split('\n'); @@ -234,7 +244,8 @@ export default class EventSource { } else if (line.indexOf('retry') === 0) { retry = parseInt(line.replace(/retry:?\s*/, ''), 10); if (!Number.isNaN(retry)) { - this.pollingInterval = retry; + // GOTCHA: Ignore the server retry recommendation. Use our own custom getNextRetryDelay logic. + // this.pollingInterval = retry; } } else if (line.indexOf('data') === 0) { data.push(line.replace(/data:?\s*/, '')); @@ -307,7 +318,7 @@ export default class EventSource { this.onerror(data); break; case 'retry': - this.onretrying({ delayMillis: this.pollingInterval }); + this.onretrying(data); break; default: break; diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts index 1a417a7db1..3c828189b8 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts @@ -18,6 +18,7 @@ export interface CloseEvent { export interface RetryEvent { type: 'retry'; + delayMillis: number; } export interface TimeoutEvent { @@ -47,13 +48,12 @@ export interface ExceptionEvent { export interface EventSourceOptions { method?: string; timeout?: number; - timeoutBeforeConnection?: number; withCredentials?: boolean; headers?: Record; body?: any; - debug?: boolean; - pollingInterval?: number; retryAndHandleError?: (err: any) => boolean; + initialRetryDelayMillis?: number; + logger?: any; } type BuiltInEventMap = { diff --git a/packages/sdk/react-native/src/platform/index.ts b/packages/sdk/react-native/src/platform/index.ts index 6e9e7a81c4..7702f0aa12 100644 --- a/packages/sdk/react-native/src/platform/index.ts +++ b/packages/sdk/react-native/src/platform/index.ts @@ -23,10 +23,13 @@ import AsyncStorage from './ConditionalAsyncStorage'; import PlatformCrypto from './crypto'; class PlatformRequests implements Requests { + constructor(private readonly logger: LDLogger) {} + createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { return new RNEventSource(url, { headers: eventSourceInitDict.headers, retryAndHandleError: eventSourceInitDict.errorFilter, + logger: this.logger, }); } @@ -95,7 +98,7 @@ class PlatformStorage implements Storage { const createPlatform = (logger: LDLogger): Platform => ({ crypto: new PlatformCrypto(), info: new PlatformInfo(logger), - requests: new PlatformRequests(), + requests: new PlatformRequests(logger), encoding: new PlatformEncoding(), storage: new PlatformStorage(logger), });