Skip to content

Commit 1e4218b

Browse files
authored
FFM-11788 Add maxStreamRetries config option (#126)
1 parent a62e831 commit 1e4218b

File tree

8 files changed

+306
-7
lines changed

8 files changed

+306
-7
lines changed

README.md

+26
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,32 @@ const client = initialize(
119119
)
120120
```
121121

122+
Max Stream Retries
123+
You can configure the maximum number of streaming retries before the SDK stops attempting to reconnect or falls back to polling (if enabled). The maxRetries option can be set to any positive number or Infinity for unlimited retries (which is the default).
124+
125+
```typescript
126+
const options = {
127+
maxRetries: 5, // Set the maximum number of retries for streaming. Default is Infinity.
128+
streamEnabled: true,
129+
pollingEnabled: true,
130+
pollingInterval: 60000,
131+
}
132+
133+
const client = initialize(
134+
'YOUR_SDK_KEY',
135+
{
136+
identifier: 'Harness1',
137+
attributes: {
138+
lastUpdated: Date(),
139+
host: location.href
140+
}
141+
},
142+
options
143+
)
144+
145+
```
146+
If maxRetries is reached and pollingEnabled is true, the SDK will stay in polling mode. If pollingEnabled is false, the SDK will not poll, and evaluations will not be updated until the SDK Client is initialized again, for example if the app or page is restarted.
147+
122148
## Listening to events from the `client` instance.
123149

124150
```typescript

package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harnessio/ff-javascript-client-sdk",
3-
"version": "1.26.3",
3+
"version": "1.27.0-rc.0",
44
"author": "Harness",
55
"license": "Apache-2.0",
66
"main": "dist/sdk.cjs.js",

src/__tests__/stream.test.ts

+247
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { Streamer } from '../stream'
2+
import type { Options } from '../types'
3+
import { Event } from '../types'
4+
import { getRandom } from '../utils'
5+
import type { Emitter } from 'mitt'
6+
import type Poller from "../poller";
7+
8+
jest.useFakeTimers()
9+
10+
jest.mock('../utils.ts', () => ({
11+
getRandom: jest.fn()
12+
}))
13+
14+
const mockEventBus: Emitter = {
15+
emit: jest.fn(),
16+
on: jest.fn(),
17+
off: jest.fn(),
18+
all: new Map()
19+
}
20+
21+
const mockXHR = {
22+
open: jest.fn(),
23+
setRequestHeader: jest.fn(),
24+
send: jest.fn(),
25+
abort: jest.fn(),
26+
status: 0,
27+
responseText: '',
28+
onload: null,
29+
onerror: null,
30+
onprogress: null,
31+
onabort: null,
32+
ontimeout: null
33+
}
34+
35+
global.XMLHttpRequest = jest.fn(() => mockXHR) as unknown as jest.MockedClass<typeof XMLHttpRequest>
36+
37+
const logError = jest.fn()
38+
const logDebug = jest.fn()
39+
40+
const getStreamer = (overrides: Partial<Options> = {}, maxRetries: number = Infinity): Streamer => {
41+
const options: Options = {
42+
baseUrl: 'http://test',
43+
eventUrl: 'http://event',
44+
pollingInterval: 60000,
45+
debug: true,
46+
pollingEnabled: true,
47+
streamEnabled: true,
48+
...overrides
49+
}
50+
51+
return new Streamer(
52+
mockEventBus,
53+
options,
54+
`${options.baseUrl}/stream`,
55+
'test-api-key',
56+
{ 'Test-Header': 'value' },
57+
{ start: jest.fn(), stop: jest.fn(), isPolling: jest.fn() } as unknown as Poller,
58+
logDebug,
59+
logError,
60+
jest.fn(),
61+
maxRetries
62+
)
63+
}
64+
65+
describe('Streamer', () => {
66+
beforeEach(() => {
67+
jest.clearAllMocks()
68+
})
69+
70+
it('should connect and emit CONNECTED event', () => {
71+
const streamer = getStreamer({}, 3)
72+
73+
streamer.start()
74+
expect(mockXHR.open).toHaveBeenCalledWith('GET', 'http://test/stream')
75+
expect(mockXHR.send).toHaveBeenCalled()
76+
77+
mockXHR.onprogress({} as ProgressEvent)
78+
expect(mockEventBus.emit).toHaveBeenCalledWith(Event.CONNECTED)
79+
})
80+
81+
it('should reconnect successfully after multiple failures', () => {
82+
const streamer = getStreamer({}, 5)
83+
84+
streamer.start()
85+
expect(mockXHR.send).toHaveBeenCalled()
86+
87+
for (let i = 0; i < 3; i++) {
88+
mockXHR.onerror({} as ProgressEvent)
89+
jest.advanceTimersByTime(getRandom(1000, 10000))
90+
}
91+
92+
// Simulate a successful connection on the next attempt
93+
mockXHR.onprogress({} as ProgressEvent)
94+
95+
expect(mockEventBus.emit).toHaveBeenCalledWith(Event.CONNECTED)
96+
expect(mockXHR.send).toHaveBeenCalledTimes(4) // Should attempt to reconnect 3 times before succeeding
97+
})
98+
99+
it('should retry connecting on error and eventually fallback to polling', () => {
100+
const streamer = getStreamer()
101+
102+
streamer.start()
103+
expect(mockXHR.send).toHaveBeenCalled()
104+
105+
for (let i = 0; i < 3; i++) {
106+
mockXHR.onerror({} as ProgressEvent)
107+
jest.advanceTimersByTime(getRandom(1000, 10000))
108+
}
109+
110+
expect(mockEventBus.emit).toHaveBeenCalledWith(Event.DISCONNECTED)
111+
})
112+
113+
it('should not retry after max retries are exhausted', () => {
114+
const streamer = getStreamer({}, 3)
115+
116+
streamer.start()
117+
expect(mockXHR.send).toHaveBeenCalled()
118+
119+
for (let i = 0; i < 3; i++) {
120+
mockXHR.onerror({} as ProgressEvent)
121+
jest.advanceTimersByTime(getRandom(1000, 10000))
122+
}
123+
124+
mockXHR.onerror({} as ProgressEvent)
125+
expect(logError).toHaveBeenCalledWith('Streaming: Max streaming retries reached. Staying in polling mode.')
126+
expect(mockEventBus.emit).toHaveBeenCalledWith(Event.DISCONNECTED)
127+
expect(mockXHR.send).toHaveBeenCalledTimes(3) // Should not send after max retries
128+
})
129+
130+
it('should fallback to polling on stream failure', () => {
131+
const poller = { start: jest.fn(), stop: jest.fn(), isPolling: jest.fn() } as unknown as Poller
132+
const streamer = new Streamer(
133+
mockEventBus,
134+
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
135+
'http://test/stream',
136+
'test-api-key',
137+
{ 'Test-Header': 'value' },
138+
poller,
139+
logDebug,
140+
logError,
141+
jest.fn(),
142+
Infinity
143+
)
144+
145+
streamer.start()
146+
expect(mockXHR.send).toHaveBeenCalled()
147+
148+
mockXHR.onerror({} as ProgressEvent)
149+
jest.advanceTimersByTime(getRandom(1000, 10000))
150+
151+
expect(poller.start).toHaveBeenCalled()
152+
expect(logDebug).toHaveBeenCalledWith('Streaming: Falling back to polling mode while stream recovers')
153+
})
154+
155+
it('should stop polling when close is called if in fallback polling mode', () => {
156+
const poller = { start: jest.fn(), stop: jest.fn(), isPolling: jest.fn() } as unknown as Poller
157+
;(poller.isPolling as jest.Mock)
158+
.mockImplementationOnce(() => false)
159+
.mockImplementationOnce(() => true)
160+
161+
const streamer = new Streamer(
162+
mockEventBus,
163+
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
164+
'http://test/stream',
165+
'test-api-key',
166+
{ 'Test-Header': 'value' },
167+
poller,
168+
logDebug,
169+
logError,
170+
jest.fn(),
171+
3
172+
)
173+
174+
streamer.start()
175+
expect(mockXHR.send).toHaveBeenCalled()
176+
177+
// Simulate stream failure and fallback to polling
178+
mockXHR.onerror({} as ProgressEvent)
179+
jest.advanceTimersByTime(getRandom(1000, 10000))
180+
181+
// Ensure polling has started
182+
expect(poller.start).toHaveBeenCalled()
183+
184+
// Now close the streamer
185+
streamer.close()
186+
187+
expect(mockXHR.abort).toHaveBeenCalled()
188+
expect(poller.stop).toHaveBeenCalled()
189+
expect(mockEventBus.emit).toHaveBeenCalledWith(Event.STOPPED)
190+
})
191+
192+
it('should stop streaming but not call poller.stop if not in fallback polling mode when close is called', () => {
193+
const poller = { start: jest.fn(), stop: jest.fn(), isPolling: jest.fn().mockReturnValue(false) } as unknown as Poller
194+
const streamer = new Streamer(
195+
mockEventBus,
196+
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
197+
'http://test/stream',
198+
'test-api-key',
199+
{ 'Test-Header': 'value' },
200+
poller,
201+
logDebug,
202+
logError,
203+
jest.fn(),
204+
3
205+
)
206+
207+
streamer.start()
208+
streamer.close()
209+
210+
expect(mockXHR.abort).toHaveBeenCalled()
211+
expect(poller.stop).not.toHaveBeenCalled()
212+
expect(mockEventBus.emit).toHaveBeenCalledWith(Event.STOPPED)
213+
})
214+
215+
it('should retry indefinitely if maxRetries is set to Infinity', () => {
216+
const streamer = getStreamer()
217+
218+
streamer.start()
219+
expect(mockXHR.send).toHaveBeenCalled()
220+
221+
for (let i = 0; i < 100; i++) {
222+
mockXHR.onerror({} as ProgressEvent)
223+
jest.advanceTimersByTime(getRandom(1000, 10000))
224+
}
225+
226+
expect(logError).not.toHaveBeenCalledWith('Streaming: Max streaming retries reached. Staying in polling mode.')
227+
expect(mockXHR.send).toHaveBeenCalledTimes(101)
228+
})
229+
230+
it('should reconnect successfully after multiple failures', () => {
231+
const streamer = getStreamer({}, 5)
232+
233+
streamer.start()
234+
expect(mockXHR.send).toHaveBeenCalled()
235+
236+
for (let i = 0; i < 3; i++) {
237+
mockXHR.onerror({} as ProgressEvent)
238+
jest.advanceTimersByTime(getRandom(1000, 10000))
239+
}
240+
241+
// Simulate a successful connection on the next attempt
242+
mockXHR.onprogress({} as ProgressEvent)
243+
244+
expect(mockEventBus.emit).toHaveBeenCalledWith(Event.CONNECTED)
245+
expect(mockXHR.send).toHaveBeenCalledTimes(4) // Should attempt to reconnect 3 times before succeeding
246+
})
247+
})

src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,8 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result =
543543
} else if (event.domain === 'target-segment') {
544544
handleSegmentEvent(event)
545545
}
546-
}
546+
},
547+
configurations.maxStreamRetries
547548
)
548549
eventSource.start()
549550
}

src/stream.ts

+20-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export class Streamer {
1111
private readTimeoutCheckerId: any
1212
private connectionOpened = false
1313
private disconnectEventEmitted = false
14-
private reconnectAttempts = 0
14+
private reconnectAttempts = 0
15+
private retriesExhausted: boolean = false
1516

1617
constructor(
1718
private eventBus: Emitter,
@@ -22,7 +23,8 @@ export class Streamer {
2223
private fallbackPoller: Poller,
2324
private logDebug: (...data: any[]) => void,
2425
private logError: (...data: any[]) => void,
25-
private eventCallback: (e: StreamEvent) => void
26+
private eventCallback: (e: StreamEvent) => void,
27+
private maxRetries: number
2628
) {}
2729

2830
start() {
@@ -60,10 +62,26 @@ export class Streamer {
6062
)
6163
}
6264

65+
if (this.reconnectAttempts >= this.maxRetries) {
66+
this.retriesExhausted = true
67+
if (this.configurations.pollingEnabled) {
68+
this.logErrorMessage('Max streaming retries reached. Staying in polling mode.')
69+
} else {
70+
this.logErrorMessage(
71+
'Max streaming retries reached. Polling mode is disabled and will receive no further flag updates until SDK client is restarted.'
72+
)
73+
}
74+
return
75+
}
76+
6377
setTimeout(() => this.start(), reconnectDelayMs)
6478
}
6579

6680
const onFailed = (msg: string) => {
81+
if (this.retriesExhausted) {
82+
return
83+
}
84+
6785
if (!!msg) {
6886
this.logDebugMessage('Stream has issue', msg)
6987
}

src/types.ts

+6
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ export interface Options {
144144
* @default console
145145
*/
146146
logger?: Logger
147+
148+
/**
149+
* By default, the stream will attempt to reconnect indefinitely if it disconnects. Use this option to limit
150+
* the number of attempts it will make.
151+
*/
152+
maxStreamRetries?: number
147153
}
148154

149155
export interface MetricsInfo {

src/utils.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export const defaultOptions: Options = {
1010
eventsSyncInterval: MIN_EVENTS_SYNC_INTERVAL,
1111
pollingInterval: MIN_POLLING_INTERVAL,
1212
streamEnabled: true,
13-
cache: false
13+
cache: false,
14+
maxStreamRetries: Infinity
1415
}
1516

1617
export const getConfiguration = (options: Options): Options => {

0 commit comments

Comments
 (0)