-
Notifications
You must be signed in to change notification settings - Fork 576
fix: add jitter, toasts, and cleanup to workspace token refresh #12276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: fix/be-186-session-logout
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ import { useAuthStore } from '@/stores/authStore' | |
| import type { AuthHeader } from '@/types/authTypes' | ||
| import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes' | ||
| import { useFeatureFlags } from '@/composables/useFeatureFlags' | ||
| import { useToastStore } from '@/platform/updates/common/toastStore' | ||
|
|
||
| const WorkspaceWithRoleSchema = z.object({ | ||
| id: z.string(), | ||
|
|
@@ -56,6 +57,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| // Timer state | ||
| let refreshTimerId: ReturnType<typeof setTimeout> | null = null | ||
|
|
||
| // AbortController for cancelling in-flight refresh operations | ||
| let currentRefreshAbort: AbortController | null = null | ||
|
|
||
| // Request ID to prevent stale refresh operations from overwriting newer workspace contexts | ||
| let refreshRequestId = 0 | ||
|
|
||
|
|
@@ -72,6 +76,13 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| } | ||
| } | ||
|
|
||
| function abortCurrentRefresh(): void { | ||
| if (currentRefreshAbort) { | ||
| currentRefreshAbort.abort() | ||
| currentRefreshAbort = null | ||
| } | ||
| } | ||
|
|
||
| function scheduleTokenRefresh(expiresAt: number): void { | ||
| stopRefreshTimer() | ||
| const now = Date.now() | ||
|
|
@@ -100,10 +111,12 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| } | ||
|
|
||
| const remainingMs = workspaceTokenExpiresAt.value - Date.now() | ||
| // Add jitter to prevent thundering herd across browser tabs | ||
| const jitter = Math.random() * 5000 | ||
| const retryDelay = | ||
| remainingMs <= 10_000 | ||
| ? remainingMs | ||
| : Math.min(60_000, Math.floor(remainingMs / 2)) | ||
| : Math.min(60_000, Math.floor(remainingMs / 2)) + jitter | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: jitter is added outside the Could we move the jitter inside the min so the cap is honored, e.g.: const retryDelay =
remainingMs <= 10_000
? remainingMs
: Math.min(60_000, Math.floor(remainingMs / 2) + jitter) |
||
|
|
||
| refreshTimerId = setTimeout(() => { | ||
| void refreshToken() | ||
|
|
@@ -147,6 +160,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
|
|
||
| function destroy(): void { | ||
| stopRefreshTimer() | ||
| abortCurrentRefresh() | ||
| } | ||
|
|
||
| function initializeFromSession(): boolean { | ||
|
|
@@ -310,10 +324,19 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| const capturedRequestId = refreshRequestId | ||
| const maxRetries = 3 | ||
| const baseDelayMs = 1000 | ||
| const toastStore = useToastStore() | ||
|
|
||
| // Create AbortController for this refresh operation | ||
| abortCurrentRefresh() | ||
| const abortController = new AbortController() | ||
| currentRefreshAbort = abortController | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: the AbortController is allocated and stored, but
The PR description says "Proper cleanup when context is cleared or component unmounts." For |
||
|
|
||
| for (let attempt = 0; attempt <= maxRetries; attempt++) { | ||
| // Check if workspace context changed since refresh started (user switched workspaces) | ||
| if (capturedRequestId !== refreshRequestId) { | ||
| if ( | ||
| capturedRequestId !== refreshRequestId || | ||
| abortController.signal.aborted | ||
| ) { | ||
| console.warn( | ||
| 'Aborting stale token refresh: workspace context changed during refresh' | ||
| ) | ||
|
|
@@ -334,6 +357,11 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| if (capturedRequestId === refreshRequestId) { | ||
| console.error('Workspace access revoked or auth invalid:', err) | ||
| clearWorkspaceContext() | ||
| toastStore.add({ | ||
| severity: 'error', | ||
| summary: t('workspaceAuth.errors.accessDenied'), | ||
| life: 10000 | ||
| }) | ||
| } | ||
| return | ||
| } | ||
|
|
@@ -346,11 +374,22 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| err.code === 'NOT_AUTHENTICATED') | ||
|
|
||
| if (shouldRetryImmediately) { | ||
| const delay = baseDelayMs * Math.pow(2, attempt) | ||
| // Add jitter to prevent thundering herd | ||
| const jitter = Math.random() * 500 | ||
| const delay = baseDelayMs * Math.pow(2, attempt) + jitter | ||
| console.warn( | ||
| `Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`, | ||
| `Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${Math.round(delay)}ms:`, | ||
| err | ||
| ) | ||
| toastStore.add({ | ||
| severity: 'warn', | ||
| summary: t('workspaceAuth.refreshRetrying'), | ||
| detail: t('workspaceAuth.refreshRetryingDetail', { | ||
| attempt: attempt + 1, | ||
| delay: Math.round(delay / 1000) | ||
| }), | ||
| life: delay + 2000 | ||
| }) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: every failed attempt adds a fresh "Reconnecting…" toast (3 per cycle, plus a final degraded/expired toast). Because Would it make sense to either gate the retry toast to |
||
| await new Promise((resolve) => setTimeout(resolve, delay)) | ||
| continue | ||
| } | ||
|
|
@@ -363,12 +402,24 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| 'Workspace token refresh failed, keeping current token until expiry:', | ||
| err | ||
| ) | ||
| toastStore.add({ | ||
| severity: 'warn', | ||
| summary: t('workspaceAuth.refreshDegraded'), | ||
| detail: t('workspaceAuth.refreshDegradedDetail'), | ||
| life: 10000 | ||
| }) | ||
| scheduleRefreshRetry() | ||
| return | ||
| } | ||
|
|
||
| if (capturedRequestId === refreshRequestId) { | ||
| console.error('Failed to refresh workspace token after retries:', err) | ||
| toastStore.add({ | ||
| severity: 'error', | ||
| summary: t('workspaceAuth.sessionExpired'), | ||
| detail: t('workspaceAuth.sessionExpiredDetail'), | ||
| life: 10000 | ||
| }) | ||
| clearWorkspaceContext() | ||
| } | ||
| return | ||
|
|
@@ -394,6 +445,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| function clearWorkspaceContext(): void { | ||
| // Increment request ID to invalidate any in-flight stale refresh operations | ||
| refreshRequestId++ | ||
| abortCurrentRefresh() | ||
| stopRefreshTimer() | ||
| currentWorkspace.value = null | ||
| workspaceToken.value = null | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question: 5s of additive jitter is large relative to small
remainingMswindows. WhenremainingMsis just above the 10s short-circuit (say ~11s), the non-short-circuit branch givesfloor(11_000/2) + up to 5_000 = up to 10.5s, leaving as little as ~500ms before the workspace token actually expires. Was 5_000ms picked deliberately, or would something proportional toremainingMs(or a smaller fixed jitter) be safer? Same question applies to the retry-loop jitter at line 378 (500ms there feels reasonable, just want to confirm the asymmetry is intentional).