Skip to content

Commit beb21a2

Browse files
committed
Update: Enhanced session/token refresh & recovery
1 parent aaa4045 commit beb21a2

File tree

6 files changed

+189
-30
lines changed

6 files changed

+189
-30
lines changed

src/components/Header.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,15 @@ export default function Header() {
5656
}
5757

5858
try {
59-
// Check running analyses
59+
// Check running analyses - only fetch from last 24 hours
60+
const twentyFourHoursAgo = new Date();
61+
twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24);
62+
6063
const { data: analysisData } = await supabase
6164
.from('analysis_history')
6265
.select('id, analysis_status, is_canceled')
63-
.eq('user_id', user.id);
66+
.eq('user_id', user.id)
67+
.gte('created_at', twentyFourHoursAgo.toISOString());
6468

6569
if (analysisData) {
6670
const runningCount = analysisData.filter(item => {

src/components/PortfolioPositions.tsx

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ export default function PortfolioPositions({ onSelectStock, selectedStock }: Por
5757
const [positions, setPositions] = useState<Position[]>([]);
5858
const [loading, setLoading] = useState(false);
5959
const [error, setError] = useState<string | null>(null);
60-
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
6160
const [showRebalanceModal, setShowRebalanceModal] = useState(false);
6261
const [showRebalanceDetailModal, setShowRebalanceDetailModal] = useState(false);
6362
const [showScheduleListModal, setShowScheduleListModal] = useState(false);
@@ -201,7 +200,6 @@ export default function PortfolioPositions({ onSelectStock, selectedStock }: Por
201200
});
202201

203202
setPositions(formattedPositions);
204-
setLastRefresh(new Date());
205203
} catch (err) {
206204
console.error('Error fetching positions:', err);
207205
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch positions';
@@ -262,12 +260,31 @@ export default function PortfolioPositions({ onSelectStock, selectedStock }: Por
262260
}
263261

264262
try {
263+
// Only fetch analyses from the last 24 hours for checking running status
264+
const twentyFourHoursAgo = new Date();
265+
twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24);
266+
265267
const { data, error } = await supabase
266268
.from('analysis_history')
267269
.select('id, analysis_status, is_canceled')
268-
.eq('user_id', user.id);
270+
.eq('user_id', user.id)
271+
.gte('created_at', twentyFourHoursAgo.toISOString())
272+
.abortSignal(new AbortController().signal); // Add fresh signal to avoid conflicts
269273

270-
if (!error && data) {
274+
if (error) {
275+
// Handle 500 errors gracefully
276+
if (error.message?.includes('500') || error.code === '500') {
277+
console.warn('Server error fetching analysis history, will retry later');
278+
return;
279+
}
280+
// Log other unexpected errors
281+
if (!error.message?.includes('abort')) {
282+
console.error('Error checking running analyses:', error);
283+
}
284+
return;
285+
}
286+
287+
if (data) {
271288
const runningCount = data.filter(item => {
272289
// Convert legacy numeric status if needed
273290
const currentStatus = typeof item.analysis_status === 'number'
@@ -329,18 +346,32 @@ export default function PortfolioPositions({ onSelectStock, selectedStock }: Por
329346
}
330347

331348
try {
349+
// Only fetch rebalances from today (for checking active status)
350+
const today = new Date();
351+
today.setHours(0, 0, 0, 0);
352+
332353
// Check for active rebalance requests using centralized status logic
333354
const { data: allRebalances, error } = await supabase
334355
.from('rebalance_requests')
335356
.select('id, status, created_at, completed_at, rebalance_plan')
336357
.eq('user_id', user.id)
358+
.gte('created_at', today.toISOString()) // Only today's rebalances
337359
.order('created_at', { ascending: false })
338360
.limit(10); // Get recent rebalances to check their status
339361

340362
if (error) {
341-
// Don't log permission errors when not authenticated
342-
if (error.code !== '42501' && error.message !== 'permission denied for table rebalance_requests') {
343-
console.warn('Error checking rebalance requests:', error);
363+
// Don't log permission errors, abort errors, or auth errors
364+
const isPermissionError = error.code === '42501' || error.message?.includes('permission denied');
365+
const isAbortError = error.message?.includes('AbortError') || error.message?.includes('signal is aborted');
366+
const isAuthError = error.message?.includes('JWT') || error.message?.includes('token') || error.code === 'PGRST301';
367+
368+
if (!isPermissionError && !isAbortError && !isAuthError) {
369+
// Only log unexpected errors with details
370+
console.warn('Error checking rebalance requests:', {
371+
code: error.code,
372+
message: error.message,
373+
hint: error.hint
374+
});
344375
}
345376
return;
346377
}
@@ -499,11 +530,6 @@ export default function PortfolioPositions({ onSelectStock, selectedStock }: Por
499530

500531
</div>
501532
</div>
502-
{lastRefresh && (
503-
<p className="text-xs text-muted-foreground">
504-
Last updated: {lastRefresh.toLocaleTimeString()}
505-
</p>
506-
)}
507533
{error && (
508534
<p className="text-xs text-red-500 mt-1">{error}</p>
509535
)}

src/components/StandaloneWatchlist.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,15 @@ export default function StandaloneWatchlist({ onSelectStock, selectedStock }: St
302302
// Check database for running analyses if user is authenticated
303303
if (user && isAuthenticated) {
304304
try {
305-
// Get all analyses
305+
// Get analyses from last 7 days (enough to show recent activity for watchlist)
306+
const sevenDaysAgo = new Date();
307+
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
308+
306309
const { data, error } = await supabase
307310
.from('analysis_history')
308311
.select('ticker, analysis_status, full_analysis, is_canceled, created_at')
309312
.eq('user_id', user.id)
313+
.gte('created_at', sevenDaysAgo.toISOString())
310314
.order('created_at', { ascending: false });
311315

312316
if (!error && data) {

src/components/workflow/hooks/helpers/analysisChecker.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,15 @@ export async function checkRunningAnalyses({
3535
// Check database for running analyses if user is authenticated
3636
if (user) {
3737
try {
38+
// Only fetch analyses from the last 24 hours (for checking running analyses)
39+
const twentyFourHoursAgo = new Date();
40+
twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24);
41+
3842
const { data, error } = await supabase
3943
.from('analysis_history')
4044
.select('ticker, analysis_status, full_analysis, created_at, id, decision, agent_insights, rebalance_request_id, is_canceled')
4145
.eq('user_id', user.id)
46+
.gte('created_at', twentyFourHoursAgo.toISOString()) // Only last 24 hours
4247
.order('created_at', { ascending: false });
4348

4449
if (!error && data) {
@@ -99,15 +104,20 @@ export async function checkRunningAnalyses({
99104
if (justCompleted.length > 0) {
100105
console.log('Analyses completed, reloading for:', justCompleted);
101106

102-
// Fetch the completed analysis data
107+
// Fetch the completed analysis data (should be recent)
103108
try {
109+
// Only look for analyses completed in the last hour
110+
const oneHourAgo = new Date();
111+
oneHourAgo.setHours(oneHourAgo.getHours() - 1);
112+
104113
const { data, error } = await supabase
105114
.from('analysis_history')
106115
.select('*')
107116
.eq('user_id', user!.id)
108117
.eq('is_canceled', false)
109118
.in('ticker', justCompleted)
110119
.neq('analysis_status', ANALYSIS_STATUS.CANCELLED)
120+
.gte('created_at', oneHourAgo.toISOString()) // Only recent completions
111121
.order('created_at', { ascending: false })
112122
.limit(1)
113123
.maybeSingle();

src/lib/auth.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,7 @@ export const initializeAuth = () => {
797797
useAuth.getState().initialize();
798798

799799
// Set up a periodic check to restore auth if lost (every 5 seconds)
800-
const authCheckInterval = setInterval(() => {
800+
const authCheckInterval = setInterval(async () => {
801801
const state = useAuth.getState();
802802

803803
// Check if current JWT token is about to expire and warn user
@@ -820,8 +820,15 @@ export const initializeAuth = () => {
820820
}
821821

822822
// Proactively refresh token when it has less than 10 minutes remaining
823-
if (timeUntilExpiry > 0 && timeUntilExpiry < 600 && !(window as any).__tokenRefreshTriggered) {
823+
// But add a cooldown to prevent multiple refresh attempts
824+
const lastRefreshAttempt = (window as any).__lastTokenRefreshAttempt || 0;
825+
const refreshCooldown = 60000; // 1 minute cooldown between refresh attempts
826+
827+
if (timeUntilExpiry > 0 && timeUntilExpiry < 600 &&
828+
!(window as any).__tokenRefreshTriggered &&
829+
(Date.now() - lastRefreshAttempt) > refreshCooldown) {
824830
(window as any).__tokenRefreshTriggered = true;
831+
(window as any).__lastTokenRefreshAttempt = Date.now();
825832
console.log('🔐 Token expiring in', Math.floor(timeUntilExpiry / 60), 'minutes - triggering refresh');
826833

827834
// Trigger token refresh

src/lib/supabase.ts

Lines changed: 120 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@ if (!supabaseUrl || !supabasePublishableKey) {
1313
});
1414
}
1515

16-
// Track rate limit status
16+
// Track rate limit status with exponential backoff
1717
let rateLimitedUntil: number = 0;
18+
let rateLimitBackoff: number = 30000; // Start with 30 seconds
19+
let consecutiveRateLimits: number = 0;
20+
21+
// Cache for session to avoid excessive getSession calls
22+
let cachedSessionToken: string | null = null;
23+
let cachedSessionExpiry: number = 0;
1824

1925
// Export function to check rate limit status
2026
export const isRateLimited = () => rateLimitedUntil > Date.now();
@@ -24,6 +30,9 @@ export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
2430
persistSession: true,
2531
autoRefreshToken: true,
2632
detectSessionInUrl: true,
33+
// Reduce refresh frequency - only refresh when token has 1 minute left (instead of default 60 seconds)
34+
// This helps prevent conflicts with our manual refresh logic
35+
autoRefreshTickDuration: 60,
2736
// Use the default storage key that matches the project
2837
// This should be 'sb-lnvjsqyvhczgxvygbqer-auth-token'
2938
// Let Supabase handle the key automatically
@@ -55,16 +64,62 @@ export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
5564
// For all Supabase API calls, ensure we're using the latest session
5665
if (typeof url === 'string' && (url.includes('/rest/v1/') || url.includes('/functions/v1/'))) {
5766
try {
58-
// Get the current session from auth state
59-
const currentSession = await supabase.auth.getSession();
60-
if (currentSession.data.session?.access_token) {
61-
// Ensure the Authorization header is set with the current token
67+
let accessToken = null;
68+
69+
// First check our in-memory cache (valid for 5 seconds to batch rapid API calls)
70+
const now = Date.now();
71+
if (cachedSessionToken && cachedSessionExpiry > now) {
72+
accessToken = cachedSessionToken;
73+
} else {
74+
// Cache expired, check localStorage next
75+
const storageKey = `sb-${supabaseUrl.split('//')[1].split('.')[0]}-auth-token`;
76+
const storedSession = localStorage.getItem(storageKey);
77+
78+
if (storedSession) {
79+
try {
80+
const sessionData = JSON.parse(storedSession);
81+
if (sessionData?.access_token) {
82+
// Check if token is not too expired (allow up to 2 hours expired for recovery)
83+
const payload = JSON.parse(atob(sessionData.access_token.split('.')[1]));
84+
const tokenExp = payload.exp;
85+
const nowSeconds = Math.floor(now / 1000);
86+
const timeUntilExpiry = tokenExp - nowSeconds;
87+
88+
if (timeUntilExpiry > -7200) { // Within 2 hours of expiry
89+
accessToken = sessionData.access_token;
90+
// Cache it for 5 seconds to avoid repeated parsing
91+
cachedSessionToken = accessToken;
92+
cachedSessionExpiry = now + 5000;
93+
}
94+
}
95+
} catch (e) {
96+
// Ignore parsing errors
97+
}
98+
}
99+
100+
// Only call getSession if we absolutely need to (no cached or stored token)
101+
// AND we're not rate limited
102+
if (!accessToken && !isRateLimited()) {
103+
const currentSession = await supabase.auth.getSession();
104+
if (currentSession.data.session?.access_token) {
105+
accessToken = currentSession.data.session.access_token;
106+
// Cache it for 5 seconds
107+
cachedSessionToken = accessToken;
108+
cachedSessionExpiry = now + 5000;
109+
}
110+
}
111+
}
112+
113+
// Set the Authorization header if we have a token
114+
if (accessToken) {
62115
const headers = new Headers(options.headers || {});
63-
headers.set('Authorization', `Bearer ${currentSession.data.session.access_token}`);
116+
headers.set('Authorization', `Bearer ${accessToken}`);
117+
headers.set('apikey', supabasePublishableKey); // Also ensure API key is set
64118
options = { ...options, headers };
65119
}
66120
} catch (e) {
67121
// If getting session fails, proceed with original options
122+
console.warn('Failed to add auth headers:', e);
68123
}
69124
}
70125
// Check if this is a token refresh request
@@ -160,13 +215,21 @@ export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
160215
// Check if the existing signal is already aborted
161216
if (options.signal.aborted) {
162217
clearTimeout(timeoutId);
163-
console.log('Skipping fetch - existing signal already aborted');
218+
// Don't log for routine aborts (component unmounts, etc)
219+
if (!(window as any).__suppressAbortLogs) {
220+
console.log('Skipping fetch - existing signal already aborted');
221+
}
164222
throw new DOMException('The user aborted a request.', 'AbortError');
165223
}
166224
// Create a combined signal that aborts if either signal aborts
167225
const combinedController = new AbortController();
168-
options.signal.addEventListener('abort', () => combinedController.abort());
169-
controller.signal.addEventListener('abort', () => combinedController.abort());
226+
const abortHandler = () => {
227+
if (!combinedController.signal.aborted) {
228+
combinedController.abort();
229+
}
230+
};
231+
options.signal.addEventListener('abort', abortHandler, { once: true });
232+
controller.signal.addEventListener('abort', abortHandler, { once: true });
170233
signal = combinedController.signal;
171234
}
172235

@@ -213,11 +276,15 @@ export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
213276

214277
// Immediately check for 429 rate limit on token refresh BEFORE returning
215278
if (response.status === 429 && isTokenRefresh) {
279+
// Increment consecutive rate limits and apply exponential backoff
280+
consecutiveRateLimits++;
281+
rateLimitBackoff = Math.min(300000, 30000 * Math.pow(2, consecutiveRateLimits - 1)); // Max 5 minutes
282+
216283
// Set the flag IMMEDIATELY
217284
(window as any).__supabaseRateLimited = true;
218-
// Set rate limit for 30 seconds
219-
rateLimitedUntil = Date.now() + 30000;
220-
console.error('🔐 Token refresh rate limited! Backing off for 30 seconds');
285+
// Set rate limit with exponential backoff
286+
rateLimitedUntil = Date.now() + rateLimitBackoff;
287+
console.error(`🔐 Token refresh rate limited! Backing off for ${rateLimitBackoff / 1000} seconds (attempt ${consecutiveRateLimits})`);
221288

222289
// Set a global flag so components know we're rate limited
223290
(window as any).__supabaseRateLimited = true;
@@ -226,6 +293,7 @@ export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
226293
setTimeout(async () => {
227294
rateLimitedUntil = 0;
228295
(window as any).__supabaseRateLimited = false;
296+
consecutiveRateLimits = Math.max(0, consecutiveRateLimits - 1); // Gradually reduce backoff
229297
console.log('🔐 Rate limit cleared, token refresh can resume');
230298

231299
// Try to restore the session if auth state was lost
@@ -300,6 +368,15 @@ export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
300368
}
301369
}
302370

371+
// Reset rate limit counter on successful token refresh
372+
if (response.ok && isTokenRefresh) {
373+
consecutiveRateLimits = 0;
374+
rateLimitBackoff = 30000; // Reset to initial backoff
375+
// Clear the cache so next request gets fresh token
376+
cachedSessionToken = null;
377+
cachedSessionExpiry = 0;
378+
}
379+
303380
return response;
304381
} catch (error) {
305382
clearTimeout(timeoutId);
@@ -457,6 +534,37 @@ export const supabaseFunctions = {
457534
}
458535
};
459536

537+
// Helper function to manually recover session after rate limit
538+
export const recoverSession = async () => {
539+
try {
540+
// Check if we're still rate limited
541+
if (rateLimitedUntil > Date.now()) {
542+
console.log('🔐 Still rate limited, waiting...');
543+
return false;
544+
}
545+
546+
// Clear rate limit flag
547+
rateLimitedUntil = 0;
548+
delete (window as any).__supabaseRateLimited;
549+
550+
// Try to refresh the session
551+
const { data, error } = await supabase.auth.refreshSession();
552+
553+
if (!error && data.session) {
554+
console.log('🔐 Session recovered successfully');
555+
consecutiveRateLimits = 0; // Reset counter on success
556+
rateLimitBackoff = 30000; // Reset backoff
557+
return true;
558+
} else {
559+
console.error('🔐 Failed to recover session:', error);
560+
return false;
561+
}
562+
} catch (error) {
563+
console.error('🔐 Error recovering session:', error);
564+
return false;
565+
}
566+
};
567+
460568
// Helper functions for common operations
461569
export const supabaseHelpers = {
462570
// Get or create API settings for a user (with actual API keys for settings page)

0 commit comments

Comments
 (0)