Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,54 @@ interface Evaluation {
}
```

## Authentication Request Timeout

The `authRequestReadTimeout` option allows you to specify a timeout in milliseconds for the authentication request. If the request takes longer than this timeout, it will be aborted. This is useful for preventing hanging requests due to network issues or slow responses.

If the request is aborted due to this timeout the SDK will fail to initialize and an `ERROR_AUTH` and `ERROR` event will be emitted.

**This only applies to the authentiaction request. If you wish to set a read timeout on the remaining requests made by the SDK, you may register [API Middleware](#api-middleware)

```typescript
const options = {
authRequestReadTimeout: 30000, // Timeout in milliseconds (default: 30000)
};

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

## API Middleware
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deciding to document this here, mainly to show users that they can use this to timeout the remaining SDK requests like evaluations / metrics

The `registerAPIRequestMiddleware` function allows you to register a middleware function to manipulate the payload (URL, body and headers) of API requests after the AUTH call has successfully completed

```typescript
function abortControllerMiddleware([url, options]) {
if (window.AbortController) {
const abortController = new AbortController();
options.signal = abortController.signal;

// Set a timeout to automatically abort the request after 30 seconds
setTimeout(() => abortController.abort(), 30000);
}

return [url, options]; // Return the modified or original arguments
}

// Register the middleware
client.registerAPIRequestMiddleware(abortControllerMiddleware);
```
This example middleware will automatically attach an AbortController to each request, which will abort the request if it takes longer than the specified timeout. You can also customize the middleware to perform other actions, such as logging or modifying headers.


## Logging
By default, the Javascript Client SDK will log errors and debug messages using the `console` object. In some cases, it
can be useful to instead log to a service or silently fail without logging errors.
Expand Down
20 changes: 10 additions & 10 deletions package-lock.json

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

2 changes: 1 addition & 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.27.0",
"version": "1.28.0",
"author": "Harness",
"license": "Apache-2.0",
"main": "dist/sdk.cjs.js",
Expand Down
92 changes: 47 additions & 45 deletions src/__tests__/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Options } from '../types'
import { Event } from '../types'
import { getRandom } from '../utils'
import type { Emitter } from 'mitt'
import type Poller from "../poller";
import type Poller from '../poller'

jest.useFakeTimers()

Expand Down Expand Up @@ -49,16 +49,16 @@ const getStreamer = (overrides: Partial<Options> = {}, maxRetries: number = Infi
}

return new Streamer(
mockEventBus,
options,
`${options.baseUrl}/stream`,
'test-api-key',
{ 'Test-Header': 'value' },
{ start: jest.fn(), stop: jest.fn(), isPolling: jest.fn() } as unknown as Poller,
logDebug,
logError,
jest.fn(),
maxRetries
mockEventBus,
options,
`${options.baseUrl}/stream`,
'test-api-key',
{ 'Test-Header': 'value' },
{ start: jest.fn(), stop: jest.fn(), isPolling: jest.fn() } as unknown as Poller,
logDebug,
logError,
jest.fn(),
maxRetries
)
}

Expand Down Expand Up @@ -130,16 +130,16 @@ describe('Streamer', () => {
it('should fallback to polling on stream failure', () => {
const poller = { start: jest.fn(), stop: jest.fn(), isPolling: jest.fn() } as unknown as Poller
const streamer = new Streamer(
mockEventBus,
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
'http://test/stream',
'test-api-key',
{ 'Test-Header': 'value' },
poller,
logDebug,
logError,
jest.fn(),
Infinity
mockEventBus,
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
'http://test/stream',
'test-api-key',
{ 'Test-Header': 'value' },
poller,
logDebug,
logError,
jest.fn(),
Infinity
)

streamer.start()
Expand All @@ -154,21 +154,19 @@ describe('Streamer', () => {

it('should stop polling when close is called if in fallback polling mode', () => {
const poller = { start: jest.fn(), stop: jest.fn(), isPolling: jest.fn() } as unknown as Poller
;(poller.isPolling as jest.Mock)
.mockImplementationOnce(() => false)
.mockImplementationOnce(() => true)
;(poller.isPolling as jest.Mock).mockImplementationOnce(() => false).mockImplementationOnce(() => true)

const streamer = new Streamer(
mockEventBus,
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
'http://test/stream',
'test-api-key',
{ 'Test-Header': 'value' },
poller,
logDebug,
logError,
jest.fn(),
3
mockEventBus,
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
'http://test/stream',
'test-api-key',
{ 'Test-Header': 'value' },
poller,
logDebug,
logError,
jest.fn(),
3
)

streamer.start()
Expand All @@ -190,18 +188,22 @@ describe('Streamer', () => {
})

it('should stop streaming but not call poller.stop if not in fallback polling mode when close is called', () => {
const poller = { start: jest.fn(), stop: jest.fn(), isPolling: jest.fn().mockReturnValue(false) } as unknown as Poller
const poller = {
start: jest.fn(),
stop: jest.fn(),
isPolling: jest.fn().mockReturnValue(false)
} as unknown as Poller
const streamer = new Streamer(
mockEventBus,
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
'http://test/stream',
'test-api-key',
{ 'Test-Header': 'value' },
poller,
logDebug,
logError,
jest.fn(),
3
mockEventBus,
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
'http://test/stream',
'test-api-key',
{ 'Test-Header': 'value' },
poller,
logDebug,
logError,
jest.fn(),
3
)

streamer.start()
Expand Down
44 changes: 40 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result =
configurations.logger.error(`[FF-SDK] ${message}`, ...args)
}

const logWarn = (message: string, ...args: any[]) => {
configurations.logger.warn(`[FF-SDK] ${message}`, ...args)
}

const convertValue = (evaluation: Evaluation) => {
let { value } = evaluation

Expand Down Expand Up @@ -143,18 +147,50 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result =
}

const authenticate = async (clientID: string, configuration: Options): Promise<string> => {
const response = await fetch(`${configuration.baseUrl}/client/auth`, {
const url = `${configuration.baseUrl}/client/auth`
const requestOptions: RequestInit = {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Harness-SDK-Info': SDK_INFO },
body: JSON.stringify({
apiKey: clientID,
target: { ...target, identifier: String(target.identifier) }
})
})
}

let timeoutId: number | undefined
let abortController: AbortController | undefined

const data: { authToken: string } = await response.json()
if (window.AbortController && configurations.authRequestReadTimeout > 0) {
abortController = new AbortController()
requestOptions.signal = abortController.signal

timeoutId = window.setTimeout(() => abortController.abort(), configuration.authRequestReadTimeout)
} else if (configuration.authRequestReadTimeout > 0) {
logWarn('AbortController is not available, auth request will not timeout')
}

try {
const response = await fetch(url, requestOptions)

return data.authToken
if (!response.ok) {
throw new Error(`${response.status}: ${response.statusText}`)
}

const data: { authToken: string } = await response.json()
return data.authToken
} catch (error) {
if (abortController && abortController.signal.aborted) {
throw new Error(
`Request to ${url} failed: Request timeout via configured authRequestTimeout of ${configurations.authRequestReadTimeout}`
)
}
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Request to ${url} failed: ${errorMessage}`)
} finally {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}

let failedMetricsCallCount = 0
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export interface Options {
* Whether to enable debug logging.
* @default false
*/
authRequestReadTimeout?: number
/**
* The timeout in milliseconds for the authentication request to read the response.
* If the request takes longer than this timeout, it will be aborted and the SDK will fail to initialize, and `ERROR_AUTH` and `ERROR` events will be emitted.
* @default 0 (no timeout)
*/
debug?: boolean
/**
* Whether to enable caching.
Expand Down
1 change: 1 addition & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const defaultOptions: Options = {
pollingInterval: MIN_POLLING_INTERVAL,
streamEnabled: true,
cache: false,
authRequestReadTimeout: 0,
maxStreamRetries: Infinity
}

Expand Down