Skip to content

Commit

Permalink
FFM 9030 - Add Polling mode (#98)
Browse files Browse the repository at this point in the history
* FFM-9030 attempting polling plus min interval

* FFM-9030 working on polling

* FFM-9030 Add way to stop polling

* FFM-9030 Add way to stop polling

* FFM-9030 Use class for polling

* FFM-9030 Use class for polling

* FFM-9030 Instantiate poller

* FFM-9030 Instantiate poller

* FFM-9030 Update config

* FFM-9030 Only poll if stream disabled

* FFM-9030 Adding a solution for fallback polling

* FFM-9030 Adding a solution for fallback polling

* FFM-9030 Adding a solution for fallback polling

* FFM-9030 Adding a solution for fallback polling

* FFM-9030 Index from 1 on the retry

* FFM-9030 Improve logging

* FFM-9030 Prettier

* FFM-9030 Extract poll into its own function

* FFM-9030 Extract poll into its own function

* FFM-9030 Extract poll into its own function

* FFM-9030 Add type to fallbackpoller

* FFM-9030 Rename polling file to poller

* FFM-9030 Add polling tests

* FFM-9030 Add polling tests

* FFM-9030 Add polling tests

* FFM-9030 Update type for poller

* FFM-9030 Make poll private

* FFM-9030 Update test type

* FFM-9030 Comment

* FFM-9030 Update test

* FFM-9030 Update test

* FFM-9030 Update readme

* FFM-9030 1.17.0-rc.1

* FFM-9030 Avoid  index name

* FFM-9030 Use standard truthy check

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* FFM-9030 Remove global vars in poller tests

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* FFM-9030 Set maxAttempt constant instead of constructor arg

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* FFM-9030 Use constant for maxAttempt

* FFM-9030 Refactor tests

* FFM-9030 Exctract reconnect into its own function

* Revert "FFM-9030 Exctract reconnect into its own function"

This reverts commit ab378bf.

* FFM-9030 Add delay to poll

* FFM-9030 Update test cases for sleep

* FFM-9030 Log errors

* FFM-9030 Refactor authenticate

* FFM-9030 Remove bad debug log for event stream ready

* FFM-9030 Make streaming and polling opt-in

* FFM-9030 Make streaming and polling opt-in

* FFM-9030 Delete unused var

* FFM-9030 Remove needless if

* FFM-9030 Update log

* Update src/poller.ts

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* Update src/poller.ts

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* FFM-9030 Update fallback polling

* FFM-9030 Update readme

* FFM-9030 Update readme

* FFM-9030 Update readme

* Update src/stream.ts

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* FFM-9030 Add logs to streaming - extract logDebug to function

* FFM-9030 Fix fallback polling

* FFM-9030 Remove window references for setTimeout

* FFM-9030 Update src/index.ts

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* FFM-9030 Don't test internal implementation details

* FFM-9030 Hardcode max attempts in test

* FFM-9030 Fix tests

* FFM-9030 Update readme

* FFM-9030 Update readme

* Update README.md

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* Update README.md

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* FFM-9030 Update readme

* FFM-9030 Update package.json

* Update package.json

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* Update src/utils.ts

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>

* FFM-9030 If polling is undefined, default it to streaming mode

* FFM-9030 If polling is undefined, default it to streaming mode

* FFM-9030 Remove typo

* FFM-9030 Remove typo

* FFM-9030 Remove polling enabled from hardcoded options

* FFM-9030 Add polling stopped debug log

* FFM-9030 Add assertions for debug

* FFM-9030 Additional debug log plus test cases

* FFM-9030 Add next poll time to debug log

* FFM-9030 1.17.0 release prep

* FFM-9030 Clarify default polling mode

* FFM-9030 Clarify default polling mode

---------

Co-authored-by: Kevin Nagurski <Kevin.nagurski@harness.io>
  • Loading branch information
erdirowlands and knagurski authored Sep 18, 2023
1 parent 400c3bf commit 180af10
Show file tree
Hide file tree
Showing 9 changed files with 400 additions and 58 deletions.
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ interface Options {
baseUrl?: string
eventUrl?: string
eventsSyncInterval?: number
pollingInterval?: number
pollingEnabled?: boolean
streamEnabled?: boolean
allAttributesPrivate?: boolean
privateAttributeNames?: string[]
Expand All @@ -71,7 +73,53 @@ const client = initialize('00000000-1111-2222-3333-444444444444', {
})
```

### Listening to events from the `client` instance.

## Streaming and Polling Mode

By default, Harness Feature Flags SDK has streaming enabled and polling enabled. Both modes can be toggled according to your preference using the SDK's configuration.

### Streaming Mode
Streaming mode establishes a continuous connection between your application and the Feature Flags service.
This allows for real-time updates on feature flags without requiring periodic checks.
If an error occurs while streaming and `pollingEnabled` is set to `true`,
the SDK will automatically fall back to polling mode until streaming can be reestablished.
If `pollingEnabled` is `false`, streaming will attempt to reconnect without falling back to polling.

### Polling Mode
In polling mode, the SDK will periodically check with the Feature Flags service to retrieve updates for feature flags. The frequency of these checks can be adjusted using the SDK's configurations.

### No Streaming or Polling
If both streaming and polling modes are disabled (`streamEnabled: false` and `pollingEnabled: false`),
the SDK will not automatically fetch feature flag updates after the initial fetch.
This means that after the initial load, any changes made to the feature flags on the Harness server will not be reflected in the application until the SDK is re-initialized or one of the modes is re-enabled.

This configuration might be useful in specific scenarios where you want to ensure a consistent set of feature flags
for a session or when the application operates in an environment where regular updates are not necessary. However, it's essential to be aware that this configuration can lead to outdated flag evaluations if the flags change on the server.

To configure the modes:

```typescript

const options = {
streamEnabled: true, // Enable or disable streaming - default is enabled
pollingEnabled: true, // Enable or disable polling - default is enabled if stream enabled, or disabled if stream disabled.
pollingInterval: 60000, // Polling interval in ms, default is 60000ms which is the minimum. If set below this, will default to 60000ms.
}

const client = initialize(
'YOUR_SDK_KEY',
{
identifier: 'Harness1',
attributes: {
lastUpdated: Date(),
host: location.href
}
},
options
)
```

## Listening to events from the `client` instance.

```typescript
client.on(Event.READY, flags => {
Expand Down
17 changes: 15 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harnessio/ff-javascript-client-sdk",
"version": "1.16.0",
"version": "1.17.0",
"author": "Harness",
"license": "Apache-2.0",
"main": "dist/sdk.cjs.js",
Expand Down Expand Up @@ -31,6 +31,7 @@
"jest-junit": "^16.0.0",
"prettier": "^3.0.3",
"ts-jest": "^29.1.1",
"tslib": "^2.6.2",
"typescript": "^5.2.2"
},
"description": "Basic library for integrating CF into javascript applications.",
Expand Down
152 changes: 152 additions & 0 deletions src/__tests__/poller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import Poller from '../poller'
import type { Options } from '../types'
import { getRandom } from '../utils'

jest.useFakeTimers()

jest.mock('../utils.ts', () => ({
getRandom: jest.fn(),
logError: jest.fn()
}))

interface PollerArgs {
fetchFlags: jest.MockedFunction<() => Promise<any>>
configurations: Partial<Options>
}

interface TestArgs {
delayFunction: jest.MockedFunction<() => number>
logSpy: jest.SpyInstance
mockError: Error
delayMs: number
}

let currentPoller: Poller
const getPoller = (overrides: Partial<PollerArgs> = {}): Poller => {
const args: PollerArgs = {
fetchFlags: jest.fn(),
configurations: {},
...overrides
}

currentPoller = new Poller(args.fetchFlags, args.configurations)

return currentPoller
}

const getTestArgs = (overrides: Partial<TestArgs> = {}): TestArgs => {
const { delayMs = 5000 } = overrides // Extract delayMs or set a default of 5000

return {
delayMs,
delayFunction: (getRandom as jest.Mock).mockReturnValue(delayMs),
logSpy: jest.spyOn(console, 'debug').mockImplementation(() => {}),
mockError: new Error('Fetch Error'),
...overrides
}
}

describe('Poller', () => {
afterEach(() => {
jest.clearAllMocks()
currentPoller?.stop()
})
it('should not start polling if it is already polling', () => {
getPoller({ configurations: { debug: true } })
const testArgs = getTestArgs()

currentPoller.start()
currentPoller.start()
expect(testArgs.logSpy).toHaveBeenCalledTimes(2)
})

it('should retry fetching if there is an error', async () => {
const testArgs = getTestArgs()

let attemptCount = 0
const fetchFlagsMock = jest.fn().mockImplementation(() => {
attemptCount++

// Return null (success) on the maxAttempts-th call, error otherwise.
return Promise.resolve(attemptCount === 2 ? null : testArgs.mockError)
})

const pollInterval = 60000

getPoller({
fetchFlags: fetchFlagsMock,
configurations: { pollingInterval: pollInterval, debug: true }
})

currentPoller.start()

jest.advanceTimersByTime(pollInterval)

// Allow first attempt to resolve
await Promise.resolve()

// Advance past the first delay
jest.advanceTimersByTime(testArgs.delayMs)

// Allow successful attempt to resolve
await Promise.resolve()

expect(fetchFlagsMock).toHaveBeenCalledTimes(2)
expect(testArgs.logSpy).toHaveBeenCalledTimes(3)
})

it('should not retry after max attempts are exceeded', async () => {
const maxAttempts = 5
let attemptCount = 0

const mockError = new Error('Fetch Error')

const fetchFlagsMock = jest.fn().mockImplementation(() => {
attemptCount++

// Return null (success) on the maxAttempts-th call, error otherwise.
return Promise.resolve(attemptCount === maxAttempts ? null : mockError)
})

const pollInterval = 60000

getPoller({
fetchFlags: fetchFlagsMock,
configurations: { pollingInterval: pollInterval, debug: true }
})

const testArgs = getTestArgs()

currentPoller.start()

jest.advanceTimersByTime(pollInterval)

for (let i = 0; i < maxAttempts; i++) {
jest.advanceTimersByTime(testArgs.delayMs)
// We need to wait for the fetchFlags promise and the timeout promise to resolve
await Promise.resolve()
await Promise.resolve()
}

expect(fetchFlagsMock).toHaveBeenCalledTimes(5)
expect(testArgs.logSpy).toHaveBeenCalledTimes(7)
})

it('should successfully fetch flags without retrying on success', async () => {
const pollInterval = 60000
const fetchFlagsMock = jest.fn().mockResolvedValue(null)

getPoller({
fetchFlags: fetchFlagsMock,
configurations: { pollingInterval: pollInterval, debug: true }
})
const testArgs = getTestArgs()

currentPoller.start()
jest.advanceTimersByTime(pollInterval)
await Promise.resolve()

expect(fetchFlagsMock).toHaveBeenCalledTimes(1)
expect(testArgs.logSpy).toHaveBeenCalledTimes(3)
})
})
Loading

0 comments on commit 180af10

Please sign in to comment.