Skip to content

Commit

Permalink
fix: Implement RN SDK EventSource jitter backoff. (#359)
Browse files Browse the repository at this point in the history
This PR adds jitter and backoff logic to the RNEventSource.

![Screenshot 2024-01-30 at 2 52
01 PM](https://github.com/launchdarkly/js-core/assets/1593077/5f5e861f-ceb9-4b0c-8341-a7893f7c0a33)
  • Loading branch information
yusinto authored Feb 1, 2024
1 parent deea99c commit 95e58bd
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 42 deletions.
4 changes: 2 additions & 2 deletions packages/sdk/react-native/example/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"compilerOptions": {
"jsx": "react",
"strict": true,
"typeRoots": ["./types"],
"typeRoots": ["./types"]
},
"exclude": ["e2e"],
"exclude": ["e2e"]
}
Original file line number Diff line number Diff line change
@@ -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<EventName>;

beforeAll(() => {
jest.useFakeTimers();
});

beforeEach(() => {
jest
.spyOn(Math, 'random')
.mockImplementationOnce(() => 0.888)
.mockImplementationOnce(() => 0.999);

eventSource = new EventSource<EventName>(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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<E extends string = never> {
ERROR = -1;
CONNECTING = 0;
Expand All @@ -41,16 +52,16 @@ export default class EventSource<E extends string = never> {

private method: string;
private timeout: number;
private timeoutBeforeConnection: number;
private withCredentials: boolean;
private headers: Record<string, any>;
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 = {
Expand All @@ -61,25 +72,29 @@ export default class EventSource<E extends string = never> {
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() {
Expand Down Expand Up @@ -113,7 +128,7 @@ export default class EventSource<E extends string = never> {
return;
}

this.logDebug(
this.logger?.debug(
`[EventSource][onreadystatechange] ReadyState: ${
XMLReadyStateMap[this.xhr.readyState] || 'Unknown'
}(${this.xhr.readyState}), status: ${this.xhr.status}`,
Expand All @@ -128,16 +143,18 @@ export default class EventSource<E extends string = never> {

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;
Expand All @@ -149,20 +166,20 @@ export default class EventSource<E extends string = never> {
});

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();
}
}
}
Expand Down Expand Up @@ -207,13 +224,6 @@ export default class EventSource<E extends string = never> {
}
}

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');

Expand All @@ -234,7 +244,8 @@ export default class EventSource<E extends string = never> {
} 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*/, ''));
Expand Down Expand Up @@ -307,7 +318,7 @@ export default class EventSource<E extends string = never> {
this.onerror(data);
break;
case 'retry':
this.onretrying({ delayMillis: this.pollingInterval });
this.onretrying(data);
break;
default:
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface CloseEvent {

export interface RetryEvent {
type: 'retry';
delayMillis: number;
}

export interface TimeoutEvent {
Expand Down Expand Up @@ -47,13 +48,12 @@ export interface ExceptionEvent {
export interface EventSourceOptions {
method?: string;
timeout?: number;
timeoutBeforeConnection?: number;
withCredentials?: boolean;
headers?: Record<string, any>;
body?: any;
debug?: boolean;
pollingInterval?: number;
retryAndHandleError?: (err: any) => boolean;
initialRetryDelayMillis?: number;
logger?: any;
}

type BuiltInEventMap = {
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk/react-native/src/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventName>(url, {
headers: eventSourceInitDict.headers,
retryAndHandleError: eventSourceInitDict.errorFilter,
logger: this.logger,
});
}

Expand Down Expand Up @@ -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),
});
Expand Down

0 comments on commit 95e58bd

Please sign in to comment.