@@ -16,6 +16,11 @@ import { ANALYTICS_EVENTS } from "@/types/analytics";
1616
1717const log = logger . scope ( "auth-store" ) ;
1818
19+ let refreshPromise : Promise < void > | null = null ;
20+
21+ const REFRESH_MAX_RETRIES = 3 ;
22+ const REFRESH_INITIAL_DELAY_MS = 1000 ;
23+
1924interface StoredTokens {
2025 accessToken : string ;
2126 refreshToken : string ;
@@ -152,62 +157,119 @@ export const useAuthStore = create<AuthState>()(
152157 } ,
153158
154159 refreshAccessToken : async ( ) => {
155- const state = get ( ) ;
156-
157- if ( ! state . oauthRefreshToken || ! state . cloudRegion ) {
158- throw new Error ( "No refresh token available" ) ;
160+ // If a refresh is already in progress, wait for it
161+ if ( refreshPromise ) {
162+ log . debug ( "Token refresh already in progress, waiting..." ) ;
163+ return refreshPromise ;
159164 }
160165
161- const result = await trpcVanilla . oauth . refreshToken . mutate ( {
162- refreshToken : state . oauthRefreshToken ,
163- region : state . cloudRegion ,
164- } ) ;
166+ const doRefresh = async ( ) => {
167+ const state = get ( ) ;
165168
166- if ( ! result . success || ! result . data ) {
167- // Refresh failed - logout user
168- get ( ) . logout ( ) ;
169- throw new Error ( result . error || "Token refresh failed" ) ;
170- }
169+ if ( ! state . oauthRefreshToken || ! state . cloudRegion ) {
170+ throw new Error ( "No refresh token available" ) ;
171+ }
171172
172- const tokenResponse = result . data ;
173- const expiresAt = Date . now ( ) + tokenResponse . expires_in * 1000 ;
173+ // Retry with exponential backoff
174+ let lastError : Error | null = null ;
175+ for ( let attempt = 0 ; attempt < REFRESH_MAX_RETRIES ; attempt ++ ) {
176+ try {
177+ if ( attempt > 0 ) {
178+ const delay = REFRESH_INITIAL_DELAY_MS * 2 ** ( attempt - 1 ) ;
179+ log . debug (
180+ `Retrying token refresh (attempt ${ attempt + 1 } /${ REFRESH_MAX_RETRIES } ) after ${ delay } ms` ,
181+ ) ;
182+ await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
183+ }
174184
175- const storedTokens : StoredTokens = {
176- accessToken : tokenResponse . access_token ,
177- refreshToken : tokenResponse . refresh_token ,
178- expiresAt,
179- cloudRegion : state . cloudRegion ,
180- scopedTeams : tokenResponse . scoped_teams ,
181- } ;
185+ const result = await trpcVanilla . oauth . refreshToken . mutate ( {
186+ refreshToken : state . oauthRefreshToken ,
187+ region : state . cloudRegion ,
188+ } ) ;
182189
183- const apiHost = getCloudUrlFromRegion ( state . cloudRegion ) ;
184- const projectId =
185- tokenResponse . scoped_teams ?. [ 0 ] || state . projectId || undefined ;
190+ if ( ! result . success || ! result . data ) {
191+ // Network errors should retry, auth errors should logout immediately
192+ if ( result . errorCode === "network_error" ) {
193+ log . warn (
194+ `Token refresh network error (attempt ${ attempt + 1 } ): ${ result . error } ` ,
195+ ) ;
196+ continue ; // Retry
197+ }
198+
199+ // Auth error or unknown - logout
200+ get ( ) . logout ( ) ;
201+ throw new Error ( result . error || "Token refresh failed" ) ;
202+ }
186203
187- const client = new PostHogAPIClient (
188- tokenResponse . access_token ,
189- apiHost ,
190- async ( ) => {
191- await get ( ) . refreshAccessToken ( ) ;
192- const token = get ( ) . oauthAccessToken ;
193- if ( ! token ) {
194- throw new Error ( "No access token after refresh" ) ;
204+ const tokenResponse = result . data ;
205+ const expiresAt = Date . now ( ) + tokenResponse . expires_in * 1000 ;
206+
207+ const storedTokens : StoredTokens = {
208+ accessToken : tokenResponse . access_token ,
209+ refreshToken : tokenResponse . refresh_token ,
210+ expiresAt,
211+ cloudRegion : state . cloudRegion ,
212+ scopedTeams : tokenResponse . scoped_teams ,
213+ } ;
214+
215+ const apiHost = getCloudUrlFromRegion ( state . cloudRegion ) ;
216+ const projectId =
217+ tokenResponse . scoped_teams ?. [ 0 ] ||
218+ state . projectId ||
219+ undefined ;
220+
221+ const client = new PostHogAPIClient (
222+ tokenResponse . access_token ,
223+ apiHost ,
224+ async ( ) => {
225+ await get ( ) . refreshAccessToken ( ) ;
226+ const token = get ( ) . oauthAccessToken ;
227+ if ( ! token ) {
228+ throw new Error ( "No access token after refresh" ) ;
229+ }
230+ return token ;
231+ } ,
232+ projectId ,
233+ ) ;
234+
235+ set ( {
236+ oauthAccessToken : tokenResponse . access_token ,
237+ oauthRefreshToken : tokenResponse . refresh_token ,
238+ tokenExpiry : expiresAt ,
239+ storedTokens,
240+ client,
241+ ...( projectId && { projectId } ) ,
242+ } ) ;
243+
244+ get ( ) . scheduleTokenRefresh ( ) ;
245+ return ; // Success
246+ } catch ( error ) {
247+ lastError =
248+ error instanceof Error ? error : new Error ( String ( error ) ) ;
249+
250+ // Check if this is a permanent failure (logout already called)
251+ if ( ! get ( ) . oauthRefreshToken ) {
252+ throw lastError ;
253+ }
254+
255+ // tRPC exceptions are typically IPC failures - retry them
256+ log . warn (
257+ `Token refresh exception (attempt ${ attempt + 1 } ): ${ lastError . message } ` ,
258+ ) ;
195259 }
196- return token ;
197- } ,
198- projectId ,
199- ) ;
260+ }
200261
201- set ( {
202- oauthAccessToken : tokenResponse . access_token ,
203- oauthRefreshToken : tokenResponse . refresh_token ,
204- tokenExpiry : expiresAt ,
205- storedTokens,
206- client,
207- ...( projectId && { projectId } ) ,
262+ // All retries exhausted
263+ log . error ( "Token refresh failed after all retries" ) ;
264+ get ( ) . logout ( ) ;
265+ throw lastError || new Error ( "Token refresh failed" ) ;
266+ } ;
267+
268+ refreshPromise = doRefresh ( ) . finally ( ( ) => {
269+ refreshPromise = null ;
208270 } ) ;
209271
210- get ( ) . scheduleTokenRefresh ( ) ;
272+ return refreshPromise ;
211273 } ,
212274
213275 scheduleTokenRefresh : ( ) => {
@@ -336,6 +398,25 @@ export const useAuthStore = create<AuthState>()(
336398 return true ;
337399 } catch ( error ) {
338400 log . error ( "Failed to validate OAuth session:" , error ) ;
401+
402+ // Network errors from fetch are TypeError, wrapped by fetcher.ts as cause
403+ const isNetworkError =
404+ error instanceof Error && error . cause instanceof TypeError ;
405+
406+ if ( isNetworkError ) {
407+ log . warn (
408+ "Network error during session validation - keeping session active" ,
409+ ) ;
410+ set ( {
411+ isAuthenticated : true ,
412+ client,
413+ projectId,
414+ } ) ;
415+ get ( ) . scheduleTokenRefresh ( ) ;
416+ return true ;
417+ }
418+
419+ // For auth errors (401/403) or unknown errors, clear the session
339420 set ( { storedTokens : null , isAuthenticated : false } ) ;
340421 return false ;
341422 }
0 commit comments