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