Skip to content

Retry access token fetch to handle iOS background resume#346

Closed
dowski wants to merge 1 commit into
mainfrom
dowski/retry-access-token
Closed

Retry access token fetch to handle iOS background resume#346
dowski wants to merge 1 commit into
mainfrom
dowski/retry-access-token

Conversation

@dowski

@dowski dowski commented Apr 22, 2026

Copy link
Copy Markdown

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

  • Bug Fixes
    • Improved authentication token retrieval reliability with automatic retry logic and progressive backoff timing. The system now automatically retries failed token requests with increasing delays, significantly enhancing resilience during temporary network issues or service interruptions. Strengthened concurrent request handling ensures more robust and stable authentication across the platform.

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>
@vercel

vercel Bot commented Apr 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
better-auth Ready Ready Preview, Comment Apr 22, 2026 9:09pm

Request Review

@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

A single-file modification that enhances token fetching resilience by implementing exponential backoff retry logic in the fetchAccessToken function, supporting up to four attempts, and adjusts the pending token reference cleanup to the async wrapper's finally block.

Changes

Cohort / File(s) Summary
Token Fetch Retry Logic
src/react/index.tsx
Added exponential backoff retry mechanism (up to 4 attempts with delays of 100ms × 2^attempt) to token fetching. Moved pendingTokenRef clearing from the prior promise chain to the async wrapper's finally block, ensuring concurrent fetch prevention logic executes after retry attempts complete.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • PR #241: Modifies the same fetchAccessToken flow in src/react/index.tsx to adjust token call behavior while preserving data/error shape.
  • PR #267: Alters fetchAccessToken and pendingTokenRef deduplication logic with similar pending promise clearing adjustments.
  • PR #223: Updates fetchAccessToken in src/react/index.tsx and adjusts pendingTokenRef clearing patterns.

Suggested reviewers

  • erquhart
  • devstojko

Poem

🐰 A token thrice or four times sought,
With backoff's patient, gentle thought,
Exponential delays between,
The finest retry dance I've seen!
Pending promises now stay clean,
With finally blocks in between! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding retry logic to the access token fetch to handle iOS background resume scenarios, which aligns with the actual implementation of exponential backoff retry attempts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dowski/retry-access-token

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new

pkg-pr-new Bot commented Apr 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/get-convex/better-auth/@convex-dev/better-auth@346

commit: c6501c4

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0fafd1c6-8d64-4789-8dbe-dfaab45fb5b6

📥 Commits

Reviewing files that changed from the base of the PR and between 458b794 and c6501c4.

📒 Files selected for processing (1)
  • src/react/index.tsx

Comment thread src/react/index.tsx
Comment on lines +143 to +149
const { data } = await authClient.convex.token({
fetchOptions: { throw: false },
});
const token = data?.token || null;
setCachedToken(token);
return token;
} catch (e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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 -1

Repository: 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 -n

Repository: 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 3

Repository: 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:


🌐 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:


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.

@dowski

dowski commented Apr 24, 2026

Copy link
Copy Markdown
Author

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).

@dowski dowski closed this Apr 24, 2026
ramonclaudio added a commit to ramonclaudio/convex-better-auth-fork that referenced this pull request May 8, 2026
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)
ramonclaudio added a commit to ramonclaudio/convex-better-auth-fork that referenced this pull request May 8, 2026
… 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant