Skip to content

Commit 8eff49e

Browse files
authored
FFM-12374 Retry enhancements (#123)
1 parent 986ed95 commit 8eff49e

File tree

7 files changed

+1824
-2223
lines changed

7 files changed

+1824
-2223
lines changed

Diff for: package-lock.json

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

Diff for: package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harnessio/ff-nodejs-server-sdk",
3-
"version": "1.8.4",
3+
"version": "1.8.5",
44
"description": "Feature flags SDK for NodeJS environments",
55
"main": "dist/cjs/index.js",
66
"module": "dist/esm/index.mjs",
@@ -23,7 +23,7 @@
2323
"@types/node": "^14.17.11",
2424
"@typescript-eslint/eslint-plugin": "~5.62.0",
2525
"@typescript-eslint/parser": "~5.62.0",
26-
"esbuild": "^0.19.11",
26+
"esbuild": "^0.25.2",
2727
"eslint": "~7.30.0",
2828
"eslint-config-prettier": "~8.3.0",
2929
"eslint-plugin-jest": "~24.3.6",
@@ -61,7 +61,7 @@
6161
},
6262
"dependencies": {
6363
"axios": "^1.7.3",
64-
"axios-retry": "^3.9.1",
64+
"axios-retry": "4.5.0",
6565
"jwt-decode": "^3.1.2",
6666
"keyv": "^4.5.4",
6767
"keyv-file": "^0.3.0",

Diff for: src/client.ts

+68-7
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,14 @@ export default class Client {
130130
this.initialize(Processor.POLL);
131131
});
132132

133-
this.eventBus.on(PollerEvent.ERROR, () => {
133+
this.eventBus.on(PollerEvent.ERROR, (error) => {
134134
this.failure = true;
135-
this.eventBus.emit(Event.FAILED);
135+
this.eventBus.emit(
136+
Event.FAILED,
137+
new Error(
138+
`Failed to load flags or groups: ${error?.message ?? 'unknown'}`,
139+
),
140+
);
136141
});
137142

138143
this.eventBus.on(StreamEvent.READY, () => {
@@ -290,7 +295,6 @@ export default class Client {
290295
if (!this.waitForInitializePromise) {
291296
if (this.initialized) {
292297
this.waitForInitializePromise = Promise.resolve(this);
293-
infoSDKInitOK(this.log);
294298
} else if (!this.initialized && this.failure) {
295299
// We unblock the call even if initialization has failed. We've
296300
// already warned the user that initialization has failed with the reason and that
@@ -305,10 +309,35 @@ export default class Client {
305309
});
306310
}
307311
}
308-
309312
return this.waitForInitializePromise;
310313
}
311314

315+
private axiosRetryCondition(error) {
316+
if (axiosRetry.isNetworkOrIdempotentRequestError(error)) {
317+
return true;
318+
}
319+
320+
// retry if connection is aborted
321+
if (error.code === 'ECONNABORTED') {
322+
return true;
323+
}
324+
325+
// Auth is a POST request so not covered by isNetworkOrIdempotentRequestError and it's not an aborted connection
326+
const status = error?.response?.status;
327+
const url = error?.config?.url ?? '';
328+
329+
if (
330+
url.includes('client/auth') &&
331+
status >= 500 &&
332+
status <= 599
333+
) {
334+
return true;
335+
}
336+
337+
// Otherwise do not retry
338+
return false;
339+
}
340+
312341
private createAxiosInstanceWithRetries(options: Options): AxiosInstance {
313342
let axiosConfig: AxiosRequestConfig = {
314343
timeout: options.axiosTimeout,
@@ -327,8 +356,40 @@ export default class Client {
327356

328357
const instance: AxiosInstance = axios.create(axiosConfig);
329358
axiosRetry(instance, {
330-
retries: 3,
359+
retries: options.axiosRetries,
331360
retryDelay: axiosRetry.exponentialDelay,
361+
retryCondition: this.axiosRetryCondition,
362+
shouldResetTimeout: true,
363+
onRetry: (retryCount, error, requestConfig) => {
364+
// Get the URL without query parameters for cleaner logs
365+
const url = requestConfig.url?.split('?')[0] || 'unknown URL';
366+
const method = requestConfig.method?.toUpperCase() || 'unknown method';
367+
368+
const retryMessage =
369+
`Retrying request (${retryCount}/${options.axiosRetries}) to ${method} ${url} - ` +
370+
`Error: ${error.code || 'unknown'} - ${error.message}`;
371+
372+
// Log first retry as warn and subsequent retries as debug to reduce noise
373+
if (retryCount === 1) {
374+
this.log.warn(
375+
`${retryMessage} (subsequent retries will be logged at DEBUG level)`,
376+
);
377+
} else {
378+
this.log.debug(retryMessage);
379+
}
380+
},
381+
onMaxRetryTimesExceeded: (error, retryCount) => {
382+
// Get request details to use in error log
383+
const config = error.config || {};
384+
const axiosConfig = config as AxiosRequestConfig;
385+
const url = axiosConfig.url?.split('?')[0] || 'unknown URL';
386+
const method = axiosConfig.method?.toUpperCase() || 'unknown method';
387+
388+
this.log.warn(
389+
`Request failed permanently after ${retryCount} retries: ${method} ${url} - ` +
390+
`Error: ${error.code || 'unknown'} - ${error.message}`,
391+
);
392+
},
332393
});
333394
return instance;
334395
}
@@ -364,7 +425,9 @@ export default class Client {
364425
return;
365426
}
366427

428+
this.initialized = true;
367429
this.eventBus.emit(Event.READY);
430+
infoSDKInitOK(this.log);
368431
}
369432

370433
private async run(): Promise<void> {
@@ -420,8 +483,6 @@ export default class Client {
420483
}
421484

422485
this.log.info('finished setting up processors');
423-
this.initialized = true;
424-
infoSDKInitOK(this.log);
425486
}
426487

427488
boolVariation(

Diff for: src/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const defaultOptions: Options = {
4545
store: new FileStore(),
4646
logger: new ConsoleLog(),
4747
axiosTimeout: 30000,
48+
axiosRetries: 3,
4849
};
4950

5051
const TARGET_SEGMENT_RULES_QUERY_PARAMETER = 'v2';

Diff for: src/polling.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export class PollingProcessor {
8080
}
8181
})
8282
.catch((error) => {
83-
this.eventBus.emit(PollerEvent.ERROR, { error });
83+
this.eventBus.emit(PollerEvent.ERROR, error);
8484
})
8585
.finally(() => {
8686
// we will check one more time if processor is stopped
@@ -105,7 +105,9 @@ export class PollingProcessor {
105105
this.repository.setFlag(fc.feature, fc),
106106
);
107107
} catch (error) {
108-
this.log.error('Error loading flags', error);
108+
this.log.error(
109+
`Error loading flags (${error.code ?? "UNKNOWN"}): ${error.message}`
110+
);
109111
throw error;
110112
}
111113
}
@@ -124,7 +126,9 @@ export class PollingProcessor {
124126
this.repository.setSegment(segment.identifier, segment),
125127
);
126128
} catch (error) {
127-
this.log.error('Error loading segments', error);
129+
this.log.error(
130+
`Error loading segments (${error.code ?? "UNKNOWN"}): ${error.message}`
131+
);
128132
throw error;
129133
}
130134
}

Diff for: src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface Options {
1414
logger?: Logger;
1515
tlsTrustedCa?: string;
1616
axiosTimeout?: number;
17+
axiosRetries?: number;
1718
}
1819

1920
export interface APIConfiguration {

Diff for: src/version.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const VERSION = '1.8.4';
1+
export const VERSION = '1.8.5';

0 commit comments

Comments
 (0)