[GH-ISSUE #987] Fetch Requests Don't Retry on Network Failure (need exponential backoff algorithm) #17167

Closed
opened 2026-04-15 15:09:28 -05:00 by GiteaMirror · 14 comments
Owner

Originally created by @daveycodez on GitHub (Dec 22, 2024).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/987

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

If the user is on mobile using a static application (e.g. Capacitor), the auth API is hosted on a production server. If the user's connection drops briefly when trying to fetch /api/auth/get-session, it will fail. There is currently no mechanism to retry fetching the session. This appears to the end user as if they've logged out, and they must refresh (hope the app has a built in way to do a full page refresh). They will have to quit the app or PWA and re-open it entirely, or try to log back in. Navigating the app won't attempt to refresh the session. At this point it's important that it retries and isPending is still set to true.

This may happen for anyone whose connection drops when trying to get session on any browser, but that is the easiest example where it will occur often, mobile connections drop all the time and if the user opens the static app they shouldn't think they've been logged out. The expected behavior is that they see spinners, Skeletons or stale data.

SWR manages this by using the exponential backoff algorithm

You can see from their docs here: https://swr.vercel.app/docs/error-handling

My suggestion is one of the following -

  1. Use SWR for all fetch requests in the authClient (would also allow us to use our own SWRConfig)
  2. Allow us to override the default fetcher in the auth-client configuration
  3. Use a custom fetchWithRetry function

If C. here is my function that also supports "X-RateLimit-Reset" header. It defaults to exponential backoff method, waiting 5 seconds before the first retry. I do think this should be baked in to the auth client itself somehow, I think it will need to be a standard feature for get-session especially, but other requests may also benefit from this.

async function fetchWithRetry(
    url: string,
    options?: RequestInit,
    retryCount = 5,
    delay = 5000
) {
    const sleep = (ms: number): Promise<void> =>
        new Promise((resolve) => setTimeout(resolve, ms))

    let attempts = 0
    let waitTime = 0

    while (attempts < retryCount) {
        attempts++

        try {
            const response = await fetch(url, options)

            // Check if response indicates rate limiting
            if (response.status === 429 && attempts < retryCount) {
                const resetTime = response.headers.get("X-RateLimit-Reset")

                if (resetTime) {
                    // Calculate time to wait based on the current time and the reset time
                    const currentTime = Math.floor(Date.now())
                    waitTime = (Number(resetTime) - currentTime)
                }
            } else {
                return response
            }
        } catch (error) {
            if (attempts >= retryCount) {
                throw error
            }
        }

        // Exponential backoff
        if (waitTime <= 0) {
            waitTime = delay * Math.pow(2, attempts)
        }

        await sleep(waitTime)
    }

    throw new Error("Max retries reached")
}

Current vs. Expected behavior

Refetch get-session using exponential backoff algorithm when it fails due to network error or 429 rate limit response.

What version of Better Auth are you using?

1.0.22

Provide environment information

Next.js

Which area(s) are affected? (Select all that apply)

Backend, Client

Auth config (if applicable)

No response

Additional context

No response

Originally created by @daveycodez on GitHub (Dec 22, 2024). Original GitHub issue: https://github.com/better-auth/better-auth/issues/987 ### Is this suited for github? - [X] Yes, this is suited for github ### To Reproduce If the user is on mobile using a static application (e.g. Capacitor), the auth API is hosted on a production server. If the user's connection drops briefly when trying to fetch /api/auth/get-session, it will fail. There is currently no mechanism to retry fetching the session. This appears to the end user as if they've logged out, and they must refresh (hope the app has a built in way to do a full page refresh). They will have to quit the app or PWA and re-open it entirely, or try to log back in. Navigating the app won't attempt to refresh the session. At this point it's important that it retries and isPending is still set to true. This may happen for anyone whose connection drops when trying to get session on any browser, but that is the easiest example where it will occur often, mobile connections drop all the time and if the user opens the static app they shouldn't think they've been logged out. The expected behavior is that they see spinners, Skeletons or stale data. [SWR](https://swr.vercel.app/) manages this by using the [exponential backoff algorithm](https://en.wikipedia.org/wiki/Exponential_backoff) You can see from their docs here: https://swr.vercel.app/docs/error-handling My suggestion is one of the following - 1. Use SWR for all fetch requests in the authClient (would also allow us to use our own SWRConfig) 2. Allow us to override the default fetcher in the auth-client configuration 3. Use a custom fetchWithRetry function If C. here is my function that also supports "X-RateLimit-Reset" header. It defaults to exponential backoff method, waiting 5 seconds before the first retry. I do think this should be baked in to the auth client itself somehow, I think it will need to be a standard feature for get-session especially, but other requests may also benefit from this. ```ts async function fetchWithRetry( url: string, options?: RequestInit, retryCount = 5, delay = 5000 ) { const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)) let attempts = 0 let waitTime = 0 while (attempts < retryCount) { attempts++ try { const response = await fetch(url, options) // Check if response indicates rate limiting if (response.status === 429 && attempts < retryCount) { const resetTime = response.headers.get("X-RateLimit-Reset") if (resetTime) { // Calculate time to wait based on the current time and the reset time const currentTime = Math.floor(Date.now()) waitTime = (Number(resetTime) - currentTime) } } else { return response } } catch (error) { if (attempts >= retryCount) { throw error } } // Exponential backoff if (waitTime <= 0) { waitTime = delay * Math.pow(2, attempts) } await sleep(waitTime) } throw new Error("Max retries reached") } ``` ### Current vs. Expected behavior Refetch get-session using exponential backoff algorithm when it fails due to network error or 429 rate limit response. ### What version of Better Auth are you using? 1.0.22 ### Provide environment information ```bash Next.js ``` ### Which area(s) are affected? (Select all that apply) Backend, Client ### Auth config (if applicable) _No response_ ### Additional context _No response_
GiteaMirror added the stalelockedbug labels 2026-04-15 15:09:28 -05:00
Author
Owner

@Bekacru commented on GitHub (Dec 22, 2024):

The Better Auth client is built on top of Better Fetch, which includes a retry mechanism with exponential backoff baked in (https://better-fetch.vercel.app/docs/timeout-and-retry). You can pass any options supported by Better Fetch via the fetchOptions when creating the client.

But, getSession doesn't return an error if the session doesn't exist after it hits the db. it simply returns null. This behavior is intentional to avoid logging unnecessary errors, as it's likely the expected outcome in most cases.

And for your sugesstions if needed, you can use a custom fetcher by passing fetchOptions.customFetchImpl to the createAuthClient function.

<!-- gh-comment-id:2558357255 --> @Bekacru commented on GitHub (Dec 22, 2024): The Better Auth client is built on top of **Better Fetch**, which includes a retry mechanism with exponential backoff baked in (https://better-fetch.vercel.app/docs/timeout-and-retry). You can pass any options supported by Better Fetch via the `fetchOptions` when creating the client. But, `getSession` doesn't return an error if the session doesn't exist after it hits the db. it simply returns `null`. This behavior is intentional to avoid logging unnecessary errors, as it's likely the expected outcome in most cases. And for your sugesstions if needed, you can use a custom fetcher by passing `fetchOptions.customFetchImpl` to the `createAuthClient` function.
Author
Owner

@daveycodez commented on GitHub (Dec 22, 2024):

My biggest concern is a shaky connection hitting get-session then failing, we need to make sure that isPending is still set to true. In my testing it "logs out" the user because I use isPending to show Skeletons, if the network request fails, the skeletons go away and the logged out state gets rendered. I'll try the better fetch options but I think the retry on network failure while keeping isPending true should be the default

<!-- gh-comment-id:2558365652 --> @daveycodez commented on GitHub (Dec 22, 2024): My biggest concern is a shaky connection hitting get-session then failing, we need to make sure that isPending is still set to true. In my testing it "logs out" the user because I use isPending to show Skeletons, if the network request fails, the skeletons go away and the logged out state gets rendered. I'll try the better fetch options but I think the retry on network failure while keeping isPending true should be the default
Author
Owner

@Bekacru commented on GitHub (Dec 22, 2024):

for network failures better fetch retries should work for most cases.

<!-- gh-comment-id:2558400053 --> @Bekacru commented on GitHub (Dec 22, 2024): for network failures better fetch retries should work for most cases.
Author
Owner

@daveycodez commented on GitHub (Dec 22, 2024):

Hmm I tried this and it doesn't seem to be working

export const authClient = createAuthClient({
    baseURL: getURL(),
    plugins: [
        anonymousClient()
    ],
    fetchOptions: {
        retry: {
            count: 3,
            interval: 1000, //optional
            type: "exponential",
            attempts: 5,
            baseDelay: 1000, // Start with 1 second delay
            maxDelay: 10000 // Cap the delay at 10 seconds, so requests would go out after 1s then 2s, 4s, 8s, 10s
        }
    }
})
GET https://newtech.dev/api/auth/get-session?currentURL=http%3A%2F%2Flocalhost%3A3000%2F net::ERR_INTERNET_DISCONNECTED
T @ _app-faf126e708b066b2.js:1
await in T
(anonymous) @ _app-faf126e708b066b2.js:1
i @ _app-faf126e708b066b2.js:1
(anonymous) @ _app-faf126e708b066b2.js:1
(anonymous) @ _app-faf126e708b066b2.js:1
(anonymous) @ _app-faf126e708b066b2.js:1
(anonymous) @ _app-faf126e708b066b2.js:1
(anonymous) @ _app-faf126e708b066b2.js:1
get @ _app-faf126e708b066b2.js:1
(anonymous) @ _app-faf126e708b066b2.js:1
l.<computed> @ _app-faf126e708b066b2.js:1
oF @ _app-faf126e708b066b2.js:1
oW @ _app-faf126e708b066b2.js:1
lb @ framework-a4ddb9b21624b39b.js:1
aY @ framework-a4ddb9b21624b39b.js:1
ot @ framework-a4ddb9b21624b39b.js:1
ua @ framework-a4ddb9b21624b39b.js:1
(anonymous) @ framework-a4ddb9b21624b39b.js:1
ul @ framework-a4ddb9b21624b39b.js:1
i3 @ framework-a4ddb9b21624b39b.js:1
uT @ framework-a4ddb9b21624b39b.js:1
z @ framework-a4ddb9b21624b39b.js:1Understand this errorAI
_app-faf126e708b066b2.js:1 
        
        
       Uncaught (in promise) TypeError: Failed to fetch
    at T (_app-faf126e708b066b2.js:1:32507)
    at async _app-faf126e708b066b2.js:1:29640

I set my baseURL to production then turn off WiFi and the request fails immediately and doesn't retry, isPending goes to false and I get the logged out state. This means if a user on mobile opens the app during a connection drop (on a train or something) they will see a logged out state on app open, when ideally they would be seeing spinners from isPending still being true and it retrying on an interval and successfully getting in once that connection recovers

<!-- gh-comment-id:2558573068 --> @daveycodez commented on GitHub (Dec 22, 2024): Hmm I tried this and it doesn't seem to be working ```ts export const authClient = createAuthClient({ baseURL: getURL(), plugins: [ anonymousClient() ], fetchOptions: { retry: { count: 3, interval: 1000, //optional type: "exponential", attempts: 5, baseDelay: 1000, // Start with 1 second delay maxDelay: 10000 // Cap the delay at 10 seconds, so requests would go out after 1s then 2s, 4s, 8s, 10s } } }) ``` ```shell GET https://newtech.dev/api/auth/get-session?currentURL=http%3A%2F%2Flocalhost%3A3000%2F net::ERR_INTERNET_DISCONNECTED T @ _app-faf126e708b066b2.js:1 await in T (anonymous) @ _app-faf126e708b066b2.js:1 i @ _app-faf126e708b066b2.js:1 (anonymous) @ _app-faf126e708b066b2.js:1 (anonymous) @ _app-faf126e708b066b2.js:1 (anonymous) @ _app-faf126e708b066b2.js:1 (anonymous) @ _app-faf126e708b066b2.js:1 (anonymous) @ _app-faf126e708b066b2.js:1 get @ _app-faf126e708b066b2.js:1 (anonymous) @ _app-faf126e708b066b2.js:1 l.<computed> @ _app-faf126e708b066b2.js:1 oF @ _app-faf126e708b066b2.js:1 oW @ _app-faf126e708b066b2.js:1 lb @ framework-a4ddb9b21624b39b.js:1 aY @ framework-a4ddb9b21624b39b.js:1 ot @ framework-a4ddb9b21624b39b.js:1 ua @ framework-a4ddb9b21624b39b.js:1 (anonymous) @ framework-a4ddb9b21624b39b.js:1 ul @ framework-a4ddb9b21624b39b.js:1 i3 @ framework-a4ddb9b21624b39b.js:1 uT @ framework-a4ddb9b21624b39b.js:1 z @ framework-a4ddb9b21624b39b.js:1Understand this errorAI _app-faf126e708b066b2.js:1 Uncaught (in promise) TypeError: Failed to fetch at T (_app-faf126e708b066b2.js:1:32507) at async _app-faf126e708b066b2.js:1:29640 ``` I set my baseURL to production then turn off WiFi and the request fails immediately and doesn't retry, isPending goes to false and I get the logged out state. This means if a user on mobile opens the app during a connection drop (on a train or something) they will see a logged out state on app open, when ideally they would be seeing spinners from isPending still being true and it retrying on an interval and successfully getting in once that connection recovers
Author
Owner

@daveycodez commented on GitHub (Dec 22, 2024):

This could also happen on a shaky connection on a normal mobile site where the page loads but then the get-session fails, the user will have to refresh the entire page to fix it & be confused about why they are signed out.

I tried this option:

         shouldRetry: (response) => {
                console.log("shouldRetry")
                return true
            }

and shouldRetry is never getting called. Maybe fetchOptions aren't getting passed along properly to better-fetch

Using my custom fetchWithRetry function with customFetchImpl is working, but it would be great to pass options to better-fetch so I don't need the custom implementation. Or even better to have the defaults set to auto retry without needing to configure it

<!-- gh-comment-id:2558575143 --> @daveycodez commented on GitHub (Dec 22, 2024): This could also happen on a shaky connection on a normal mobile site where the page loads but then the get-session fails, the user will have to refresh the entire page to fix it & be confused about why they are signed out. I tried this option: ```ts shouldRetry: (response) => { console.log("shouldRetry") return true } ``` and shouldRetry is never getting called. Maybe fetchOptions aren't getting passed along properly to better-fetch Using my custom fetchWithRetry function with customFetchImpl is working, but it would be great to pass options to better-fetch so I don't need the custom implementation. Or even better to have the defaults set to auto retry without needing to configure it
Author
Owner

@daveycodez commented on GitHub (Dec 22, 2024):

I've determined that the options for retry are indeed correctly getting passed to betterFetch, and it is working for retrying requests with 429 or any other errors.. however... the most important thing to retry is Network Failure, but better-fetch itself does not support this. If there is a Network Error, better-fetch throws an error and does not retry.

index.js:553 
        
        
       Uncaught (in promise) TypeError: Failed to fetch
    at betterFetch (index.js:553:24)
    at async $fetch (index.js:262:12)

You can set catchAllError: true to catch this error to avoid the exception, but it won't retry. I've dug through their docs and I don't see any way to retry on fetch error being thrown, only on response errors

<!-- gh-comment-id:2558640635 --> @daveycodez commented on GitHub (Dec 22, 2024): I've determined that the options for retry are indeed correctly getting passed to betterFetch, and it is working for retrying requests with 429 or any other errors.. however... the most important thing to retry is Network Failure, but better-fetch itself does not support this. If there is a Network Error, better-fetch throws an error and does not retry. ```shell index.js:553 Uncaught (in promise) TypeError: Failed to fetch at betterFetch (index.js:553:24) at async $fetch (index.js:262:12) ``` You can set catchAllError: true to catch this error to avoid the exception, but it won't retry. I've dug through their docs and I don't see any way to retry on fetch error being thrown, only on response errors
Author
Owner

@daveycodez commented on GitHub (Dec 22, 2024):

  fetchOptions: {
        customFetchImpl: async (input, init) => {
            try {
                return await fetch(input, init)
            } catch (error) {
                return Response.error()
            }
        },
        retry: {
            type: "exponential",
            attempts: 5,
            baseDelay: 1000, // Start with 1 second delay
            maxDelay: 10000 // Cap the delay at 10 seconds, so requests would go out after 1s then 2s, 4s, 8s, 10s
        },
    },

This is the custom fix I've tried to make better-fetch catch errors. This does work in that it will retry, but isPending flickers back and forth between true and false during each retry instead of just staying isPending true

<!-- gh-comment-id:2558644157 --> @daveycodez commented on GitHub (Dec 22, 2024): ```ts fetchOptions: { customFetchImpl: async (input, init) => { try { return await fetch(input, init) } catch (error) { return Response.error() } }, retry: { type: "exponential", attempts: 5, baseDelay: 1000, // Start with 1 second delay maxDelay: 10000 // Cap the delay at 10 seconds, so requests would go out after 1s then 2s, 4s, 8s, 10s }, }, ``` This is the custom fix I've tried to make better-fetch catch errors. This does work in that it will retry, but isPending flickers back and forth between true and false during each retry instead of just staying isPending true
Author
Owner

@daveycodez commented on GitHub (Dec 22, 2024):

the onError callback needs to be updated like this to prevent isPending flickering and support retries properly:

125b44d5c5/packages/better-auth/src/client/query.ts (L58)

async onError(context) {
    const { request } = context
    const retry = request.retry as { attempts: number }
    const retryAttempt = request.retryAttempt || 0

    if (retry?.attempts && retryAttempt < retry.attempts) return

    value.set({
        error: context.error,
        data: null,
        isPending: false,
        isRefetching: false,
    })

    await opts?.onError?.(context)
}

And then this needs to be the default customFetchImpl:

customFetchImpl: async (input, init) => {
    try {
        return await fetch(input, init)
    } catch (error) {
        return Response.error()
    }
}

And ideally out of the box default retry settings for all GET requests (same delay SWR uses) -

retry: {
  shouldRetry: (response) => {
      if (response === null) return true
      if (response.type === "error") return true
      if (response.status === 429) return true

      return false
  },
  type: "exponential",
  attempts: Infinity,
  baseDelay: 5000,
  maxDelay: Infinity
}
<!-- gh-comment-id:2558658051 --> @daveycodez commented on GitHub (Dec 22, 2024): the onError callback needs to be updated like this to prevent isPending flickering and support retries properly: https://github.com/better-auth/better-auth/blob/125b44d5c5eff403782a90c9ec1a42fb6df93195/packages/better-auth/src/client/query.ts#L58 ```ts async onError(context) { const { request } = context const retry = request.retry as { attempts: number } const retryAttempt = request.retryAttempt || 0 if (retry?.attempts && retryAttempt < retry.attempts) return value.set({ error: context.error, data: null, isPending: false, isRefetching: false, }) await opts?.onError?.(context) } ``` And then this needs to be the default customFetchImpl: ```ts customFetchImpl: async (input, init) => { try { return await fetch(input, init) } catch (error) { return Response.error() } } ``` And ideally out of the box default retry settings for all GET requests (same delay SWR uses) - ```ts retry: { shouldRetry: (response) => { if (response === null) return true if (response.type === "error") return true if (response.status === 429) return true return false }, type: "exponential", attempts: Infinity, baseDelay: 5000, maxDelay: Infinity } ```
Author
Owner

@daveycodez commented on GitHub (Dec 29, 2024):

@Bekacru possible to get the onError callback updated with the checks for retry attempts? That's the only blocker to being able to use retry on failure with better auth

125b44d5c5/packages/better-auth/src/client/query.ts (L58)

async onError(context) {
    const { request } = context
    const retry = request.retry as { attempts: number }
    const retryAttempt = request.retryAttempt || 0

    if (retry?.attempts && retryAttempt < retry.attempts) return

    value.set({
        error: context.error,
        data: null,
        isPending: false,
        isRefetching: false,
    })

    await opts?.onError?.(context)
}
<!-- gh-comment-id:2564821820 --> @daveycodez commented on GitHub (Dec 29, 2024): @Bekacru possible to get the onError callback updated with the checks for retry attempts? That's the only blocker to being able to use retry on failure with better auth https://github.com/better-auth/better-auth/blob/125b44d5c5eff403782a90c9ec1a42fb6df93195/packages/better-auth/src/client/query.ts#L58 ```ts async onError(context) { const { request } = context const retry = request.retry as { attempts: number } const retryAttempt = request.retryAttempt || 0 if (retry?.attempts && retryAttempt < retry.attempts) return value.set({ error: context.error, data: null, isPending: false, isRefetching: false, }) await opts?.onError?.(context) } ```
Author
Owner

@Bekacru commented on GitHub (Dec 29, 2024):

Hey @daveycodez, yeah, it will be added in the next release! but, for the default retry rule, we're going to avoid that since some people use the auth client with something like SWR or react query. This could lead to duplicate retry rules, and it's easy enough to configure it yourself if needed.

<!-- gh-comment-id:2564823833 --> @Bekacru commented on GitHub (Dec 29, 2024): Hey @daveycodez, yeah, it will be added in the next release! but, for the default retry rule, we're going to avoid that since some people use the auth client with something like SWR or react query. This could lead to duplicate retry rules, and it's easy enough to configure it yourself if needed.
Author
Owner

@Bekacru commented on GitHub (Dec 30, 2024):

Response error is returned on network failures starting rom 1.1.7

<!-- gh-comment-id:2565321393 --> @Bekacru commented on GitHub (Dec 30, 2024): Response error is returned on network failures starting rom `1.1.7`
Author
Owner

@daveycodez commented on GitHub (Dec 31, 2024):

@Bekacru I see that fix looks great, but how about the fix for isPending flickering back and forth during retries? From the onError code above. isPending should remain true during retries

<!-- gh-comment-id:2566326467 --> @daveycodez commented on GitHub (Dec 31, 2024): @Bekacru I see that fix looks great, but how about the fix for isPending flickering back and forth during retries? From the onError code above. isPending should remain true during retries
Author
Owner

@yasseralsaidi commented on GitHub (Jan 1, 2025):

My users say they face the same problem: 'network error' when trying to sign in :(
any fix?

<!-- gh-comment-id:2567064459 --> @yasseralsaidi commented on GitHub (Jan 1, 2025): My users say they face the same problem: 'network error' when trying to sign in :( any fix?
Author
Owner

@dosubot[bot] commented on GitHub (Jun 14, 2025):

Hi, @daveycodez. I'm Dosu, and I'm helping the better-auth team manage their backlog. I'm marking this issue as stale.

Issue Summary:

  • You reported network failures causing session fetch failures, leading to users appearing logged out.
  • @Bekacru suggested using Better Fetch with a retry mechanism, but you noted it doesn't handle network errors effectively.
  • You proposed updates to the onError callback to prevent flickering during retries.
  • @Bekacru confirmed a fix for network failures will be included in the next release, but default retry rules won't be added to avoid conflicts.
  • @yasseralsaidi reported similar issues with network errors during sign-in.

Next Steps:

  • Please confirm if this issue is still relevant to the latest version of the better-auth repository by commenting here.
  • If no updates are provided, the issue will be automatically closed in 7 days.

Thank you for your understanding and contribution!

<!-- gh-comment-id:2972848025 --> @dosubot[bot] commented on GitHub (Jun 14, 2025): Hi, @daveycodez. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog. I'm marking this issue as stale. **Issue Summary:** - You reported network failures causing session fetch failures, leading to users appearing logged out. - @Bekacru suggested using Better Fetch with a retry mechanism, but you noted it doesn't handle network errors effectively. - You proposed updates to the `onError` callback to prevent flickering during retries. - @Bekacru confirmed a fix for network failures will be included in the next release, but default retry rules won't be added to avoid conflicts. - @yasseralsaidi reported similar issues with network errors during sign-in. **Next Steps:** - Please confirm if this issue is still relevant to the latest version of the better-auth repository by commenting here. - If no updates are provided, the issue will be automatically closed in 7 days. Thank you for your understanding and contribution!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#17167