From 3d32ddda01abb0cbac37e19c0ff2c00a32074f25 Mon Sep 17 00:00:00 2001
From: Shawn Erquhart <shawn@erquh.art>
Date: Sat, 8 Feb 2025 16:56:46 -0500
Subject: [PATCH 1/5] retry fetch token on network error

---
 src/react/client.tsx          | 41 ++++++++++++++++++++----
 src/react/is_network_error.ts | 60 +++++++++++++++++++++++++++++++++++
 2 files changed, 95 insertions(+), 6 deletions(-)
 create mode 100644 src/react/is_network_error.ts

diff --git a/src/react/client.tsx b/src/react/client.tsx
index 1ee4512..8d56c12 100644
--- a/src/react/client.tsx
+++ b/src/react/client.tsx
@@ -18,6 +18,9 @@ import type {
   ConvexAuthActionsContext as ConvexAuthActionsContextType,
   TokenStorage,
 } from "./index.js";
+import { isNetworkError } from "./is_network_error.js";
+
+const FETCH_TOKEN_RETRIES = 2;
 
 export const ConvexAuthActionsContext =
   createContext<ConvexAuthActionsContextType>(undefined as any);
@@ -164,16 +167,42 @@ export function AuthProvider({
     return () => browserRemoveEventListener("storage", listener);
   }, [setToken]);
 
+  const verifyCode = useCallback(
+    async (
+      args: { code: string; verifier?: string } | { refreshToken: string },
+    ) => {
+      let lastError;
+      // Retry the call if it fails due to a network error.
+      let retries = FETCH_TOKEN_RETRIES;
+      while (retries > 0) {
+        try {
+          return await client.unauthenticatedCall(
+            "auth:signIn" as unknown as SignInAction,
+            "code" in args
+              ? { params: { code: args.code }, verifier: args.verifier }
+              : args,
+          );
+        } catch (e) {
+          lastError = e;
+          if (!isNetworkError(e)) {
+            break;
+          }
+          retries--;
+          logVerbose(
+            `verifyCode failed with network error, retry ${FETCH_TOKEN_RETRIES - retries} of ${FETCH_TOKEN_RETRIES}`,
+          );
+        }
+      }
+      throw lastError;
+    },
+    [client],
+  );
+
   const verifyCodeAndSetToken = useCallback(
     async (
       args: { code: string; verifier?: string } | { refreshToken: string },
     ) => {
-      const { tokens } = await client.unauthenticatedCall(
-        "auth:signIn" as unknown as SignInAction,
-        "code" in args
-          ? { params: { code: args.code }, verifier: args.verifier }
-          : args,
-      );
+      const { tokens } = await verifyCode(args);
       logVerbose(`retrieved tokens, is null: ${tokens === null}`);
       await setToken({ shouldStore: true, tokens: tokens ?? null });
       return tokens !== null;
diff --git a/src/react/is_network_error.ts b/src/react/is_network_error.ts
new file mode 100644
index 0000000..029601b
--- /dev/null
+++ b/src/react/is_network_error.ts
@@ -0,0 +1,60 @@
+/**
+ * is-network-error@1.1.0
+ * https://github.com/sindresorhus/is-network-error
+ *
+ * Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in the
+ * Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+// eslint-disable-next-line @typescript-eslint/unbound-method
+const objectToString = Object.prototype.toString;
+
+const isError = (value: unknown): value is Error =>
+  objectToString.call(value) === "[object Error]";
+
+const errorMessages = new Set([
+  "network error", // Chrome
+  "Failed to fetch", // Chrome
+  "NetworkError when attempting to fetch resource.", // Firefox
+  "The Internet connection appears to be offline.", // Safari 16
+  "Load failed", // Safari 17+
+  "Network request failed", // `cross-fetch`
+  "fetch failed", // Undici (Node.js)
+  "terminated", // Undici (Node.js)
+]);
+
+export function isNetworkError(error: unknown): boolean {
+  const isValid =
+    error &&
+    isError(error) &&
+    error.name === "TypeError" &&
+    typeof error.message === "string";
+
+  if (!isValid) {
+    return false;
+  }
+
+  // We do an extra check for Safari 17+ as it has a very generic error message.
+  // Network errors in Safari have no stack.
+  if (error.message === "Load failed") {
+    return error.stack === undefined;
+  }
+
+  return errorMessages.has(error.message);
+}

From 0fa0635b6e4ddd2b07de1ecaf79577a52a22c740 Mon Sep 17 00:00:00 2001
From: Shawn Erquhart <shawn@erquh.art>
Date: Mon, 10 Feb 2025 17:53:32 -0500
Subject: [PATCH 2/5] add short wait for retry

---
 src/react/client.tsx | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/react/client.tsx b/src/react/client.tsx
index 8d56c12..5173cbb 100644
--- a/src/react/client.tsx
+++ b/src/react/client.tsx
@@ -191,6 +191,9 @@ export function AuthProvider({
           logVerbose(
             `verifyCode failed with network error, retry ${FETCH_TOKEN_RETRIES - retries} of ${FETCH_TOKEN_RETRIES}`,
           );
+          // Adding a short wait here but not a full backoff since this only
+          // runs on network errors.
+          await new Promise((resolve) => setTimeout(resolve, 100));
         }
       }
       throw lastError;

From d897d8a8ffabe7f87a7f95597f17e902f5ce786b Mon Sep 17 00:00:00 2001
From: Shawn Erquhart <shawn@erquh.art>
Date: Mon, 10 Feb 2025 18:10:53 -0500
Subject: [PATCH 3/5] add backoff

---
 src/react/client.tsx | 17 +++++++++--------
 1 file changed, 9 insertions(+), 8 deletions(-)

diff --git a/src/react/client.tsx b/src/react/client.tsx
index 5173cbb..7edbfe8 100644
--- a/src/react/client.tsx
+++ b/src/react/client.tsx
@@ -20,7 +20,9 @@ import type {
 } from "./index.js";
 import { isNetworkError } from "./is_network_error.js";
 
-const FETCH_TOKEN_RETRIES = 2;
+// Retry after this much time, based on the retry number.
+const RETRY_BACKOFF = [100, 1000]; // In ms
+const RETRY_JITTER = 10; // In ms
 
 export const ConvexAuthActionsContext =
   createContext<ConvexAuthActionsContextType>(undefined as any);
@@ -173,8 +175,8 @@ export function AuthProvider({
     ) => {
       let lastError;
       // Retry the call if it fails due to a network error.
-      let retries = FETCH_TOKEN_RETRIES;
-      while (retries > 0) {
+      let retry = 0;
+      while (retry < RETRY_BACKOFF.length) {
         try {
           return await client.unauthenticatedCall(
             "auth:signIn" as unknown as SignInAction,
@@ -187,13 +189,12 @@ export function AuthProvider({
           if (!isNetworkError(e)) {
             break;
           }
-          retries--;
+          const wait = RETRY_BACKOFF[retry] + RETRY_JITTER * Math.random();
           logVerbose(
-            `verifyCode failed with network error, retry ${FETCH_TOKEN_RETRIES - retries} of ${FETCH_TOKEN_RETRIES}`,
+            `verifyCode failed with network error, retry ${retry} of ${RETRY_BACKOFF.length} in ${wait}ms`,
           );
-          // Adding a short wait here but not a full backoff since this only
-          // runs on network errors.
-          await new Promise((resolve) => setTimeout(resolve, 100));
+          retry++;
+          await new Promise((resolve) => setTimeout(resolve, wait));
         }
       }
       throw lastError;

From d44d79110d20fe11595816829d6ad422011e56ac Mon Sep 17 00:00:00 2001
From: Shawn Erquhart <shawn@erquh.art>
Date: Wed, 12 Feb 2025 17:18:18 -0500
Subject: [PATCH 4/5] use client logger, improve backoff timing

---
 src/react/client.tsx    | 7 ++++---
 src/react/clientType.ts | 2 ++
 src/react/index.tsx     | 8 ++++----
 3 files changed, 10 insertions(+), 7 deletions(-)

diff --git a/src/react/client.tsx b/src/react/client.tsx
index 7edbfe8..8eade2d 100644
--- a/src/react/client.tsx
+++ b/src/react/client.tsx
@@ -21,8 +21,8 @@ import type {
 import { isNetworkError } from "./is_network_error.js";
 
 // Retry after this much time, based on the retry number.
-const RETRY_BACKOFF = [100, 1000]; // In ms
-const RETRY_JITTER = 10; // In ms
+const RETRY_BACKOFF = [500, 2000]; // In ms
+const RETRY_JITTER = 100; // In ms
 
 export const ConvexAuthActionsContext =
   createContext<ConvexAuthActionsContextType>(undefined as any);
@@ -77,6 +77,7 @@ export function AuthProvider({
     (message: string) => {
       if (verbose) {
         console.debug(`${new Date().toISOString()} ${message}`);
+        client.logger?.logVerbose(message);
       }
     },
     [verbose],
@@ -190,10 +191,10 @@ export function AuthProvider({
             break;
           }
           const wait = RETRY_BACKOFF[retry] + RETRY_JITTER * Math.random();
+          retry++;
           logVerbose(
             `verifyCode failed with network error, retry ${retry} of ${RETRY_BACKOFF.length} in ${wait}ms`,
           );
-          retry++;
           await new Promise((resolve) => setTimeout(resolve, wait));
         }
       }
diff --git a/src/react/clientType.ts b/src/react/clientType.ts
index a46c8cc..abd5de1 100644
--- a/src/react/clientType.ts
+++ b/src/react/clientType.ts
@@ -1,3 +1,4 @@
+import { ConvexReactClient } from "convex/react";
 import { FunctionReference, OptionalRestArgs } from "convex/server";
 
 export type AuthClient = {
@@ -12,4 +13,5 @@ export type AuthClient = {
     ...args: OptionalRestArgs<Action>
   ): Promise<Action["_returnType"]>;
   verbose: boolean | undefined;
+  logger?: ConvexReactClient["logger"];
 };
diff --git a/src/react/index.tsx b/src/react/index.tsx
index 3063d0a..6ebdd3f 100644
--- a/src/react/index.tsx
+++ b/src/react/index.tsx
@@ -100,12 +100,12 @@ export function ConvexAuthProvider(props: {
           return client.action(action, args);
         },
         unauthenticatedCall(action, args) {
-          return new ConvexHttpClient((client as any).address).action(
-            action,
-            args,
-          );
+          return new ConvexHttpClient((client as any).address, {
+            logger: client.logger,
+          }).action(action, args);
         },
         verbose: (client as any).options?.verbose,
+        logger: client.logger,
       }) satisfies AuthClient,
     [client],
   );

From 58e978f0a0ebb18fb61433e5700f143db5d1994e Mon Sep 17 00:00:00 2001
From: Shawn Erquhart <shawn@erquh.art>
Date: Tue, 18 Feb 2025 14:48:48 -0500
Subject: [PATCH 5/5] consume is-network-error as dependency, update comment

---
 package-lock.json             | 13 ++++++++
 package.json                  |  1 +
 src/react/client.tsx          |  5 ++-
 src/react/is_network_error.ts | 60 -----------------------------------
 4 files changed, 18 insertions(+), 61 deletions(-)
 delete mode 100644 src/react/is_network_error.ts

diff --git a/package-lock.json b/package-lock.json
index a0bbaa5..629007e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
         "arctic": "^1.2.0",
         "cookie": "^1.0.1",
         "cspell": "^8.17.2",
+        "is-network-error": "^1.1.0",
         "jose": "^5.2.2",
         "jwt-decode": "^4.0.0",
         "lucia": "^3.2.0",
@@ -5523,6 +5524,18 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/is-network-error": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz",
+      "integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/is-number": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
diff --git a/package.json b/package.json
index 819087e..ec1582a 100644
--- a/package.json
+++ b/package.json
@@ -66,6 +66,7 @@
     "arctic": "^1.2.0",
     "cookie": "^1.0.1",
     "cspell": "^8.17.2",
+    "is-network-error": "^1.1.0",
     "jose": "^5.2.2",
     "jwt-decode": "^4.0.0",
     "lucia": "^3.2.0",
diff --git a/src/react/client.tsx b/src/react/client.tsx
index 8eade2d..3535366 100644
--- a/src/react/client.tsx
+++ b/src/react/client.tsx
@@ -18,7 +18,7 @@ import type {
   ConvexAuthActionsContext as ConvexAuthActionsContextType,
   TokenStorage,
 } from "./index.js";
-import { isNetworkError } from "./is_network_error.js";
+import isNetworkError from "is-network-error";
 
 // Retry after this much time, based on the retry number.
 const RETRY_BACKOFF = [500, 2000]; // In ms
@@ -176,6 +176,9 @@ export function AuthProvider({
     ) => {
       let lastError;
       // Retry the call if it fails due to a network error.
+      // This is especially common in mobile apps where an app is backgrounded
+      // while making a call and hits a network error, but will succeed with a
+      // retry once the app is brought to the foreground.
       let retry = 0;
       while (retry < RETRY_BACKOFF.length) {
         try {
diff --git a/src/react/is_network_error.ts b/src/react/is_network_error.ts
deleted file mode 100644
index 029601b..0000000
--- a/src/react/is_network_error.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * is-network-error@1.1.0
- * https://github.com/sindresorhus/is-network-error
- *
- * Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of
- * this software and associated documentation files (the "Software"), to deal in the
- * Software without restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
- * Software, and to permit persons to whom the Software is furnished to do so,
- * subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
- * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
- * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
- * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-// eslint-disable-next-line @typescript-eslint/unbound-method
-const objectToString = Object.prototype.toString;
-
-const isError = (value: unknown): value is Error =>
-  objectToString.call(value) === "[object Error]";
-
-const errorMessages = new Set([
-  "network error", // Chrome
-  "Failed to fetch", // Chrome
-  "NetworkError when attempting to fetch resource.", // Firefox
-  "The Internet connection appears to be offline.", // Safari 16
-  "Load failed", // Safari 17+
-  "Network request failed", // `cross-fetch`
-  "fetch failed", // Undici (Node.js)
-  "terminated", // Undici (Node.js)
-]);
-
-export function isNetworkError(error: unknown): boolean {
-  const isValid =
-    error &&
-    isError(error) &&
-    error.name === "TypeError" &&
-    typeof error.message === "string";
-
-  if (!isValid) {
-    return false;
-  }
-
-  // We do an extra check for Safari 17+ as it has a very generic error message.
-  // Network errors in Safari have no stack.
-  if (error.message === "Load failed") {
-    return error.stack === undefined;
-  }
-
-  return errorMessages.has(error.message);
-}