fix: enhance SSE error handling and token refresh logic; improve user profile checks in MyChores component

This commit is contained in:
Mo Tarbin
2025-12-26 19:59:39 -05:00
parent 0fd58860a8
commit 5aeacef511
3 changed files with 166 additions and 82 deletions

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { useAlerts } from '../service/AlertsProvider'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/apiClient'
import { apiClient } from '../utils/apiClient.js'
import { useAuth } from './useAuth.jsx'
const SSE_STATES = {
CONNECTING: 0,
@@ -38,7 +38,11 @@ export const useSSE = () => {
const getSSEUrl = useCallback(() => {
const authToken = token
if (!authToken || !isAuthenticated) {
console.log('SSE: No valid authentication token')
console.log(
'SSE: No valid authentication token',
authToken,
isAuthenticated,
)
return null
}
@@ -67,6 +71,27 @@ export const useSSE = () => {
// Handle different event types and update React Query cache accordingly
switch (eventData.type) {
case 'chore.created':
showNotification({
type: 'info',
title: 'New Task Created',
message: `${eventData.data.user.displayName} created "${eventData.data.chore.name}"`,
duration: 5000,
})
const newChore = eventData.data.chore
// Update individual chore cache
queryClient.setQueryData(['chore', newChore.id], {
res: newChore,
})
// Update chores list cache
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData || !oldData.res) {
return { res: [newChore] }
}
return { res: [newChore, ...oldData.res] }
})
break
case 'chore.updated':
case 'chore.completed':
case 'chore.status':
@@ -300,7 +325,7 @@ export const useSSE = () => {
// TODO: use cookie-based once/if at all i move from local storage to httpOnly cookies.
eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
headers: {
Authorization: `Bearer ${sseConfig.token}`,
Authorization: `Bearer ${localStorage.getItem('token')}`,
'Cache-Control': 'no-cache',
Accept: 'text/event-stream',
},
@@ -371,25 +396,68 @@ export const useSSE = () => {
eventSourceRef.current.onmessage = handleSSEMessage
eventSourceRef.current.onerror = error => {
eventSourceRef.current.onerror = async error => {
console.error('SSE error:', error)
setConnectionState(SSE_STATES.CLOSED)
stopHeartbeatMonitor()
if (!isManuallyClosedRef.current) {
// Check if this is a 401 unauthorized error
const is401Error =
error.status === 401 ||
error.error?.message?.includes('401') ||
error.error?.message?.includes('Unauthorized')
// Check if this is a timeout error specifically
const isTimeoutError =
error.error?.message?.includes('No activity within') ||
error.error?.message?.includes('timeout')
if (isTimeoutError) {
if (is401Error) {
console.log('SSE 401 error detected, attempting token refresh...')
setError('Authentication expired - refreshing token...')
try {
const refreshResult = await apiClient.refreshToken()
if (refreshResult.success) {
console.log(
'Token refreshed successfully, retrying SSE connection...',
)
setError('Token refreshed - reconnecting...')
// Reset reconnect attempts since we have a fresh token
reconnectAttemptsRef.current = 0
// Schedule immediate reconnect with fresh token
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
reconnectTimeoutRef.current = setTimeout(() => {
connect()
}, 1000) // Short delay to avoid rapid reconnection
return // Exit early, don't use exponential backoff for 401 errors
} else {
console.error('Token refresh failed:', refreshResult.error)
setError('Authentication failed - please log in again')
// Don't attempt reconnection if token refresh failed
return
}
} catch (refreshError) {
console.error('Token refresh error:', refreshError)
setError('Authentication error - please log in again')
return
}
} else if (isTimeoutError) {
console.log('SSE timeout detected, attempting reconnection...')
setError('Connection timeout - reconnecting...')
} else {
setError('Connection error occurred')
}
// Schedule reconnect
// Schedule reconnect for non-401 errors
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}

View File

@@ -6,6 +6,51 @@ class ApiClient {
this.baseURL = `${API_URL}/api/v1`
this.isRefreshing = false
this.failedQueue = []
this.lastRefreshTime = 0
this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds
}
async refreshToken() {
if (this.isRefreshing) {
return { success: false, error: 'Already refreshing' }
}
// Check cooldown
const now = Date.now()
if (now - this.lastRefreshTime < this.refreshCooldown) {
return { success: false, error: 'Refresh cooldown active' }
}
this.isRefreshing = true
try {
const refreshReq = await RefreshToken()
if (refreshReq.ok) {
const data = await refreshReq.json()
const newToken = data.token || data.access_token
// Update Local Storage
localStorage.setItem('token', newToken)
if (data.expire || data.access_token_expiry) {
localStorage.setItem(
'token_expiry',
data.expire || data.access_token_expiry,
)
}
// Update last refresh time
this.lastRefreshTime = Date.now()
return { success: true, token: newToken }
} else {
return { success: false, error: 'Refresh failed' }
}
} catch (error) {
return { success: false, error: error.message }
} finally {
this.isRefreshing = false
}
}
getToken() {
@@ -64,69 +109,58 @@ class ApiClient {
// 2. Check for 401 (Unauthorized)
if (response.status === 401) {
if (!this.isRefreshing) {
this.isRefreshing = true
try {
// Attempt to refresh token
const refreshReq = await RefreshToken()
if (refreshReq.ok) {
const data = await refreshReq.json()
const newToken = data.token || data.access_token
// Update Local Storage
localStorage.setItem('token', newToken)
if (data.expire || data.access_token_expiry) {
localStorage.setItem(
'token_expiry',
data.expire || data.access_token_expiry,
)
// Always queue this request first
const queuedPromise = new Promise((resolve, reject) => {
this.failedQueue.push({
resolve: async token => {
if (!token) {
reject(new Error('Token refresh failed'))
return
}
// Process queue with success
this.processQueue(null, newToken)
// Retry the original request with new token
const newHeaders = this.getHeaders(options?.headers)
const retryConfig = {
...config,
headers: newHeaders,
}
response = await fetch(url, retryConfig)
// If it fails again with 401, force logout
if (response.status === 401) {
this.handleLogout()
return null
}
} else {
// Refresh failed (e.g., refresh token expired)
this.processQueue(new Error('Token refresh failed'), null)
this.handleLogout()
return null
}
} finally {
this.isRefreshing = false
}
} else {
// Token is currently being refreshed, queue this request
return new Promise((resolve, reject) => {
this.failedQueue.push({
resolve: (token) => {
// Retry the original request with new token
try {
const newHeaders = this.getHeaders(options?.headers)
const retryConfig = {
...config,
headers: newHeaders,
}
resolve(fetch(url, retryConfig))
},
reject
})
const retryResponse = await fetch(url, retryConfig)
resolve(retryResponse)
} catch (error) {
reject(error)
}
},
reject,
})
})
// If already refreshing, just return the queued promise
if (this.isRefreshing) {
return queuedPromise
}
// Check if we're within the refresh cooldown period
const now = Date.now()
if (now - this.lastRefreshTime < this.refreshCooldown) {
console.warn('Token refresh attempted too soon, forcing logout')
this.processQueue(new Error('Refresh cooldown active'), null)
this.handleLogout()
return null
}
const refreshResult = await this.refreshToken()
if (refreshResult.success) {
// Process queue with success - this will retry all queued requests
this.processQueue(null, refreshResult.token)
} else {
// Refresh failed
this.processQueue(new Error(refreshResult.error), null)
this.handleLogout()
return null
}
// Return the queued promise for this request
return queuedPromise
}
return response

View File

@@ -197,7 +197,7 @@ const MyChores = () => {
if (
!choresLoading &&
!membersLoading &&
userProfile &&
userProfile?.id &&
membersData?.res &&
choresData?.res
) {
@@ -245,7 +245,7 @@ const MyChores = () => {
isUserProfileLoading,
choresData?.res,
membersData?.res,
userProfile?.id,
// userProfile?.id, NOT HERE
impersonatedUser?.userId,
selectedChoreSection,
])
@@ -1506,6 +1506,7 @@ const MyChores = () => {
if (
isUserProfileLoading ||
userProfile === null ||
userLabelsLoading ||
membersLoading ||
choresLoading
@@ -1569,25 +1570,6 @@ const MyChores = () => {
}
/>
{/* {activeTextField != 'search' && (
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
onClick={() => {
setActiveTextFieldWithCache('search')
setSearchInputFocus(searchInputFocus + 1)
searchInputRef?.current?.focus()
}}
>
<Search />
</IconButton>
)} */}
<SortAndGrouping
title='Group by'
k={'icon-menu-group-by'}