Skip to content

Commit

Permalink
FFM-11972 Add authRequestReadTimeout option (#135)
Browse files Browse the repository at this point in the history
  • Loading branch information
erdirowlands authored Sep 3, 2024
1 parent 36bd143 commit 8ea98bb
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 60 deletions.
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
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

0 comments on commit 8ea98bb

Please sign in to comment.