Skip to content

Commit 95e58bd

Browse files
authored
fix: Implement RN SDK EventSource jitter backoff. (#359)
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)
1 parent deea99c commit 95e58bd

File tree

5 files changed

+146
-42
lines changed

5 files changed

+146
-42
lines changed

packages/sdk/react-native/example/tsconfig.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"compilerOptions": {
44
"jsx": "react",
55
"strict": true,
6-
"typeRoots": ["./types"],
6+
"typeRoots": ["./types"]
77
},
8-
"exclude": ["e2e"],
8+
"exclude": ["e2e"]
99
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { type EventName } from '@launchdarkly/js-client-sdk-common';
2+
import { logger } from '@launchdarkly/private-js-mocks';
3+
4+
import EventSource, { backoff, jitter } from './EventSource';
5+
6+
describe('EventSource', () => {
7+
const uri = 'https://mock.events.uri';
8+
let eventSource: EventSource<EventName>;
9+
10+
beforeAll(() => {
11+
jest.useFakeTimers();
12+
});
13+
14+
beforeEach(() => {
15+
jest
16+
.spyOn(Math, 'random')
17+
.mockImplementationOnce(() => 0.888)
18+
.mockImplementationOnce(() => 0.999);
19+
20+
eventSource = new EventSource<EventName>(uri, { logger });
21+
eventSource.open = jest.fn();
22+
eventSource.onretrying = jest.fn();
23+
});
24+
25+
afterEach(() => {
26+
// GOTCHA: Math.random must be reset separately because of a source-map type error
27+
// https://medium.com/orchestrated/updating-react-to-version-17-471bfbe6bfcd
28+
jest.spyOn(Math, 'random').mockRestore();
29+
30+
jest.resetAllMocks();
31+
});
32+
33+
test('backoff exponentially', () => {
34+
const delay0 = backoff(1000, 0);
35+
const delay1 = backoff(1000, 1);
36+
const delay2 = backoff(1000, 2);
37+
38+
expect(delay0).toEqual(1000);
39+
expect(delay1).toEqual(2000);
40+
expect(delay2).toEqual(4000);
41+
});
42+
43+
test('backoff returns max delay', () => {
44+
const delay = backoff(1000, 5);
45+
expect(delay).toEqual(30000);
46+
});
47+
48+
test('jitter', () => {
49+
const delay0 = jitter(1000);
50+
const delay1 = jitter(2000);
51+
52+
expect(delay0).toEqual(556);
53+
expect(delay1).toEqual(1001);
54+
});
55+
56+
test('getNextRetryDelay', () => {
57+
// @ts-ignore
58+
const delay0 = eventSource.getNextRetryDelay();
59+
// @ts-ignore
60+
const delay1 = eventSource.getNextRetryDelay();
61+
62+
// @ts-ignore
63+
expect(eventSource.retryCount).toEqual(2);
64+
expect(delay0).toEqual(556);
65+
expect(delay1).toEqual(1001);
66+
});
67+
68+
test('tryConnect force no delay', () => {
69+
// @ts-ignore
70+
eventSource.tryConnect(true);
71+
jest.runAllTimers();
72+
73+
expect(logger.debug).toHaveBeenCalledWith(expect.stringMatching(/new connection in 0 ms/i));
74+
expect(eventSource.onretrying).toHaveBeenCalledWith({ type: 'retry', delayMillis: 0 });
75+
expect(eventSource.open).toHaveBeenCalledTimes(2);
76+
});
77+
78+
test('tryConnect with delay', () => {
79+
// @ts-ignore
80+
eventSource.tryConnect();
81+
jest.runAllTimers();
82+
83+
expect(logger.debug).toHaveBeenNthCalledWith(
84+
2,
85+
expect.stringMatching(/new connection in 556 ms/i),
86+
);
87+
expect(eventSource.onretrying).toHaveBeenCalledWith({ type: 'retry', delayMillis: 556 });
88+
expect(eventSource.open).toHaveBeenCalledTimes(2);
89+
});
90+
});

packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,27 @@ const XMLReadyStateMap = ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DO
1212

1313
const defaultOptions: EventSourceOptions = {
1414
body: undefined,
15-
debug: false,
1615
headers: {},
1716
method: 'GET',
18-
pollingInterval: 5000,
1917
timeout: 0,
20-
timeoutBeforeConnection: 0,
2118
withCredentials: false,
2219
retryAndHandleError: undefined,
20+
initialRetryDelayMillis: 1000,
21+
logger: undefined,
2322
};
2423

24+
const maxRetryDelay = 30 * 1000; // Maximum retry delay 30 seconds.
25+
const jitterRatio = 0.5; // Delay should be 50%-100% of calculated time.
26+
27+
export function backoff(base: number, retryCount: number) {
28+
const delay = base * Math.pow(2, retryCount);
29+
return Math.min(delay, maxRetryDelay);
30+
}
31+
32+
export function jitter(computedDelayMillis: number) {
33+
return computedDelayMillis - Math.trunc(Math.random() * jitterRatio * computedDelayMillis);
34+
}
35+
2536
export default class EventSource<E extends string = never> {
2637
ERROR = -1;
2738
CONNECTING = 0;
@@ -41,16 +52,16 @@ export default class EventSource<E extends string = never> {
4152

4253
private method: string;
4354
private timeout: number;
44-
private timeoutBeforeConnection: number;
4555
private withCredentials: boolean;
4656
private headers: Record<string, any>;
4757
private body: any;
48-
private debug: boolean;
4958
private url: string;
5059
private xhr: XMLHttpRequest = new XMLHttpRequest();
5160
private pollTimer: any;
52-
private pollingInterval: number;
5361
private retryAndHandleError?: (err: any) => boolean;
62+
private initialRetryDelayMillis: number = 1000;
63+
private retryCount: number = 0;
64+
private logger?: any;
5465

5566
constructor(url: string, options?: EventSourceOptions) {
5667
const opts = {
@@ -61,25 +72,29 @@ export default class EventSource<E extends string = never> {
6172
this.url = url;
6273
this.method = opts.method!;
6374
this.timeout = opts.timeout!;
64-
this.timeoutBeforeConnection = opts.timeoutBeforeConnection!;
6575
this.withCredentials = opts.withCredentials!;
6676
this.headers = opts.headers!;
6777
this.body = opts.body;
68-
this.debug = opts.debug!;
69-
this.pollingInterval = opts.pollingInterval!;
7078
this.retryAndHandleError = opts.retryAndHandleError;
79+
this.initialRetryDelayMillis = opts.initialRetryDelayMillis!;
80+
this.logger = opts.logger;
7181

72-
this.pollAgain(this.timeoutBeforeConnection, true);
82+
this.tryConnect(true);
7383
}
7484

75-
private pollAgain(time: number, allowZero: boolean) {
76-
if (time > 0 || allowZero) {
77-
this.logDebug(`[EventSource] Will open new connection in ${time} ms.`);
78-
this.dispatch('retry', { type: 'retry' });
79-
this.pollTimer = setTimeout(() => {
80-
this.open();
81-
}, time);
82-
}
85+
private getNextRetryDelay() {
86+
const delay = jitter(backoff(this.initialRetryDelayMillis, this.retryCount));
87+
this.retryCount += 1;
88+
return delay;
89+
}
90+
91+
private tryConnect(forceNoDelay: boolean = false) {
92+
let delay = forceNoDelay ? 0 : this.getNextRetryDelay();
93+
this.logger?.debug(`[EventSource] Will open new connection in ${delay} ms.`);
94+
this.dispatch('retry', { type: 'retry', delayMillis: delay });
95+
this.pollTimer = setTimeout(() => {
96+
this.open();
97+
}, delay);
8398
}
8499

85100
open() {
@@ -113,7 +128,7 @@ export default class EventSource<E extends string = never> {
113128
return;
114129
}
115130

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

129144
if (this.xhr.status >= 200 && this.xhr.status < 400) {
130145
if (this.status === this.CONNECTING) {
146+
this.retryCount = 0;
131147
this.status = this.OPEN;
132148
this.dispatch('open', { type: 'open' });
133-
this.logDebug('[EventSource][onreadystatechange][OPEN] Connection opened.');
149+
this.logger?.debug('[EventSource][onreadystatechange][OPEN] Connection opened.');
134150
}
135151

152+
// retry from server gets set here
136153
this.handleEvent(this.xhr.responseText || '');
137154

138155
if (this.xhr.readyState === XMLHttpRequest.DONE) {
139-
this.logDebug('[EventSource][onreadystatechange][DONE] Operation done.');
140-
this.pollAgain(this.pollingInterval, false);
156+
this.logger?.debug('[EventSource][onreadystatechange][DONE] Operation done.');
157+
this.tryConnect();
141158
}
142159
} else if (this.xhr.status !== 0) {
143160
this.status = this.ERROR;
@@ -149,20 +166,20 @@ export default class EventSource<E extends string = never> {
149166
});
150167

151168
if (this.xhr.readyState === XMLHttpRequest.DONE) {
152-
this.logDebug('[EventSource][onreadystatechange][ERROR] Response status error.');
169+
this.logger?.debug('[EventSource][onreadystatechange][ERROR] Response status error.');
153170

154171
if (!this.retryAndHandleError) {
155-
// default implementation
156-
this.pollAgain(this.pollingInterval, false);
172+
// by default just try and reconnect if there's an error.
173+
this.tryConnect();
157174
} else {
158-
// custom retry logic
175+
// custom retry logic taking into account status codes.
159176
const shouldRetry = this.retryAndHandleError({
160177
status: this.xhr.status,
161178
message: this.xhr.responseText,
162179
});
163180

164181
if (shouldRetry) {
165-
this.pollAgain(this.pollingInterval, true);
182+
this.tryConnect();
166183
}
167184
}
168185
}
@@ -207,13 +224,6 @@ export default class EventSource<E extends string = never> {
207224
}
208225
}
209226

210-
private logDebug(...msg: string[]) {
211-
if (this.debug) {
212-
// eslint-disable-next-line no-console
213-
console.debug(...msg);
214-
}
215-
}
216-
217227
private handleEvent(response: string) {
218228
const parts = response.slice(this.lastIndexProcessed).split('\n');
219229

@@ -234,7 +244,8 @@ export default class EventSource<E extends string = never> {
234244
} else if (line.indexOf('retry') === 0) {
235245
retry = parseInt(line.replace(/retry:?\s*/, ''), 10);
236246
if (!Number.isNaN(retry)) {
237-
this.pollingInterval = retry;
247+
// GOTCHA: Ignore the server retry recommendation. Use our own custom getNextRetryDelay logic.
248+
// this.pollingInterval = retry;
238249
}
239250
} else if (line.indexOf('data') === 0) {
240251
data.push(line.replace(/data:?\s*/, ''));
@@ -307,7 +318,7 @@ export default class EventSource<E extends string = never> {
307318
this.onerror(data);
308319
break;
309320
case 'retry':
310-
this.onretrying({ delayMillis: this.pollingInterval });
321+
this.onretrying(data);
311322
break;
312323
default:
313324
break;

packages/sdk/react-native/src/fromExternal/react-native-sse/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface CloseEvent {
1818

1919
export interface RetryEvent {
2020
type: 'retry';
21+
delayMillis: number;
2122
}
2223

2324
export interface TimeoutEvent {
@@ -47,13 +48,12 @@ export interface ExceptionEvent {
4748
export interface EventSourceOptions {
4849
method?: string;
4950
timeout?: number;
50-
timeoutBeforeConnection?: number;
5151
withCredentials?: boolean;
5252
headers?: Record<string, any>;
5353
body?: any;
54-
debug?: boolean;
55-
pollingInterval?: number;
5654
retryAndHandleError?: (err: any) => boolean;
55+
initialRetryDelayMillis?: number;
56+
logger?: any;
5757
}
5858

5959
type BuiltInEventMap = {

packages/sdk/react-native/src/platform/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ import AsyncStorage from './ConditionalAsyncStorage';
2323
import PlatformCrypto from './crypto';
2424

2525
class PlatformRequests implements Requests {
26+
constructor(private readonly logger: LDLogger) {}
27+
2628
createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource {
2729
return new RNEventSource<EventName>(url, {
2830
headers: eventSourceInitDict.headers,
2931
retryAndHandleError: eventSourceInitDict.errorFilter,
32+
logger: this.logger,
3033
});
3134
}
3235

@@ -95,7 +98,7 @@ class PlatformStorage implements Storage {
9598
const createPlatform = (logger: LDLogger): Platform => ({
9699
crypto: new PlatformCrypto(),
97100
info: new PlatformInfo(logger),
98-
requests: new PlatformRequests(),
101+
requests: new PlatformRequests(logger),
99102
encoding: new PlatformEncoding(),
100103
storage: new PlatformStorage(logger),
101104
});

0 commit comments

Comments
 (0)