@joinmeow/cognito-passwordless-auth
Version:
Passwordless authentication with Amazon Cognito: FIDO2 (WebAuthn, support for Passkeys)
100 lines (99 loc) • 4.49 kB
JavaScript
/**
* Return a fetch function that will retry on network errors, HTTP 5xx,
* and specific retryable 400 errors (TooManyRequestsException,
* LimitExceededException, CodeDeliveryFailureException), up to `maxRetries`
* times with exponential backoff.
*
* Uses `Response.clone()` to inspect 400 error bodies without consuming the
* original response body.
*/
export function createFetchWithRetry(fetchFn, debugFn, maxRetries = 3, baseDelayMs = 1000) {
const retryableErrors = new Set([
"TooManyRequestsException",
"LimitExceededException",
"CodeDeliveryFailureException",
]);
return async (input, init) => {
// Helper to wait with abort support
const wait = (ms) => {
if (init?.signal?.aborted) {
return Promise.reject(new DOMException("Aborted", "AbortError"));
}
return new Promise((resolve, reject) => {
const id = setTimeout(resolve, ms);
init?.signal?.addEventListener("abort", () => {
clearTimeout(id);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
};
for (let attempt = 1; attempt <= maxRetries; attempt++) {
// Abort before starting the attempt
if (init?.signal?.aborted) {
throw new DOMException("Aborted", "AbortError");
}
try {
const res = await fetchFn(input, init);
if (res.ok) {
return res;
}
if (res.status === 400) {
// Retry on specific 400 errors by cloning the response
const nativeRes = res;
if (typeof nativeRes.clone === "function") {
let retryErrorType;
try {
const cloned = nativeRes.clone();
const errObj = (await cloned.json());
const errorType = errObj.__type;
if (errorType && retryableErrors.has(errorType)) {
retryErrorType = errorType; // mark for retry after we leave the try/catch
}
}
catch (parseError) {
debugFn?.("Failed to parse error response for retryable type:", parseError);
}
// Evaluate retry after JSON parse to avoid the thrown error being
// swallowed by the try/catch above.
if (retryErrorType) {
if (attempt === maxRetries) {
// On the last attempt, surface the response (like we do for 5xx)
return res;
}
// Throw to trigger retry logic outside of the nested try/catch
throw new Error(retryErrorType);
}
}
return res;
}
// Retry on server errors (5xx), status 0 (network error), or undefined status
const status = res.status;
if (status === undefined || status === 0 || status >= 500) {
if (attempt === maxRetries) {
// Final attempt: return response so caller can parse error body
return res;
}
// Retry on transient server or network error
throw new Error(`ServerError:${status}`);
}
return res;
}
catch (error) {
if (init?.signal?.aborted ||
(error instanceof Error && error.name === "AbortError")) {
throw error;
}
if (attempt === maxRetries) {
throw error;
}
debugFn?.(`fetchWithRetry attempt ${attempt}/${maxRetries} failed:`, error);
// Exponential backoff for all errors: 1s, 2s, 4s
const backoff = baseDelayMs * Math.pow(2, attempt - 1);
debugFn?.(`Retrying in ${backoff}ms...`);
await wait(backoff);
}
}
// Fallback
return fetchFn(input, init);
};
}