Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .changeset/in-app-wallet-transport-retry.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions packages/thirdweb/src/utils/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
81 changes: 64 additions & 17 deletions packages/thirdweb/src/utils/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>,
): Promise<Response> {
return retry(doFetch, {
backoff: true,
delay: TRANSPORT_RETRY_BASE_DELAY_MS,
retries: TRANSPORT_RETRY_ATTEMPTS,
shouldRetry: isTransportError,
});
}

/**
* @internal
*/
Expand Down Expand Up @@ -107,24 +148,30 @@ export function getClientFetch(client: ThirdwebClient, ecosystem?: Ecosystem) {
}
}

let controller: AbortController | undefined;
let abortTimeout: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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;
}
Expand Down
41 changes: 41 additions & 0 deletions packages/thirdweb/src/utils/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
27 changes: 23 additions & 4 deletions packages/thirdweb/src/utils/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>} The result of the function execution if successful.
*/

export async function retry<T>(
fn: () => Promise<T>,
options: { retries?: number; delay?: number },
options: {
retries?: number;
delay?: number;
backoff?: boolean;
shouldRetry?: (error: unknown) => boolean;
},
): Promise<T> {
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));
}
}
}
Expand Down
29 changes: 19 additions & 10 deletions packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -44,11 +45,15 @@
}
})();

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",
}),
);

Check warning on line 56 in packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts#L50-L56

Added lines #L50 - L56 were not covered by tests
Comment on lines +48 to +56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add idempotency protection before retrying these POST auth mutations.

These retries can still duplicate side effects when a request is processed server-side but the client never receives the response. Retrying sendOtp/verifyOtp without an idempotency key + server dedupe contract risks duplicate OTP sends or unintended code consumption.

Also applies to: 119-127

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts` around lines 48 -
56, The POST retries for sendOtp and verifyOtp use fetchWithTransportRetry but
generate the request payload inside the retry closure, which can cause duplicate
server-side side effects; generate a stable idempotency key (e.g. via
crypto.randomUUID() or a provided client id) once before the call and include it
in the request (Idempotency-Key header or an idempotency field on body) so the
same key is sent on every retry; ensure the idempotency key is created outside
the fetchWithTransportRetry closure and added to headers/body used by the
fetch(url, { body: stringify(body), headers, method: "POST" }) calls in sendOtp
and verifyOtp so server-side dedupe can detect retries.


if (!response.ok) {
const raw = await response.text();
Expand Down Expand Up @@ -111,11 +116,15 @@
}
})();

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",
}),
);

Check warning on line 127 in packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/wallets/in-app/web/lib/auth/otp.ts#L121-L127

Added lines #L121 - L127 were not covered by tests

if (!response.ok) {
throw new Error("Failed to verify verification code");
Expand Down
Loading