Skip to content

Commit d475aa3

Browse files
committed
feat: increase default get timeout to 10s, add 3 retries
1 parent 6302392 commit d475aa3

5 files changed

Lines changed: 127 additions & 26 deletions

File tree

packages/node-sdk/src/client.ts

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import BatchBuffer from "./batch-buffer";
66
import cache from "./cache";
77
import {
88
API_BASE_URL,
9+
API_TIMEOUT_MS,
910
BUCKET_LOG_PREFIX,
1011
FEATURE_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
1112
FEATURES_REFETCH_MS,
1213
loadConfig,
1314
SDK_VERSION,
1415
SDK_VERSION_HEADER_NAME,
1516
} from "./config";
16-
import fetchClient from "./fetch-http-client";
17+
import fetchClient, { withRetry } from "./fetch-http-client";
1718
import { subscribe as triggerOnExit } from "./flusher";
1819
import { newRateLimiter } from "./rate-limiter";
1920
import type {
@@ -116,6 +117,8 @@ export class BucketClient {
116117
rateLimiter: ReturnType<typeof newRateLimiter>;
117118
offline: boolean;
118119
configFile?: string;
120+
featuresFetchRetries: number;
121+
fetchTimeoutMs: number;
119122
};
120123

121124
private _initialize = once(async () => {
@@ -140,7 +143,8 @@ export class BucketClient {
140143
* @param options.batchOptions - The options for the batch buffer (optional).
141144
* @param options.featureOverrides - The feature overrides to use for the client (optional).
142145
* @param options.configFile - The path to the config file (optional).
143-
146+
* @param options.featuresFetchRetries - Number of retries for fetching features (optional, defaults to 3).
147+
* @param options.fetchTimeoutMs - Timeout for fetching features (optional, defaults to 10000ms).
144148
*
145149
* @throws An error if the options are invalid.
146150
**/
@@ -182,6 +186,20 @@ export class BucketClient {
182186
"configFile must be a string",
183187
);
184188

189+
ok(
190+
options.featuresFetchRetries === undefined ||
191+
(Number.isInteger(options.featuresFetchRetries) &&
192+
options.featuresFetchRetries >= 0),
193+
"featuresFetchRetries must be a non-negative integer",
194+
);
195+
196+
ok(
197+
options.fetchTimeoutMs === undefined ||
198+
(Number.isInteger(options.fetchTimeoutMs) &&
199+
options.fetchTimeoutMs >= 0),
200+
"fetchTimeoutMs must be a non-negative integer",
201+
);
202+
185203
if (!options.configFile) {
186204
options.configFile =
187205
(process.env.BUCKET_CONFIG_FILE ??
@@ -266,6 +284,8 @@ export class BucketClient {
266284
typeof config.featureOverrides === "function"
267285
? config.featureOverrides
268286
: () => config.featureOverrides,
287+
featuresFetchRetries: options.featuresFetchRetries ?? 3,
288+
fetchTimeoutMs: options.fetchTimeoutMs ?? API_TIMEOUT_MS,
269289
};
270290

271291
if ((config.batchOptions?.flushOnExit ?? true) && !this._config.offline) {
@@ -643,35 +663,45 @@ export class BucketClient {
643663
* Sends a GET request to the specified path.
644664
*
645665
* @param path - The path to send the request to.
666+
* @param retries - Optional number of retries for the request.
646667
*
647668
* @returns The response from the server.
648669
* @throws An error if the path is invalid.
649670
**/
650-
private async get<TResponse>(path: string) {
671+
private async get<TResponse>(path: string, retries: number = 3) {
651672
ok(typeof path === "string" && path.length > 0, "path must be a string");
652673

653674
try {
654675
const url = this.buildUrl(path);
655-
const response = await this._config.httpClient.get<
656-
TResponse & { success: boolean }
657-
>(url, this._config.headers);
658-
659-
this._config.logger?.debug(`get request to "${url}"`, response);
660-
661-
if (!response.ok || !isObject(response.body) || !response.body.success) {
662-
this._config.logger?.warn(
663-
`invalid response received from server for "${url}"`,
664-
response,
665-
);
666-
667-
return undefined;
668-
}
669-
670-
const { success: _, ...result } = response.body;
671-
return result as TResponse;
676+
return await withRetry(
677+
async () => {
678+
const response = await this._config.httpClient.get<
679+
TResponse & { success: boolean }
680+
>(url, this._config.headers, this._config.fetchTimeoutMs);
681+
682+
this._config.logger?.debug(`get request to "${url}"`, response);
683+
684+
if (
685+
!response.ok ||
686+
!isObject(response.body) ||
687+
!response.body.success
688+
) {
689+
this._config.logger?.warn(
690+
`invalid response received from server for "${url}"`,
691+
response,
692+
);
693+
return undefined;
694+
}
695+
const { success: _, ...result } = response.body;
696+
return result as TResponse;
697+
},
698+
retries,
699+
1000,
700+
10000,
701+
);
672702
} catch (error) {
673703
this._config.logger?.error(
674-
`get request to "${path}" failed with error`,
704+
`get request to "${path}" failed with error after ${retries} retries`,
675705
error,
676706
);
677707
return undefined;
@@ -849,7 +879,10 @@ export class BucketClient {
849879
this._config.staleWarningInterval,
850880
this._config.logger,
851881
async () => {
852-
const res = await this.get<FeaturesAPIResponse>("features");
882+
const res = await this.get<FeaturesAPIResponse>(
883+
"features",
884+
this._config.featuresFetchRetries,
885+
);
853886

854887
if (!isObject(res) || !Array.isArray(res?.features)) {
855888
return undefined;

packages/node-sdk/src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { isObject, ok } from "./utils";
88
export const API_BASE_URL = "https://front.bucket.co";
99
export const SDK_VERSION_HEADER_NAME = "bucket-sdk-version";
1010
export const SDK_VERSION = `node-sdk/${version}`;
11-
export const API_TIMEOUT_MS = 5000;
11+
export const API_TIMEOUT_MS = 10000;
1212
export const END_FLUSH_TIMEOUT_MS = 5000;
1313

1414
export const BUCKET_LOG_PREFIX = "[Bucket]";

packages/node-sdk/src/fetch-http-client.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const fetchClient: HttpClient = {
1313
url: string,
1414
headers: Record<string, string>,
1515
body: TBody,
16+
timeoutMs: number = API_TIMEOUT_MS,
1617
) => {
1718
ok(typeof url === "string" && url.length > 0, "URL must be a string");
1819
ok(typeof headers === "object", "Headers must be an object");
@@ -21,7 +22,7 @@ const fetchClient: HttpClient = {
2122
method: "post",
2223
headers,
2324
body: JSON.stringify(body),
24-
signal: AbortSignal.timeout(API_TIMEOUT_MS),
25+
signal: AbortSignal.timeout(timeoutMs),
2526
});
2627

2728
const json = await response.json();
@@ -32,14 +33,18 @@ const fetchClient: HttpClient = {
3233
};
3334
},
3435

35-
get: async <TResponse>(url: string, headers: Record<string, string>) => {
36+
get: async <TResponse>(
37+
url: string,
38+
headers: Record<string, string>,
39+
timeoutMs: number = API_TIMEOUT_MS,
40+
) => {
3641
ok(typeof url === "string" && url.length > 0, "URL must be a string");
3742
ok(typeof headers === "object", "Headers must be an object");
3843

3944
const response = await fetch(url, {
4045
method: "get",
4146
headers,
42-
signal: AbortSignal.timeout(API_TIMEOUT_MS),
47+
signal: AbortSignal.timeout(timeoutMs),
4348
});
4449

4550
const json = await response.json();
@@ -51,4 +56,44 @@ const fetchClient: HttpClient = {
5156
},
5257
};
5358

59+
/**
60+
* Implements exponential backoff retry logic for async functions.
61+
*
62+
* @param fn - The async function to retry.
63+
* @param maxRetries - Maximum number of retry attempts.
64+
* @param baseDelay - Base delay in milliseconds before retrying.
65+
* @param maxDelay - Maximum delay in milliseconds.
66+
* @returns The result of the function call or throws an error if all retries fail.
67+
*/
68+
export async function withRetry<T>(
69+
fn: () => Promise<T>,
70+
maxRetries: number,
71+
baseDelay: number,
72+
maxDelay: number,
73+
): Promise<T> {
74+
let lastError: unknown;
75+
76+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
77+
try {
78+
return await fn();
79+
} catch (error) {
80+
lastError = error;
81+
82+
if (attempt === maxRetries) {
83+
break;
84+
}
85+
86+
// Calculate exponential backoff with jitter
87+
const delay = Math.min(
88+
maxDelay,
89+
baseDelay * Math.pow(2, attempt) * (0.8 + Math.random() * 0.4),
90+
);
91+
92+
await new Promise((resolve) => setTimeout(resolve, delay));
93+
}
94+
}
95+
96+
throw lastError;
97+
}
98+
5499
export default fetchClient;

packages/node-sdk/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ export interface HttpClient {
386386
get<TResponse>(
387387
url: string,
388388
headers: Record<string, string>,
389+
timeoutMs: number,
389390
): Promise<HttpClientResponse<TResponse>>;
390391
}
391392

@@ -531,6 +532,18 @@ export type ClientOptions = {
531532
**/
532533
httpClient?: HttpClient;
533534

535+
/**
536+
* The timeout in milliseconds for fetching feature targeting data (optional).
537+
* Default is 10000 ms.
538+
**/
539+
fetchTimeoutMs?: number;
540+
541+
/**
542+
* Number of times to retry fetching feature definitions (optional).
543+
* Default is 3 times.
544+
**/
545+
featuresFetchRetries?: number;
546+
534547
/**
535548
* The options for the batch buffer (optional).
536549
* If not provided, the default options are used.

packages/node-sdk/test/client.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { evaluateFeatureRules } from "@bucketco/flag-evaluation";
1515
import { BoundBucketClient, BucketClient } from "../src";
1616
import {
1717
API_BASE_URL,
18+
API_TIMEOUT_MS,
1819
BATCH_INTERVAL_MS,
1920
BATCH_MAX_SIZE,
2021
FEATURE_EVENT_RATE_LIMITER_WINDOW_SIZE_MS,
@@ -84,6 +85,7 @@ const validOptions: ClientOptions = {
8485
logger,
8586
httpClient,
8687
fallbackFeatures,
88+
featuresFetchRetries: 2,
8789
batchOptions: {
8890
maxSize: 99,
8991
intervalMs: 100,
@@ -275,6 +277,7 @@ describe("BucketClient", () => {
275277
isEnabled: true,
276278
},
277279
});
280+
expect(client["_config"].featuresFetchRetries).toBe(2);
278281
});
279282

280283
it("should route messages to the supplied logger", () => {
@@ -970,6 +973,7 @@ describe("BucketClient", () => {
970973
expect(httpClient.get).toHaveBeenCalledWith(
971974
`https://api.example.com/features`,
972975
expectedHeaders,
976+
API_TIMEOUT_MS,
973977
);
974978
});
975979
});
@@ -2291,6 +2295,7 @@ describe("BucketClient", () => {
22912295
expect(httpClient.get).toHaveBeenCalledWith(
22922296
"https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1",
22932297
expectedHeaders,
2298+
API_TIMEOUT_MS,
22942299
);
22952300
});
22962301

@@ -2302,6 +2307,7 @@ describe("BucketClient", () => {
23022307
expect(httpClient.get).toHaveBeenCalledWith(
23032308
"https://api.example.com/features/evaluated?",
23042309
expectedHeaders,
2310+
API_TIMEOUT_MS,
23052311
);
23062312
});
23072313

@@ -2373,6 +2379,7 @@ describe("BucketClient", () => {
23732379
expect(httpClient.get).toHaveBeenCalledWith(
23742380
"https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1&key=feature1",
23752381
expectedHeaders,
2382+
API_TIMEOUT_MS,
23762383
);
23772384
});
23782385

@@ -2382,6 +2389,7 @@ describe("BucketClient", () => {
23822389
expect(httpClient.get).toHaveBeenCalledWith(
23832390
"https://api.example.com/features/evaluated?key=feature1",
23842391
expectedHeaders,
2392+
API_TIMEOUT_MS,
23852393
);
23862394
});
23872395

@@ -2617,6 +2625,7 @@ describe("BoundBucketClient", () => {
26172625
expect(httpClient.get).toHaveBeenCalledWith(
26182626
"https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value",
26192627
expectedHeaders,
2628+
API_TIMEOUT_MS,
26202629
);
26212630
});
26222631

@@ -2641,6 +2650,7 @@ describe("BoundBucketClient", () => {
26412650
expect(httpClient.get).toHaveBeenCalledWith(
26422651
"https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value&key=feature1",
26432652
expectedHeaders,
2653+
API_TIMEOUT_MS,
26442654
);
26452655
});
26462656
});

0 commit comments

Comments
 (0)