Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 115 additions & 2 deletions packages/core/auth-js/src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ const DEFAULT_OPTIONS: Omit<
throwOnError: false,
lockAcquireTimeout: 5000, // 5 seconds. Only used when a custom `lock` is supplied. TODO(v3): remove.
skipAutoInitialize: false,
maxAutoRefreshFailures: 0,
experimental: {},
}

Expand Down Expand Up @@ -371,6 +372,16 @@ export default class GoTrueClient {
* Only consulted when a custom `lock` is supplied. TODO(v3): remove.
*/
protected lockAcquireTimeout: number
/**
* Maximum consecutive auto-refresh tick failures before the client stops
* retrying and emits `TOKEN_REFRESH_FAILED`. 0 means no limit (default).
*/
protected maxAutoRefreshFailures: number
/**
* Tracks consecutive auto-refresh tick failures. Reset to 0 on any
* successful token refresh.
*/
protected autoRefreshFailureCount: number = 0
/**
* Opt-in flags for experimental features. Defaults to an empty object.
* See `GoTrueClientOptions.experimental`.
Expand Down Expand Up @@ -449,6 +460,7 @@ export default class GoTrueClient {
// (including supabase-js tests) read it off the client to verify option
// flow-through.
this.lockAcquireTimeout = settings.lockAcquireTimeout
this.maxAutoRefreshFailures = settings.maxAutoRefreshFailures

// TODO(v3): remove. Legacy opt-in path preserved for backwards
// compatibility with callers passing a custom `lock` (typically React
Expand Down Expand Up @@ -5188,6 +5200,16 @@ export default class GoTrueClient {
// _saveSession is always called whenever a new session has been acquired
// so we can safely suppress the warning returned by future getSession calls
this.suppressGetSessionWarning = true

// A new session means auth is working — reset the failure counter.
this.autoRefreshFailureCount = 0

// In non-browser environments (React Native, Node) nothing restarts the
// ticker after the limit was hit. Re-enable it here so that a successful
// sign-in or setSession recovers auto-refresh.
if (this.autoRefreshToken && this.autoRefreshTicker === null && !isBrowser()) {
void this._startAutoRefresh()
}
// Create a shallow copy to work with, to avoid mutating the original session object if it's used elsewhere
const sessionToProcess = { ...session }

Expand Down Expand Up @@ -5233,6 +5255,7 @@ export default class GoTrueClient {
// The session is gone — no point holding on to a cached refresh failure
// for a token that no longer exists. Synchronous, before any `await`.
this.lastRefreshFailure = null
this.autoRefreshFailureCount = 0

this.suppressGetSessionWarning = false

Expand Down Expand Up @@ -5477,10 +5500,19 @@ export default class GoTrueClient {
return await this._useSession(async (result) => {
const {
data: { session },
error,
} = result

if (!session || !session.refresh_token || !session.expires_at) {
this._debug('#_autoRefreshTokenTick()', 'no session')
if (error && isAuthRetryableFetchError(error)) {
this.autoRefreshFailureCount++
this._debug(
'#_autoRefreshTokenTick()',
`session load failed, consecutive failures: ${this.autoRefreshFailureCount}`
)
await this._handleAutoRefreshFailure(session ?? null)
}
return
}

Expand All @@ -5494,14 +5526,30 @@ export default class GoTrueClient {
)

if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
await this._callRefreshToken(session.refresh_token)
const refreshResult = await this._callRefreshToken(session.refresh_token)
if (refreshResult.error) {
if (isAuthRetryableFetchError(refreshResult.error)) {
this.autoRefreshFailureCount++
this._debug(
'#_autoRefreshTokenTick()',
`refresh failed, consecutive failures: ${this.autoRefreshFailureCount}`
)
await this._handleAutoRefreshFailure(session ?? null)
}
}
}
})
} catch (e) {
this.autoRefreshFailureCount++
this._debug(
'#_autoRefreshTokenTick()',
`tick threw, consecutive failures: ${this.autoRefreshFailureCount}`
)
console.error(
'Auto refresh tick failed with error. This is likely a transient error.',
e
)
await this._handleAutoRefreshFailure()
}
} finally {
this._debug('#_autoRefreshTokenTick()', 'end')
Expand Down Expand Up @@ -5532,10 +5580,22 @@ export default class GoTrueClient {
await this._useSession(async (result) => {
const {
data: { session },
error,
} = result

if (!session || !session.refresh_token || !session.expires_at) {
this._debug('#_autoRefreshTokenTick()', 'no session')
// If __loadSession returned an error, it means an internal refresh
// was attempted and failed (e.g. expired session). Only count
// retryable errors (network/5xx) toward the limit.
if (error && isAuthRetryableFetchError(error)) {
this.autoRefreshFailureCount++
this._debug(
'#_autoRefreshTokenTick()',
`session load failed, consecutive failures: ${this.autoRefreshFailureCount}`
)
await this._handleAutoRefreshFailure(session ?? null)
}
return
}

Expand All @@ -5550,17 +5610,70 @@ export default class GoTrueClient {
)

if (expiresInTicks <= AUTO_REFRESH_TICK_THRESHOLD) {
await this._callRefreshToken(session.refresh_token)
const refreshResult = await this._callRefreshToken(session.refresh_token)
if (refreshResult.error) {
// Only count retryable errors (network/5xx) toward the limit.
// Non-retryable errors (revoked token, rate limit) have their
// own handling in _callRefreshToken and should not stop auto-refresh.
if (isAuthRetryableFetchError(refreshResult.error)) {
this.autoRefreshFailureCount++
this._debug(
'#_autoRefreshTokenTick()',
`refresh failed, consecutive failures: ${this.autoRefreshFailureCount}`
)
await this._handleAutoRefreshFailure(session ?? null)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
})
} catch (e) {
this.autoRefreshFailureCount++
this._debug(
'#_autoRefreshTokenTick()',
`tick threw, consecutive failures: ${this.autoRefreshFailureCount}`
)
console.error('Auto refresh tick failed with error. This is likely a transient error.', e)
await this._handleAutoRefreshFailure()
}
} finally {
this._debug('#_autoRefreshTokenTick()', 'end')
}
}

/**
* Checks whether the consecutive auto-refresh failure count has reached
* `maxAutoRefreshFailures`. If so, stops the auto-refresh ticker and
* emits a `TOKEN_REFRESH_FAILED` event so the application can react
* (e.g. redirect to login, show a banner).
*
* This is a no-op when `maxAutoRefreshFailures` is 0 (the default),
* preserving the existing "retry forever" behavior.
*
* Note: in browsers, visibility changes restart the auto-refresh ticker
* (which resets the counter), so the stop is not permanent — the next tab
* focus will resume refresh attempts with a fresh budget.
*/
private async _handleAutoRefreshFailure(session: Session | null = null): Promise<void> {
if (
this.maxAutoRefreshFailures > 0 &&
this.autoRefreshFailureCount >= this.maxAutoRefreshFailures &&
this.autoRefreshTicker !== null
) {
this._debug(
'#_handleAutoRefreshFailure()',
`max failures reached (${this.maxAutoRefreshFailures}), stopping auto-refresh`
)
await this._stopAutoRefresh()
try {
await this._notifyAllSubscribers('TOKEN_REFRESH_FAILED', session, false)
} catch (e) {
// Swallow subscriber errors so the failure handler cannot re-enter
// itself via the tick's catch block.
console.error('Error in TOKEN_REFRESH_FAILED subscriber:', e)
}
}
}

/**
* Registers callbacks on the browser / platform, which in-turn run
* algorithms when the browser window/tab are in foreground. On non-browser
Expand Down
23 changes: 23 additions & 0 deletions packages/core/auth-js/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type AuthChangeEvent =
| 'SIGNED_IN'
| 'SIGNED_OUT'
| 'TOKEN_REFRESHED'
| 'TOKEN_REFRESH_FAILED'
| 'USER_UPDATED'
| AuthChangeEventMFA

Expand Down Expand Up @@ -161,6 +162,28 @@ export type GoTrueClientOptions = {
*/
lockAcquireTimeout?: number

/**
* Maximum number of consecutive auto-refresh tick failures before the
* client stops attempting to refresh the token and emits a
* `TOKEN_REFRESH_FAILED` event via `onAuthStateChange`.
*
* Only retryable errors (network failures, 5xx) count toward the limit.
* Non-retryable errors (revoked token, rate limits) do not. Each tick is
* ~30 seconds, so `maxAutoRefreshFailures: 5` means roughly 2.5 minutes
* of consecutive failures before the event fires.
*
* In browsers, tab refocus restarts the ticker and resets the counter
* (via `_onVisibilityChanged`), so the stop is not permanent. In
* non-browser environments (React Native, Node), a successful
* `setSession` or sign-in restarts auto-refresh automatically.
*
* Set to `0` to disable the limit (retry forever, which is the default
* behavior).
*
* @default 0 (no limit — retry indefinitely)
*/
maxAutoRefreshFailures?: number

/**
* If true, skips automatic initialization in constructor. Useful for SSR
* contexts where initialization timing must be controlled to prevent race
Expand Down
Loading