Retry access token fetch to handle iOS background resume#346
Conversation
When an iOS app is backgrounded and later resumed, the network stack may not be immediately available, causing token fetches to fail with "Network request failed". Retry up to 3 times with exponential backoff (100ms, 200ms, 400ms) before giving up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughA single-file modification that enhances token fetching resilience by implementing exponential backoff retry logic in the Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
commit: |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/react/index.tsx`:
- Around line 143-149: The try block calling authClient.convex.token({
fetchOptions: { throw: false } }) currently ignores the returned error field and
treats failures as a null token; update the logic in that try (the call to
authClient.convex.token, the extraction of data and token, and the subsequent
setCachedToken) to inspect the response.error and if present throw it (or a new
Error with the error message) so the surrounding retry loop will retry; only
setCachedToken and return the token when there is no response.error.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
| const { data } = await authClient.convex.token({ | ||
| fetchOptions: { throw: false }, | ||
| }); | ||
| const token = data?.token || null; | ||
| setCachedToken(token); | ||
| return token; | ||
| } catch (e) { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the complete context around the retry logic
cd /tmp && git ls-files "src/react/index.tsx" | head -1Repository: get-convex/better-auth
Length of output: 134
🏁 Script executed:
# Get the actual implementation of the retry wrapper
sed -n '130,165p' src/react/index.tsx | cat -nRepository: get-convex/better-auth
Length of output: 1682
🏁 Script executed:
# Search for other usages of authClient.convex.token in the codebase
rg "authClient\.convex\.token" -A 3 -B 3Repository: get-convex/better-auth
Length of output: 529
🌐 Web query:
Better Auth client error handling with throw: false fetchOptions
💡 Result:
In Better Auth, the client uses better-fetch under the hood for API requests. By default (throw: false, implicit), client methods like authClient.signIn.email return a response object { data, error } instead of throwing on HTTP errors or API failures. You handle errors by checking if (error) { ... }. To enable throwing errors (for try/catch or promise rejection compatibility), set throw: true in fetchOptions. This makes the fetch throw BetterFetchError on failure, returning data directly on success. Usage examples: 1. Global configuration in createAuthClient: import { createAuthClient } from "better-auth/client"; export const authClient = createAuthClient({ fetchOptions: { throw: true // Throws on errors globally } }); 2. Per-call in auth methods: const result = await authClient.signIn.email({ email: "user@example.com", password: "pass123" }, { fetchOptions: { throw: true // Throws on error for this call } }); // On success: result is data directly // On error: throws BetterFetchError with status, statusText, error details With throw: true: try { const session = await authClient.getSession({ fetchOptions: { throw: true } }); // Use session directly } catch (error) { if (error instanceof BetterFetchError) { console.error(error.status, error.message); } } For throw: false (default, better error handling without try/catch): const { data: session, error } = await authClient.getSession; if (error) { console.error(error.status, error.message, error.code); // Use authClient.$ERROR_CODES for translation // Show toast, etc. } This throw: false approach is recommended for Better Auth's {data, error} pattern, avoiding unhandled promise rejections. Use onError/onSuccess callbacks in fetchOptions for additional handling: await authClient.signIn.email({ ... }, { fetchOptions: { throw: false, onError: (ctx) => { console.error(ctx.error); }, onSuccess: (ctx) => { console.log(ctx.data); } } }); Note: Client promises do not reject on errors by design (even with throw: true in some cases pre-2026 fixes); wrap in new Promise for libraries like toast.promise if needed.
Citations:
- 1: throwOnError mode? better-auth/better-auth#2456).
- 2: https://better-auth.com/docs/concepts/client
- 3: better-auth/better-auth@275ca46fe
- 4: https://www.mintlify.com/better-auth/better-fetch/api/types
- 5: https://www.mintlify.com/better-auth/better-fetch/api/better-fetch
- 6: https://better-auth.vercel.app/docs/concepts/client
🌐 Web query:
Better Fetch throw: false behavior error responses
💡 Result:
In the Better Fetch library (@better-fetch/fetch), the throw: false option (default behavior) means the fetch request returns an object with data (success response or null) and error (error details or null) instead of throwing an exception on HTTP errors (non-2xx status codes) or network failures. When throw: false: - Success (2xx): { data: parsedResponse, error: null } - Error (4xx/5xx or network error): { data: null, error: { status, statusText, message? } } - The error object always includes status and statusText; message is from response body if JSON. To enable throwing (like native fetch manual check), use throw: true, which returns data directly on success and throws BetterFetchError on failure. For TypeScript generics with throw: true, pass false as the second generic (error type) to infer non-union data type: betterFetch<DataType, false>(url, { throw: true }). Network errors (e.g., ECONNREFUSED) may throw uncaught exceptions instead of returning error object (open issue #75), unlike HTTP errors. Example: const { data, error } = await betterFetch(url); // throw: false (default) if (error) { /* handle */ } Official docs: https://better-fetch.vercel.app/docs/handling-errors
Citations:
- 1: https://better-fetch.vercel.app/docs/handling-errors
- 2: https://www.mintlify.com/better-auth/better-fetch/api/better-fetch
- 3: https://www.mintlify.com/better-auth/better-fetch/api/types
- 4: better-auth/better-fetch@a097077
- 5: onError hook not triggered for network errors (ECONNREFUSED, DNS failures, etc.) better-auth/better-fetch#75
- 6: https://better-fetch.vercel.app/docs/getting-started
Handle Better Auth error responses in retry loop.
With fetchOptions: { throw: false }, Better Auth returns { data, error } instead of throwing on failure. The code ignores the error field and treats failures as successful null tokens, bypassing the retry logic. Extract and throw error to ensure transient failures are retried across the 100/200/400ms backoff intervals.
🐛 Proposed fix
const { data, error } = await authClient.convex.token({
fetchOptions: { throw: false },
});
+ if (error) {
+ throw error;
+ }
const token = data?.token || null;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/react/index.tsx` around lines 143 - 149, The try block calling
authClient.convex.token({ fetchOptions: { throw: false } }) currently ignores
the returned error field and treats failures as a null token; update the logic
in that try (the call to authClient.convex.token, the extraction of data and
token, and the subsequent setCachedToken) to inspect the response.error and if
present throw it (or a new Error with the error message) so the surrounding
retry loop will retry; only setCachedToken and return the token when there is no
response.error.
|
I think this is actually fixed with better-auth/better-auth#8437. I'm going to close this out and wait for that fix to land when we upgrade to better-auth 1.6.x (#323). |
The bridge's fetchAccessToken called setCachedToken inside its `/convex/token` .then chain. The setState scheduled a re-render that landed inside Convex's fetchTokenAndGuardAgainstRace await window (authentication_manager.ts), bumped configVersion, and made the in-flight setConfig bail with isFromOutdatedConfig:true. The WebSocket stayed paused and useConvexAuth().isAuthenticated never settled to true on Hermes V1 native async (Expo SDK 56 canary 2026-05-05+ since expo/expo#45345 dropped @babel/plugin-transform-async-to-generator from the Hermes V1 preset). Drop the async keyword and wrap the body in new Promise(...). The wrapping Promise resolves via thenable adoption, which inserts the same microtask hop the regenerator runtime's _asyncToGenerator provides. The hop pushes the React re-render past the consumer's await continuation, so the next setAuth fires after setConfig completes instead of inside its await window. Refs: - get-convex#168 (Expo, open since Nov 2025) - get-convex#303 (Next.js, open since Mar 2026) - get-convex#329 (closed; addressed session-rotation leg of the same race class) - get-convex#346 (closed; iOS background resume)
… on Hermes V1 The /convex/token response triggers a session rotation (via Better Auth's Set-Cookie processing) plus a setCachedToken call inside the bridge's .then. The next render rebuilds fetchAccessToken's useCallback (keyed on [sessionId]) and fires ConvexAuthStateFirstEffect's client.setAuth a second time. On Hermes V1 native async (Expo SDK 56 canary 2026-05-05+ since expo/expo#45345 dropped @babel/plugin-transform-async-to-generator), that second setAuth lands inside the first setConfig's await window in authentication_manager.ts. fetchTokenAndGuardAgainstRace bumps configVersion on entry and the original await sees the stale value, returning isFromOutdatedConfig: true. setConfig bails without resumeSocket() and the chain repeats. Drop the async keyword and wrap the body in new Promise(executor) directly. The constructor's resolve(thenable) schedules a NewPromiseResolveThenableJob microtask, the same hop regenerator's _asyncToGenerator provides. With the hop in place the second setAuth lands after the first setConfig finishes rather than during its await window. Refs: - get-convex#168 (Expo, open since Nov 2025) - get-convex#303 (Next.js, open since Mar 2026) - get-convex#329 (closed; addressed session-rotation leg of the same race class) - get-convex#346 (closed; iOS background resume)
When an iOS app is backgrounded and later resumed, the network stack may not be immediately available, causing token fetches to fail with "Network request failed". Retry up to 3 times with exponential backoff (100ms, 200ms, 400ms) before giving up.
Summary by CodeRabbit