Skip to content

Commit 351b3d0

Browse files
SoulPancakeCopilot
andauthored
feat: add support for handling Retry-After header (#267)
* feat: retry after header * feat: regenerate package-lock * feat: update changelog * feat: address coderabbit comments * fix: revert whitespace changes * feat: add tests, fix existing * feat: address comments from review * feat : apply copilot suggestion for user configured maxWait Co-authored-by: Copilot <[email protected]> * fix: fix invalid suggestion,update tests and doc str * feat: tests for no retries for 501 * feat: apply suggestion from @Copilot Co-authored-by: Copilot <[email protected]> * feat: copilot nitpick blank line Co-authored-by: Copilot <[email protected]> * feat: simplify logic suggestion --------- Co-authored-by: Copilot <[email protected]>
1 parent 3fe33ac commit 351b3d0

File tree

5 files changed

+1245
-140
lines changed

5 files changed

+1245
-140
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
## [Unreleased](https://github.com/openfga/js-sdk/compare/v0.9.0...HEAD)
55

6+
- feat: add support for handling Retry-After header (#267)
7+
68
## v0.9.0
79

810
### [v0.9.0](https://github.com/openfga/js-sdk/compare/v0.8.1...v0.9.0) (2025-06-04)

common.ts

Lines changed: 135 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,20 @@ import { TelemetryHistograms } from "./telemetry/histograms";
3434
* @export
3535
*/
3636
export const DUMMY_BASE_URL = "https://example.com";
37+
// Retry-After header validation: minimum 1 second, maximum 30 minutes (1800 seconds)
38+
const MIN_RETRY_DELAY_MS = 1_000; // 1 second
39+
const MAX_RETRY_DELAY_MS = 1_800_000; // 30 minutes
40+
// Exponential backoff cap: maximum 120 seconds (2 minutes)
41+
const MAX_EXPONENTIAL_BACKOFF_MS = 120_000; // 120 seconds
3742

3843
/**
3944
*
4045
* @export
4146
* @interface RequestArgs
4247
*/
4348
export interface RequestArgs {
44-
url: string;
45-
options: any;
49+
url: string;
50+
options: any;
4651
}
4752

4853

@@ -79,15 +84,15 @@ export const setSearchParams = function (url: URL, ...objects: any[]) {
7984
};
8085

8186
/**
82-
* Check if the given MIME is a JSON MIME.
83-
* JSON MIME examples:
84-
* application/json
85-
* application/json; charset=UTF8
86-
* APPLICATION/JSON
87-
* application/vnd.company+json
88-
* @param mime - MIME (Multipurpose Internet Mail Extensions)
89-
* @return True if the given MIME is JSON, false otherwise.
90-
*/
87+
* Check if the given MIME is a JSON MIME.
88+
* JSON MIME examples:
89+
* application/json
90+
* application/json; charset=UTF8
91+
* APPLICATION/JSON
92+
* application/vnd.company+json
93+
* @param mime - MIME (Multipurpose Internet Mail Extensions)
94+
* @return True if the given MIME is JSON, false otherwise.
95+
*/
9196
const isJsonMime = (mime: string): boolean => {
9297
// eslint-disable-next-line no-control-regex
9398
const jsonMime = new RegExp("^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$", "i");
@@ -123,7 +128,7 @@ interface StringIndexable {
123128
}
124129

125130
export type CallResult<T extends ObjectOrVoid> = T & {
126-
$response: AxiosResponse<T>
131+
$response: AxiosResponse<T>
127132
};
128133

129134
export type PromiseResult<T extends ObjectOrVoid> = Promise<CallResult<T>>;
@@ -136,23 +141,116 @@ export type PromiseResult<T extends ObjectOrVoid> = Promise<CallResult<T>>;
136141
function isAxiosError(err: any): boolean {
137142
return err && typeof err === "object" && err.isAxiosError === true;
138143
}
139-
function randomTime(loopCount: number, minWaitInMs: number): number {
140-
const min = Math.ceil(2 ** loopCount * minWaitInMs);
141-
const max = Math.ceil(2 ** (loopCount + 1) * minWaitInMs);
142-
return Math.floor(Math.random() * (max - min) + min); //The maximum is exclusive and the minimum is inclusive
144+
function calculateExponentialBackoffWithJitter(retryAttempt: number, minWaitInMs: number): number {
145+
const minDelayMs = Math.ceil(2 ** retryAttempt * minWaitInMs);
146+
const maxDelayMs = Math.ceil(2 ** (retryAttempt + 1) * minWaitInMs);
147+
const randomDelayMs = Math.floor(Math.random() * (maxDelayMs - minDelayMs) + minDelayMs);
148+
return Math.min(randomDelayMs, MAX_EXPONENTIAL_BACKOFF_MS);
149+
}
150+
151+
/**
152+
* Validates if a retry delay is within acceptable bounds
153+
* @param delayMs - Delay in milliseconds
154+
* @returns True if delay is between MIN_RETRY_DELAY_MS and MAX_RETRY_DELAY_MS
155+
*/
156+
function isValidRetryDelay(delayMs: number): boolean {
157+
return delayMs >= MIN_RETRY_DELAY_MS && delayMs <= MAX_RETRY_DELAY_MS;
158+
}
159+
160+
/**
161+
* Parses the Retry-After header and returns delay in milliseconds
162+
* @param headers - HTTP response headers
163+
* @returns Delay in milliseconds if valid, undefined otherwise
164+
*/
165+
function parseRetryAfterHeader(headers: Record<string, string | string[] | undefined>): number | undefined {
166+
const retryAfterHeader = headers["retry-after"] || headers["Retry-After"];
167+
168+
if (!retryAfterHeader) {
169+
return undefined;
170+
}
171+
172+
const retryAfterHeaderValue = Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader;
173+
174+
if (!retryAfterHeaderValue) {
175+
return undefined;
176+
}
177+
178+
// Try to parse as integer (seconds)
179+
const retryAfterSeconds = parseInt(retryAfterHeaderValue, 10);
180+
if (!isNaN(retryAfterSeconds)) {
181+
const retryAfterMs = retryAfterSeconds * 1000;
182+
if (isValidRetryDelay(retryAfterMs)) {
183+
return retryAfterMs;
184+
}
185+
return undefined;
186+
}
187+
188+
// Try to parse as HTTP date
189+
try {
190+
const retryAfterDate = new Date(retryAfterHeaderValue);
191+
const currentDate = new Date();
192+
const retryDelayMs = retryAfterDate.getTime() - currentDate.getTime();
193+
194+
if (isValidRetryDelay(retryDelayMs)) {
195+
return retryDelayMs;
196+
}
197+
} catch (e) {
198+
// Invalid date format
199+
}
200+
201+
return undefined;
143202
}
144203

145204
interface WrappedAxiosResponse<R> {
146205
response?: AxiosResponse<R>;
147206
retries: number;
148207
}
149208

209+
function checkIfRetryableError(
210+
err: any,
211+
iterationCount: number,
212+
maxRetry: number
213+
): { retryable: boolean; error?: Error } {
214+
if (!isAxiosError(err)) {
215+
return { retryable: false, error: new FgaError(err) };
216+
}
217+
218+
const status = (err as any)?.response?.status;
219+
const isNetworkError = !status;
220+
221+
if (isNetworkError) {
222+
if (iterationCount > maxRetry) {
223+
return { retryable: false, error: new FgaError(err) };
224+
}
225+
return { retryable: true };
226+
}
227+
228+
if (status === 400 || status === 422) {
229+
return { retryable: false, error: new FgaApiValidationError(err) };
230+
} else if (status === 401 || status === 403) {
231+
return { retryable: false, error: new FgaApiAuthenticationError(err) };
232+
} else if (status === 404) {
233+
return { retryable: false, error: new FgaApiNotFoundError(err) };
234+
} else if (status === 429 || (status >= 500 && status !== 501)) {
235+
if (iterationCount > maxRetry) {
236+
if (status === 429) {
237+
return { retryable: false, error: new FgaApiRateLimitExceededError(err) };
238+
} else {
239+
return { retryable: false, error: new FgaApiInternalError(err) };
240+
}
241+
}
242+
return { retryable: true };
243+
} else {
244+
return { retryable: false, error: new FgaApiError(err) };
245+
}
246+
}
247+
150248
export async function attemptHttpRequest<B, R>(
151249
request: AxiosRequestConfig<B>,
152250
config: {
153-
maxRetry: number;
154-
minWaitInMs: number;
155-
},
251+
maxRetry: number;
252+
minWaitInMs: number;
253+
},
156254
axiosInstance: AxiosInstance,
157255
): Promise<WrappedAxiosResponse<R> | undefined> {
158256
let iterationCount = 0;
@@ -165,32 +263,27 @@ export async function attemptHttpRequest<B, R>(
165263
retries: iterationCount - 1,
166264
};
167265
} catch (err: any) {
168-
if (!isAxiosError(err)) {
169-
throw new FgaError(err);
266+
const { retryable, error } = checkIfRetryableError(err, iterationCount, config.maxRetry);
267+
268+
if (!retryable) {
269+
throw error;
170270
}
271+
171272
const status = (err as any)?.response?.status;
172-
if (status === 400 || status === 422) {
173-
throw new FgaApiValidationError(err);
174-
} else if (status === 401 || status === 403) {
175-
throw new FgaApiAuthenticationError(err);
176-
} else if (status === 404) {
177-
throw new FgaApiNotFoundError(err);
178-
} else if (status === 429 || status >= 500) {
179-
if (iterationCount >= config.maxRetry) {
180-
// We have reached the max retry limit
181-
// Thus, we have no choice but to throw
182-
if (status === 429) {
183-
throw new FgaApiRateLimitExceededError(err);
184-
} else {
185-
throw new FgaApiInternalError(err);
186-
}
187-
}
188-
await new Promise(r => setTimeout(r, randomTime(iterationCount, config.minWaitInMs)));
189-
} else {
190-
throw new FgaApiError(err);
273+
let retryDelayMs: number | undefined;
274+
275+
if ((status &&
276+
(status === 429 || (status >= 500 && status !== 501))) &&
277+
err.response?.headers) {
278+
retryDelayMs = parseRetryAfterHeader(err.response.headers);
279+
}
280+
if (!retryDelayMs) {
281+
retryDelayMs = calculateExponentialBackoffWithJitter(iterationCount, config.minWaitInMs);
191282
}
283+
284+
await new Promise(r => setTimeout(r, Math.min(retryDelayMs, MAX_RETRY_DELAY_MS)));
192285
}
193-
} while(iterationCount < config.maxRetry + 1);
286+
} while (iterationCount < config.maxRetry + 1);
194287
}
195288

196289
/**
@@ -263,4 +356,4 @@ export const createRequestFunction = function (axiosArgs: RequestArgs, axiosInst
263356

264357
return result;
265358
};
266-
};
359+
};

0 commit comments

Comments
 (0)