Skip to content

Commit 8ea98bb

Browse files
authored
FFM-11972 Add authRequestReadTimeout option (#135)
1 parent 36bd143 commit 8ea98bb

7 files changed

Lines changed: 153 additions & 60 deletions

File tree

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,54 @@ interface Evaluation {
330330
}
331331
```
332332

333+
## Authentication Request Timeout
334+
335+
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.
336+
337+
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.
338+
339+
**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)
340+
341+
```typescript
342+
const options = {
343+
authRequestReadTimeout: 30000, // Timeout in milliseconds (default: 30000)
344+
};
345+
346+
const client = initialize(
347+
'YOUR_API_KEY',
348+
{
349+
identifier: 'Harness1',
350+
attributes: {
351+
lastUpdated: Date(),
352+
host: location.href,
353+
},
354+
},
355+
options
356+
);
357+
```
358+
359+
## API Middleware
360+
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
361+
362+
```typescript
363+
function abortControllerMiddleware([url, options]) {
364+
if (window.AbortController) {
365+
const abortController = new AbortController();
366+
options.signal = abortController.signal;
367+
368+
// Set a timeout to automatically abort the request after 30 seconds
369+
setTimeout(() => abortController.abort(), 30000);
370+
}
371+
372+
return [url, options]; // Return the modified or original arguments
373+
}
374+
375+
// Register the middleware
376+
client.registerAPIRequestMiddleware(abortControllerMiddleware);
377+
```
378+
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.
379+
380+
333381
## Logging
334382
By default, the Javascript Client SDK will log errors and debug messages using the `console` object. In some cases, it
335383
can be useful to instead log to a service or silently fail without logging errors.

package-lock.json

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

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

src/__tests__/stream.test.ts

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Options } from '../types'
33
import { Event } from '../types'
44
import { getRandom } from '../utils'
55
import type { Emitter } from 'mitt'
6-
import type Poller from "../poller";
6+
import type Poller from '../poller'
77

88
jest.useFakeTimers()
99

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

5151
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
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
6262
)
6363
}
6464

@@ -130,16 +130,16 @@ describe('Streamer', () => {
130130
it('should fallback to polling on stream failure', () => {
131131
const poller = { start: jest.fn(), stop: jest.fn(), isPolling: jest.fn() } as unknown as Poller
132132
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
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
143143
)
144144

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

155155
it('should stop polling when close is called if in fallback polling mode', () => {
156156
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)
157+
;(poller.isPolling as jest.Mock).mockImplementationOnce(() => false).mockImplementationOnce(() => true)
160158

161159
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
160+
mockEventBus,
161+
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
162+
'http://test/stream',
163+
'test-api-key',
164+
{ 'Test-Header': 'value' },
165+
poller,
166+
logDebug,
167+
logError,
168+
jest.fn(),
169+
3
172170
)
173171

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

192190
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
191+
const poller = {
192+
start: jest.fn(),
193+
stop: jest.fn(),
194+
isPolling: jest.fn().mockReturnValue(false)
195+
} as unknown as Poller
194196
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
197+
mockEventBus,
198+
{ baseUrl: 'http://test', eventUrl: 'http://event', pollingEnabled: true, streamEnabled: true, debug: true },
199+
'http://test/stream',
200+
'test-api-key',
201+
{ 'Test-Header': 'value' },
202+
poller,
203+
logDebug,
204+
logError,
205+
jest.fn(),
206+
3
205207
)
206208

207209
streamer.start()

src/index.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ const initialize = (apiKey: string, target: Target, options?: Options): Result =
6464
configurations.logger.error(`[FF-SDK] ${message}`, ...args)
6565
}
6666

67+
const logWarn = (message: string, ...args: any[]) => {
68+
configurations.logger.warn(`[FF-SDK] ${message}`, ...args)
69+
}
70+
6771
const convertValue = (evaluation: Evaluation) => {
6872
let { value } = evaluation
6973

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

145149
const authenticate = async (clientID: string, configuration: Options): Promise<string> => {
146-
const response = await fetch(`${configuration.baseUrl}/client/auth`, {
150+
const url = `${configuration.baseUrl}/client/auth`
151+
const requestOptions: RequestInit = {
147152
method: 'POST',
148153
headers: { 'Content-Type': 'application/json', 'Harness-SDK-Info': SDK_INFO },
149154
body: JSON.stringify({
150155
apiKey: clientID,
151156
target: { ...target, identifier: String(target.identifier) }
152157
})
153-
})
158+
}
159+
160+
let timeoutId: number | undefined
161+
let abortController: AbortController | undefined
154162

155-
const data: { authToken: string } = await response.json()
163+
if (window.AbortController && configurations.authRequestReadTimeout > 0) {
164+
abortController = new AbortController()
165+
requestOptions.signal = abortController.signal
166+
167+
timeoutId = window.setTimeout(() => abortController.abort(), configuration.authRequestReadTimeout)
168+
} else if (configuration.authRequestReadTimeout > 0) {
169+
logWarn('AbortController is not available, auth request will not timeout')
170+
}
171+
172+
try {
173+
const response = await fetch(url, requestOptions)
156174

157-
return data.authToken
175+
if (!response.ok) {
176+
throw new Error(`${response.status}: ${response.statusText}`)
177+
}
178+
179+
const data: { authToken: string } = await response.json()
180+
return data.authToken
181+
} catch (error) {
182+
if (abortController && abortController.signal.aborted) {
183+
throw new Error(
184+
`Request to ${url} failed: Request timeout via configured authRequestTimeout of ${configurations.authRequestReadTimeout}`
185+
)
186+
}
187+
const errorMessage = error instanceof Error ? error.message : String(error)
188+
throw new Error(`Request to ${url} failed: ${errorMessage}`)
189+
} finally {
190+
if (timeoutId) {
191+
clearTimeout(timeoutId)
192+
}
193+
}
158194
}
159195

160196
let failedMetricsCallCount = 0

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,12 @@ export interface Options {
133133
* Whether to enable debug logging.
134134
* @default false
135135
*/
136+
authRequestReadTimeout?: number
137+
/**
138+
* The timeout in milliseconds for the authentication request to read the response.
139+
* 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.
140+
* @default 0 (no timeout)
141+
*/
136142
debug?: boolean
137143
/**
138144
* Whether to enable caching.

src/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const defaultOptions: Options = {
1111
pollingInterval: MIN_POLLING_INTERVAL,
1212
streamEnabled: true,
1313
cache: false,
14+
authRequestReadTimeout: 0,
1415
maxStreamRetries: Infinity
1516
}
1617

0 commit comments

Comments
 (0)