diff --git a/.changeset/in-app-wallet-transport-retry.md b/.changeset/in-app-wallet-transport-retry.md new file mode 100644 index 00000000000..30561f6a4cd --- /dev/null +++ b/.changeset/in-app-wallet-transport-retry.md @@ -0,0 +1,7 @@ +--- +"thirdweb": patch +--- + +Retry SDK and in-app wallet auth requests on transient network failures + +Requests now automatically retry (with jittered exponential backoff) when they fail at the network layer before any HTTP response is received — for example a `TypeError: Network request failed` on React Native. Received HTTP responses and aborted/timed-out requests are never retried, so this does not duplicate side effects such as sending a verification code. diff --git a/packages/thirdweb/src/utils/fetch.test.ts b/packages/thirdweb/src/utils/fetch.test.ts index 2da07fa5de9..c3829cc935a 100644 --- a/packages/thirdweb/src/utils/fetch.test.ts +++ b/packages/thirdweb/src/utils/fetch.test.ts @@ -116,6 +116,20 @@ describe("getClientFetch", () => { it("should abort the request after timeout", async () => { vi.useFakeTimers(); const abortSpy = vi.spyOn(AbortController.prototype, "abort"); + // Model real fetch: reject with an AbortError when the signal aborts. The + // transport-retry layer must NOT retry aborts (timeouts), so this should + // surface as a single rejection. + vi.spyOn(global, "fetch").mockImplementation( + (_url, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener("abort", () => { + reject( + new DOMException("The operation was aborted.", "AbortError"), + ); + }); + }), + ); const clientFetch = getClientFetch(mockClient); const fetchPromise = clientFetch("https://api.thirdweb.com/test", { diff --git a/packages/thirdweb/src/utils/fetch.ts b/packages/thirdweb/src/utils/fetch.ts index e09dcfe80bb..7ddee2264ca 100644 --- a/packages/thirdweb/src/utils/fetch.ts +++ b/packages/thirdweb/src/utils/fetch.ts @@ -10,9 +10,50 @@ import { import { getServiceKey } from "./domains.js"; import { isJWT } from "./jwt/is-jwt.js"; import { IS_DEV } from "./process.js"; +import { retry } from "./retry.js"; const DEFAULT_REQUEST_TIMEOUT = 60000; +const TRANSPORT_RETRY_ATTEMPTS = 3; +const TRANSPORT_RETRY_BASE_DELAY_MS = 300; + +/** + * Returns true if the error is a network failure that occurred before any HTTP + * response was received, which `fetch` surfaces as a `TypeError`. Aborts + * (timeouts / cancellation) are not considered transport errors. + * @internal + */ +export function isTransportError(error: unknown): boolean { + // Duck-type the abort check so it works on engines where `DOMException` + // is not defined (e.g. older React Native runtimes). + if ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: unknown }).name === "AbortError" + ) { + return false; + } + return error instanceof TypeError; +} + +/** + * Retries a `fetch` thunk with jittered exponential backoff, but only when the + * request fails before receiving any HTTP response. Received responses are + * never retried. + * @internal + */ +export function fetchWithTransportRetry( + doFetch: () => Promise, +): Promise { + return retry(doFetch, { + backoff: true, + delay: TRANSPORT_RETRY_BASE_DELAY_MS, + retries: TRANSPORT_RETRY_ATTEMPTS, + shouldRetry: isTransportError, + }); +} + /** * @internal */ @@ -107,24 +148,30 @@ export function getClientFetch(client: ThirdwebClient, ecosystem?: Ecosystem) { } } - let controller: AbortController | undefined; - let abortTimeout: ReturnType | undefined; - if (requestTimeoutMs) { - controller = new AbortController(); - abortTimeout = setTimeout(() => { - controller?.abort("timeout"); - }, requestTimeoutMs); - } - - return fetch(url, { - ...restInit, - headers, - signal: controller?.signal, - }).finally(() => { - if (abortTimeout) { - clearTimeout(abortTimeout); + // Each attempt gets its own AbortController/timeout so that a timeout on + // one attempt doesn't poison subsequent transport-retry attempts. + const doFetch = () => { + let controller: AbortController | undefined; + let abortTimeout: ReturnType | undefined; + if (requestTimeoutMs) { + controller = new AbortController(); + abortTimeout = setTimeout(() => { + controller?.abort("timeout"); + }, requestTimeoutMs); } - }); + + return fetch(url, { + ...restInit, + headers, + signal: controller?.signal, + }).finally(() => { + if (abortTimeout) { + clearTimeout(abortTimeout); + } + }); + }; + + return fetchWithTransportRetry(doFetch); } return fetchWithHeaders; } diff --git a/packages/thirdweb/src/utils/retry.test.ts b/packages/thirdweb/src/utils/retry.test.ts index ff758cd5c81..67c2e3e79a1 100644 --- a/packages/thirdweb/src/utils/retry.test.ts +++ b/packages/thirdweb/src/utils/retry.test.ts @@ -48,4 +48,45 @@ describe("retry", () => { expect(endTime - startTime).toBeGreaterThanOrEqual(2 * delay); expect(mockFn).toHaveBeenCalledTimes(3); }); + + it("should not retry when shouldRetry returns false", async () => { + const error = new Error("non-retryable"); + const mockFn = vi.fn().mockRejectedValue(error); + + await expect( + retry(mockFn, { delay: 0, retries: 3, shouldRetry: () => false }), + ).rejects.toThrow("non-retryable"); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it("should retry while shouldRetry returns true and then succeed", async () => { + const error = new TypeError("Network request failed"); + const mockFn = vi.fn().mockRejectedValueOnce(error).mockResolvedValue("ok"); + + await expect( + retry(mockFn, { + delay: 0, + retries: 3, + shouldRetry: (e) => e instanceof TypeError, + }), + ).resolves.toBe("ok"); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it("should not sleep after the final attempt with backoff enabled", async () => { + const error = new Error("always fails"); + const mockFn = vi.fn().mockRejectedValue(error); + + const delay = 50; + const startTime = Date.now(); + await expect( + retry(mockFn, { backoff: true, delay, retries: 2 }), + ).rejects.toThrow(); + const elapsed = Date.now() - startTime; + + // 2 attempts => only 1 sleep (after attempt 1), so elapsed should be well + // under the time two sleeps would take. + expect(mockFn).toHaveBeenCalledTimes(2); + expect(elapsed).toBeLessThan(delay * 4); + }); }); diff --git a/packages/thirdweb/src/utils/retry.ts b/packages/thirdweb/src/utils/retry.ts index 104050c2621..3239a8b3b49 100644 --- a/packages/thirdweb/src/utils/retry.ts +++ b/packages/thirdweb/src/utils/retry.ts @@ -4,24 +4,43 @@ * @param {Function} fn - A function that returns a promise to be executed. * @param {Object} options - Configuration options for the retry behavior. * @param {number} [options.retries=1] - The number of times to retry the function before failing. - * @param {number} [options.delay=0] - The delay in milliseconds between retries. + * @param {number} [options.delay=0] - The base delay in milliseconds between retries. + * @param {boolean} [options.backoff=false] - When true, applies exponential backoff with jitter to the delay between attempts. + * @param {Function} [options.shouldRetry] - Predicate that decides whether a thrown error is retryable. When it returns false, the error is rethrown immediately without further retries. * @returns {Promise} The result of the function execution if successful. */ export async function retry( fn: () => Promise, - options: { retries?: number; delay?: number }, + options: { + retries?: number; + delay?: number; + backoff?: boolean; + shouldRetry?: (error: unknown) => boolean; + }, ): Promise { const retries = options.retries ?? 1; const delay = options.delay ?? 0; + const backoff = options.backoff ?? false; + const shouldRetry = options.shouldRetry; let lastError: Error | null = null; for (let i = 0; i < retries; i++) { try { return await fn(); } catch (error) { lastError = error as Error; - if (delay > 0) { - await new Promise((resolve) => setTimeout(resolve, delay)); + // Bail out immediately on non-retryable errors (e.g. aborts, HTTP responses). + if (shouldRetry && !shouldRetry(error)) { + throw error; + } + // Don't sleep after the final attempt. + const isLastAttempt = i === retries - 1; + if (!isLastAttempt && delay > 0) { + // Exponential backoff with full jitter to avoid thundering-herd retries. + const waitMs = backoff + ? Math.round(delay * 2 ** i * (0.5 + Math.random() * 0.5)) + : delay; + await new Promise((resolve) => setTimeout(resolve, waitMs)); } } } diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts b/packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts index 7a12a2fb49b..337c128e37b 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts @@ -1,4 +1,5 @@ import type { ThirdwebClient } from "../../../../../client/client.js"; +import { fetchWithTransportRetry } from "../../../../../utils/fetch.js"; import { stringify } from "../../../../../utils/json.js"; import { getLoginCallbackUrl, @@ -44,11 +45,15 @@ export const sendOtp = async (args: PreAuthArgsType): Promise => { } })(); - const response = await fetch(url, { - body: stringify(body), - headers, - method: "POST", - }); + // Only retried when the request fails before a response is received, so a + // verification code is never sent more than once. + const response = await fetchWithTransportRetry(() => + fetch(url, { + body: stringify(body), + headers, + method: "POST", + }), + ); if (!response.ok) { const raw = await response.text(); @@ -111,11 +116,15 @@ export const verifyOtp = async ( } })(); - const response = await fetch(url, { - body: stringify(body), - headers, - method: "POST", - }); + // Only retried when the request fails before a response is received, so the + // code is never consumed more than once. + const response = await fetchWithTransportRetry(() => + fetch(url, { + body: stringify(body), + headers, + method: "POST", + }), + ); if (!response.ok) { throw new Error("Failed to verify verification code");