Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

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

- feat: add support for handling Retry-After header (#267)

## v0.9.0

### [v0.9.0](https://github.com/openfga/js-sdk/compare/v0.8.1...v0.9.0) (2025-06-04)
Expand Down
161 changes: 124 additions & 37 deletions common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export const DUMMY_BASE_URL = "https://example.com";
* @interface RequestArgs
*/
export interface RequestArgs {
url: string;
options: any;
url: string;
options: any;
}


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

/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
const isJsonMime = (mime: string): boolean => {
// eslint-disable-next-line no-control-regex
const jsonMime = new RegExp("^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$", "i");
Expand Down Expand Up @@ -123,7 +123,7 @@ interface StringIndexable {
}

export type CallResult<T extends ObjectOrVoid> = T & {
$response: AxiosResponse<T>
$response: AxiosResponse<T>
};

export type PromiseResult<T extends ObjectOrVoid> = Promise<CallResult<T>>;
Expand All @@ -139,20 +139,105 @@ function isAxiosError(err: any): boolean {
function randomTime(loopCount: number, minWaitInMs: number): number {
const min = Math.ceil(2 ** loopCount * minWaitInMs);
const max = Math.ceil(2 ** (loopCount + 1) * minWaitInMs);
return Math.floor(Math.random() * (max - min) + min); //The maximum is exclusive and the minimum is inclusive
const calculatedTime = Math.floor(Math.random() * (max - min) + min);
return Math.min(calculatedTime, 120 * 1000); // 120 seconds is the maximum time we will wait for a retry
}

function isValidRetryDelay(delayMs: number): boolean {
// Retry-After enforces >= 1 second, max is 30 minutes (1800 seconds)
const MIN_RETRY_DELAY_MS = 1_000; // 1 second
const MAX_RETRY_DELAY_MS = 1_800_000; // 30 minutes
return delayMs >= MIN_RETRY_DELAY_MS && delayMs <= MAX_RETRY_DELAY_MS;
}

function parseRetryAfterHeader(headers: Record<string, string | string[] | undefined>): number | undefined {
const retryAfter = headers["retry-after"] || headers["Retry-After"];

if (!retryAfter) {
return undefined;
}

const retryAfterValue = Array.isArray(retryAfter) ? retryAfter[0] : retryAfter;

if (!retryAfterValue) {
return undefined;
}

// Try to parse as integer (seconds)
const secondsValue = parseInt(retryAfterValue, 10);
if (!isNaN(secondsValue)) {
const msValue = secondsValue * 1000;
if (isValidRetryDelay(msValue)) {
return msValue;
}
return undefined;
}

try {
const dateValue = new Date(retryAfterValue);
const now = new Date();
const delayMs = dateValue.getTime() - now.getTime();

if (isValidRetryDelay(delayMs)) {
return delayMs;
}
} catch (e) {
// Invalid date format
}

return undefined;
}

interface WrappedAxiosResponse<R> {
response?: AxiosResponse<R>;
retries: number;
}

function checkIfRetryableError(
err: any,
iterationCount: number,
maxRetry: number
): { retryable: boolean; error?: Error } {
if (!isAxiosError(err)) {
return { retryable: false, error: new FgaError(err) };
}

const status = (err as any)?.response?.status;
const isNetworkError = !status;

if (isNetworkError) {
if (iterationCount > maxRetry) {
return { retryable: false, error: new FgaError(err) };
}
return { retryable: true };
}

if (status === 400 || status === 422) {
return { retryable: false, error: new FgaApiValidationError(err) };
} else if (status === 401 || status === 403) {
return { retryable: false, error: new FgaApiAuthenticationError(err) };
} else if (status === 404) {
return { retryable: false, error: new FgaApiNotFoundError(err) };
} else if (status === 429 || (status >= 500 && status !== 501)) {
if (iterationCount > maxRetry) {
if (status === 429) {
return { retryable: false, error: new FgaApiRateLimitExceededError(err) };
} else {
return { retryable: false, error: new FgaApiInternalError(err) };
}
}
return { retryable: true };
} else {
return { retryable: false, error: new FgaApiError(err) };
}
}

export async function attemptHttpRequest<B, R>(
request: AxiosRequestConfig<B>,
config: {
maxRetry: number;
minWaitInMs: number;
},
maxRetry: number;
minWaitInMs: number;
},
axiosInstance: AxiosInstance,
): Promise<WrappedAxiosResponse<R> | undefined> {
let iterationCount = 0;
Expand All @@ -165,32 +250,34 @@ export async function attemptHttpRequest<B, R>(
retries: iterationCount - 1,
};
} catch (err: any) {
if (!isAxiosError(err)) {
throw new FgaError(err);
const { retryable, error } = checkIfRetryableError(err, iterationCount, config.maxRetry);

if (!retryable) {
throw error;
}

const status = (err as any)?.response?.status;
if (status === 400 || status === 422) {
throw new FgaApiValidationError(err);
} else if (status === 401 || status === 403) {
throw new FgaApiAuthenticationError(err);
} else if (status === 404) {
throw new FgaApiNotFoundError(err);
} else if (status === 429 || status >= 500) {
if (iterationCount >= config.maxRetry) {
// We have reached the max retry limit
// Thus, we have no choice but to throw
if (status === 429) {
throw new FgaApiRateLimitExceededError(err);
} else {
throw new FgaApiInternalError(err);
}
let retryDelayMs: number | undefined;

if (!status) {
// Network error: exponential backoff
retryDelayMs = randomTime(iterationCount, config.minWaitInMs);
} else if (status === 429 || (status >= 500 && status !== 501)) {
if (err.response?.headers) {
retryDelayMs = parseRetryAfterHeader(err.response.headers);
}
if (!retryDelayMs) {
retryDelayMs = randomTime(iterationCount, config.minWaitInMs);
}
await new Promise(r => setTimeout(r, randomTime(iterationCount, config.minWaitInMs)));
} else {
throw new FgaApiError(err);
retryDelayMs = randomTime(iterationCount, config.minWaitInMs);
}

if (!retryDelayMs) {
await new Promise(r => setTimeout(r, retryDelayMs));
}
}
} while(iterationCount < config.maxRetry + 1);
} while (iterationCount < config.maxRetry + 1);
}

/**
Expand Down Expand Up @@ -263,4 +350,4 @@ export const createRequestFunction = function (axiosArgs: RequestArgs, axiosInst

return result;
};
};
};
Loading