This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Enhance CollectionsFeed component: Improved error handling and user feedback with new error styles and retry options. Added debug functionality for authentication status checks, including session management and timeout handling in AuthContext.

+527 -104
+103 -20
src/components/CollectionsFeed/CollectionsFeed.css
··· 731 731 732 732 /* Error Container Styles */ 733 733 .error-container { 734 - text-align: center; 735 - padding: 30px; 736 - background-color: var(--navbar-bg); 737 - border: 1px solid var(--card-border); 734 + margin: 20px 0; 735 + padding: 15px; 736 + background-color: rgba(255, 0, 0, 0.05); 738 737 border-radius: 8px; 738 + border-left: 4px solid #ff3b30; 739 739 } 740 740 741 - .error-container h2 { 742 - color: #ff6b6b; 743 - margin-top: 0; 741 + .error-message { 742 + display: flex; 743 + flex-direction: column; 744 + align-items: center; 745 + gap: 10px; 744 746 } 745 747 746 - .error-message { 747 - margin-bottom: 20px; 748 - color: var(--text); 748 + .error-message p { 749 + color: #ff3b30; 750 + font-weight: 500; 751 + margin: 0; 752 + text-align: center; 749 753 } 750 754 751 - .try-again-button { 752 - background: var(--button-bg); 753 - color: var(--button-text); 755 + .error-action-button { 756 + background-color: #006de0; 757 + color: white; 754 758 border: none; 755 - border-radius: 6px; 756 - padding: 10px 20px; 757 - font-size: 1rem; 758 - font-weight: 600; 759 + border-radius: 4px; 760 + padding: 8px 16px; 761 + font-size: 14px; 759 762 cursor: pointer; 760 - transition: background-color 0.3s; 763 + transition: background-color 0.2s; 764 + } 765 + 766 + .error-action-button:hover { 767 + background-color: #0062c7; 761 768 } 762 769 763 - .try-again-button:hover { 764 - background-color: #004F84; 770 + .error-action-button:active { 771 + background-color: #0058b4; 765 772 } 766 773 767 774 /* Responsive styles */ ··· 1180 1187 .load-more-button:disabled { 1181 1188 background-color: #95a5a6; 1182 1189 cursor: not-allowed; 1190 + } 1191 + 1192 + /* Debug panel styling */ 1193 + .debug-controls { 1194 + position: fixed; 1195 + bottom: 20px; 1196 + right: 20px; 1197 + z-index: 1000; 1198 + } 1199 + 1200 + .debug-button { 1201 + background-color: #333; 1202 + color: white; 1203 + border: none; 1204 + border-radius: 4px; 1205 + padding: 8px 12px; 1206 + font-size: 12px; 1207 + cursor: pointer; 1208 + opacity: 0.7; 1209 + transition: opacity 0.2s; 1210 + } 1211 + 1212 + .debug-button:hover { 1213 + opacity: 1; 1214 + } 1215 + 1216 + .debug-panel { 1217 + position: fixed; 1218 + bottom: 60px; 1219 + right: 20px; 1220 + width: 400px; 1221 + max-height: 600px; 1222 + overflow-y: auto; 1223 + background-color: #f5f5f5; 1224 + border: 1px solid #ddd; 1225 + border-radius: 6px; 1226 + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 1227 + z-index: 1001; 1228 + } 1229 + 1230 + .debug-header { 1231 + display: flex; 1232 + justify-content: space-between; 1233 + align-items: center; 1234 + padding: 10px 15px; 1235 + background-color: #333; 1236 + color: white; 1237 + border-top-left-radius: 6px; 1238 + border-top-right-radius: 6px; 1239 + } 1240 + 1241 + .debug-header h4 { 1242 + margin: 0; 1243 + font-size: 14px; 1244 + } 1245 + 1246 + .debug-header button { 1247 + background: none; 1248 + border: none; 1249 + color: white; 1250 + font-size: 12px; 1251 + cursor: pointer; 1252 + padding: 4px 8px; 1253 + } 1254 + 1255 + .debug-header button:hover { 1256 + text-decoration: underline; 1257 + } 1258 + 1259 + .debug-panel pre { 1260 + margin: 0; 1261 + padding: 15px; 1262 + font-family: monospace; 1263 + font-size: 12px; 1264 + overflow-x: auto; 1265 + white-space: pre-wrap; 1183 1266 }
+232 -66
src/components/CollectionsFeed/CollectionsFeed.js
··· 35 35 const [useRkeyTimestamp, setUseRkeyTimestamp] = useState(false); 36 36 const [compactView, setCompactView] = useState(false); 37 37 const [displayCount, setDisplayCount] = useState(25); 38 + const [debugInfo, setDebugInfo] = useState(null); 39 + const [showDebug, setShowDebug] = useState(false); 38 40 39 41 // Helper functions 40 42 const tidToTimestamp = (tid) => { ··· 152 154 153 155 try { 154 156 // Verify authentication before making the request 155 - await checkAuthStatus(); 157 + const authResult = await checkAuthStatus(); 158 + 159 + if (!authResult) { 160 + setError('You are not authenticated. Please log in again.'); 161 + const returnUrl = encodeURIComponent(window.location.pathname); 162 + navigate(`/login?returnUrl=${returnUrl}`); 163 + return; 164 + } 156 165 157 166 const response = await fetch(url, { 158 167 credentials: 'include' ··· 160 169 161 170 if (!response.ok) { 162 171 if (response.status === 401) { 163 - // Handle unauthorized 164 - await checkAuthStatus(); 165 - if (!isAuthenticated) { 166 - throw new Error('Authentication required. Please log in again.'); 172 + // Try to refresh auth once 173 + const refreshResult = await checkAuthStatus(); 174 + if (!refreshResult) { 175 + setError('Your session has expired. Please log in again.'); 176 + const returnUrl = encodeURIComponent(window.location.pathname); 177 + navigate(`/login?returnUrl=${returnUrl}`); 178 + return; 167 179 } 168 - // If we're still authenticated, retry this request 180 + 181 + // If still authenticated, retry this request 169 182 continue; 170 183 } 171 184 172 185 // Try to parse the error response 173 - const errorData = await response.json().catch(() => null); 174 - const errorMessage = errorData?.error || errorData?.details || response.statusText; 186 + let errorMessage; 187 + try { 188 + const errorData = await response.json(); 189 + errorMessage = errorData?.error || errorData?.details || response.statusText; 190 + } catch (jsonErr) { 191 + errorMessage = response.statusText || `HTTP ${response.status}`; 192 + } 193 + 175 194 console.error(`Error fetching records for ${collection}: ${errorMessage}`); 176 195 177 196 // Skip this collection but continue with others ··· 229 248 230 249 // Store the cursor for this collection for future "load more" operations 231 250 newCursors[collection] = data.cursor; 232 - } catch (err) { 233 - console.error(`Error fetching page ${pageCount} for collection ${collection}:`, err); 234 - // Break the pagination loop for this collection, but continue with others 251 + } catch (error) { 252 + console.error(`Error processing collection ${collection}:`, error); 253 + // Continue with other collections 235 254 break; 236 255 } 237 256 } // End of pagination while loop ··· 322 341 323 342 } catch (err) { 324 343 console.error('Error fetching collection records:', err); 325 - setError('Failed to fetch records. ' + (err.message || 'Please try again.')); 344 + if (err.message && err.message.includes('authenticated')) { 345 + setError('Authentication error: ' + err.message); 346 + // Allow time to see the error before redirecting 347 + setTimeout(() => { 348 + const returnUrl = encodeURIComponent(window.location.pathname); 349 + navigate(`/login?returnUrl=${returnUrl}`); 350 + }, 3000); 351 + } else { 352 + setError(`Failed to load records: ${err.message}`); 353 + } 326 354 setFetchingMore(false); 327 - setChartLoading(false); // Always reset on error 328 - throw err; // Re-throw to allow handling in calling functions 355 + setChartLoading(false); 329 356 } 330 - }, [records, allRecordsForChart, collectionCursors, useRkeyTimestamp, checkAuthStatus, isAuthenticated]); 357 + }, [navigate, checkAuthStatus]); 331 358 332 359 // Now define loadUserData after fetchCollectionRecords is defined 333 - const loadUserData = useCallback(async (userHandle) => { 360 + const loadUserData = useCallback(async (usernameOrDid) => { 361 + if (!usernameOrDid || !isAuthenticated) { 362 + setError('Please enter a username or DID, and ensure you are logged in.'); 363 + return; 364 + } 365 + 366 + // Reset state for new search 367 + setLoading(true); 368 + setError(''); 369 + setDid(''); 370 + setServiceEndpoint(''); 371 + setCollections([]); 372 + setSelectedCollections([]); 373 + setRecords([]); 374 + setAllRecordsForChart([]); 375 + setCollectionCursors({}); 376 + 334 377 try { 335 - setLoading(true); 336 - setError(''); 378 + // First, verify authentication is still valid 379 + const authResult = await checkAuthStatus(); 337 380 338 - // Update URL with the username 339 - if (userHandle !== username) { 340 - navigate(`/omnifeed/${encodeURIComponent(userHandle)}`); 341 - } 342 - 343 - // Resolve handle to DID 344 - let userDid; 345 - try { 346 - userDid = await resolveHandleToDid(userHandle); 347 - setDid(userDid); 348 - } catch (resolveError) { 349 - console.error('Error resolving handle to DID:', resolveError); 350 - setError(`Could not resolve handle "${userHandle}". Please check the handle and try again.`); 351 - setInitialLoad(false); 381 + if (!authResult) { 382 + setError('Your session has expired. Please log in again.'); 352 383 setLoading(false); 384 + setTimeout(() => { 385 + const returnUrl = encodeURIComponent(window.location.pathname); 386 + navigate(`/login?returnUrl=${returnUrl}`); 387 + }, 3000); 353 388 return; 354 389 } 355 390 391 + // Continue with resolving the handle to DID 392 + let userDid = usernameOrDid; 393 + 394 + // If input doesn't look like a DID, try to resolve it as a handle 395 + if (!userDid.startsWith('did:')) { 396 + try { 397 + userDid = await resolveHandleToDid(usernameOrDid); 398 + } catch (resolveErr) { 399 + setError(`Could not resolve handle: ${resolveErr.message}`); 400 + setLoading(false); 401 + return; 402 + } 403 + } 404 + 356 405 // Get service endpoint 357 406 let endpoint; 358 407 try { ··· 360 409 setServiceEndpoint(endpoint); 361 410 } catch (endpointError) { 362 411 console.error('Error getting service endpoint:', endpointError); 363 - setError(`Could not determine PDS endpoint for "${userHandle}". The user's server may be offline.`); 412 + setError(`Could not determine PDS endpoint for "${userDid}". The user's server may be offline.`); 364 413 setInitialLoad(false); 365 414 setLoading(false); 366 415 return; ··· 391 440 while (retryCount <= maxRetries) { 392 441 try { 393 442 // Verify authentication before making the request 394 - await checkAuthStatus(); 443 + const authResult = await checkAuthStatus(); 444 + 445 + if (!authResult) { 446 + setError('Your session has expired. Please log in again.'); 447 + setLoading(false); 448 + setTimeout(() => { 449 + const returnUrl = encodeURIComponent(window.location.pathname); 450 + navigate(`/login?returnUrl=${returnUrl}`); 451 + }, 3000); 452 + return; 453 + } 395 454 396 455 const collectionsResponse = await fetch(`/api/collections/${encodeURIComponent(userDid)}?endpoint=${encodeURIComponent(endpoint)}`, { 397 456 credentials: 'include' ··· 399 458 400 459 if (!collectionsResponse.ok) { 401 460 if (collectionsResponse.status === 401) { 402 - // Handle unauthorized 403 - console.log('Authentication required, checking status and redirecting if needed'); 404 - await checkAuthStatus(); 405 - if (!isAuthenticated) { 406 - throw new Error('Authentication required. Please log in again.'); 461 + // Try to refresh auth once more 462 + const refreshResult = await checkAuthStatus(); 463 + if (!refreshResult) { 464 + setError('You are not authenticated. Please log in to continue.'); 465 + setLoading(false); 466 + setTimeout(() => { 467 + const returnUrl = encodeURIComponent(window.location.pathname); 468 + navigate(`/login?returnUrl=${returnUrl}`); 469 + }, 3000); 470 + return; 407 471 } 408 472 // If we're still here, try again 409 473 retryCount++; ··· 411 475 } 412 476 413 477 // Try to parse the error response 414 - const errorData = await collectionsResponse.json().catch(() => null); 415 - const errorMessage = errorData?.error || errorData?.details || collectionsResponse.statusText; 478 + let errorMessage; 479 + try { 480 + const errorData = await collectionsResponse.json(); 481 + errorMessage = errorData?.error || errorData?.details || collectionsResponse.statusText; 482 + } catch (jsonErr) { 483 + errorMessage = collectionsResponse.statusText || `HTTP ${collectionsResponse.status}`; 484 + } 485 + 416 486 throw new Error(`Error fetching collections: ${errorMessage}`); 417 487 } 418 488 ··· 453 523 setLoading(false); 454 524 } catch (err) { 455 525 console.error('Error loading user data:', err); 456 - setError(err.message || 'An error occurred while loading data.'); 526 + 527 + // Provide more user-friendly error messages 528 + let userMessage = 'An error occurred while loading data.'; 529 + 530 + if (err.message) { 531 + if (err.message.includes('authenticated')) { 532 + userMessage = 'Authentication error: Please log in again.'; 533 + setTimeout(() => { 534 + const returnUrl = encodeURIComponent(window.location.pathname); 535 + navigate(`/login?returnUrl=${returnUrl}`); 536 + }, 3000); 537 + } else if (err.message.includes('fetch')) { 538 + userMessage = 'Network error: Could not connect to the server.'; 539 + } else if (err.message.includes('collections')) { 540 + userMessage = `Collections error: ${err.message}`; 541 + } else { 542 + userMessage = err.message; 543 + } 544 + } 545 + 546 + setError(userMessage); 457 547 setInitialLoad(false); 458 548 setLoading(false); 459 549 } 460 - }, [username, navigate, checkAuthStatus, fetchCollectionRecords, isAuthenticated]); 550 + }, [navigate, checkAuthStatus]); 461 551 462 552 // Now place useEffects after all the callbacks are defined 463 553 ··· 465 555 useEffect(() => { 466 556 const verifyAuth = async () => { 467 557 try { 468 - await checkAuthStatus(); 469 - if (!isAuthenticated) { 558 + setLoading(true); 559 + const authResult = await checkAuthStatus(); 560 + 561 + if (!authResult) { 562 + console.log('Not authenticated, redirecting to login'); 470 563 // Save the current path for redirect after login 471 564 const returnUrl = encodeURIComponent(window.location.pathname); 472 565 navigate(`/login?returnUrl=${returnUrl}`); 566 + return; 567 + } 568 + 569 + setLoading(false); 570 + 571 + // If we have a username in the URL and we're authenticated, load the data 572 + if (username && authResult) { 573 + loadUserData(username); 473 574 } 474 575 } catch (err) { 475 576 console.error('Auth verification failed:', err); 577 + setError('Authentication failed. Please try logging in again.'); 578 + setLoading(false); 476 579 navigate('/login'); 477 580 } 478 581 }; ··· 480 583 verifyAuth(); 481 584 482 585 // Set up periodic auth checks 483 - const interval = setInterval(checkAuthStatus, 30000); // Check every 30 seconds 586 + const interval = setInterval(async () => { 587 + const authResult = await checkAuthStatus(); 588 + if (!authResult && isAuthenticated) { 589 + // Session was lost during browsing 590 + setError('Your session has expired. Please log in again.'); 591 + // Show the error for 3 seconds before redirecting 592 + setTimeout(() => { 593 + const returnUrl = encodeURIComponent(window.location.pathname); 594 + navigate(`/login?returnUrl=${returnUrl}`); 595 + }, 3000); 596 + } 597 + }, 30000); // Check every 30 seconds 484 598 485 599 return () => clearInterval(interval); 486 - }, [isAuthenticated, checkAuthStatus, navigate]); 487 - 488 - // Effect to load data if username is provided in URL 489 - useEffect(() => { 490 - // Only load data if authenticated and username is available 491 - if (username && isAuthenticated) { 492 - loadUserData(username); 493 - } 494 - }, [username, isAuthenticated, loadUserData]); 600 + }, [isAuthenticated, checkAuthStatus, navigate, username, loadUserData]); 495 601 496 602 // Effect to watch for selected collections changes 497 603 useEffect(() => { ··· 713 819 } 714 820 }; 715 821 822 + // Add a function to fetch debug info 823 + const fetchDebugInfo = async () => { 824 + try { 825 + const response = await fetch('/api/debug/session', { 826 + credentials: 'include' 827 + }); 828 + 829 + if (!response.ok) { 830 + setDebugInfo({ error: `Server returned ${response.status}: ${response.statusText}` }); 831 + return; 832 + } 833 + 834 + const data = await response.json(); 835 + setDebugInfo(data); 836 + } catch (error) { 837 + console.error('Error fetching debug info:', error); 838 + setDebugInfo({ error: error.message }); 839 + } 840 + 841 + setShowDebug(true); 842 + }; 843 + 716 844 return ( 717 845 <div className="collections-feed-container"> 718 846 <Helmet> ··· 754 882 </div> 755 883 </form> 756 884 757 - {/* Error message with retry option */} 885 + {/* Error message with more styling and retry button */} 758 886 {error && ( 759 - <div className="error-message"> 760 - <p><strong>Error:</strong> {error}</p> 761 - {did && serviceEndpoint && ( 762 - <button 763 - onClick={() => loadUserData(handle || searchTerm)} 764 - className="retry-button" 765 - > 766 - Retry 767 - </button> 768 - )} 887 + <div className="error-container"> 888 + <div className="error-message"> 889 + <p>{error}</p> 890 + {error.includes('authenticated') ? ( 891 + <button 892 + className="error-action-button" 893 + onClick={() => { 894 + const returnUrl = encodeURIComponent(window.location.pathname); 895 + navigate(`/login?returnUrl=${returnUrl}`); 896 + }} 897 + > 898 + Go to Login 899 + </button> 900 + ) : ( 901 + <button 902 + className="error-action-button" 903 + onClick={() => { 904 + setError(''); 905 + if (username) { 906 + loadUserData(username); 907 + } 908 + }} 909 + > 910 + Retry 911 + </button> 912 + )} 913 + </div> 769 914 </div> 770 915 )} 771 916 ··· 893 1038 )} 894 1039 </> 895 1040 )} 1041 + </div> 1042 + )} 1043 + </div> 1044 + 1045 + {/* Debug button */} 1046 + <div className="debug-controls"> 1047 + <button 1048 + className="debug-button" 1049 + onClick={fetchDebugInfo} 1050 + title="Check authentication status" 1051 + > 1052 + Debug Auth 1053 + </button> 1054 + 1055 + {showDebug && debugInfo && ( 1056 + <div className="debug-panel"> 1057 + <div className="debug-header"> 1058 + <h4>Session Debug Info</h4> 1059 + <button onClick={() => setShowDebug(false)}>Close</button> 1060 + </div> 1061 + <pre>{JSON.stringify(debugInfo, null, 2)}</pre> 896 1062 </div> 897 1063 )} 898 1064 </div>
+62 -18
src/contexts/AuthContext.js
··· 337 337 lastAuthCheck.current = now; 338 338 339 339 try { 340 + console.log('Checking auth status...'); 341 + 342 + const controller = new AbortController(); 343 + // Set a timeout for the fetch to prevent hanging requests 344 + const timeoutId = setTimeout(() => controller.abort(), 5000); 345 + 340 346 const response = await fetch('/api/auth/status', { 341 - credentials: 'include' 347 + credentials: 'include', 348 + signal: controller.signal 342 349 }); 343 350 351 + clearTimeout(timeoutId); 352 + 344 353 if (!response.ok) { 345 354 console.error('Auth status check failed with status:', response.status); 355 + // If we get a server error, we should still rely on client-side session 356 + // to prevent users from getting logged out due to temporary server issues 346 357 authCheckInProgress.current = false; 347 - return !!session; // Return current state on error 358 + return !!session; 348 359 } 349 360 350 361 const data = await response.json(); ··· 395 406 396 407 console.log('Syncing with data:', sessionData); 397 408 398 - // Try to sync one more time 399 - const syncResponse = await fetch('/api/sync-session', { 400 - method: 'POST', 401 - headers: { 402 - 'Content-Type': 'application/json' 403 - }, 404 - body: JSON.stringify(sessionData), 405 - credentials: 'include' 406 - }); 409 + // Add a timeout for sync request 410 + const syncController = new AbortController(); 411 + const syncTimeoutId = setTimeout(() => syncController.abort(), 5000); 407 412 408 - if (syncResponse.ok) { 409 - console.log('Session sync successful during status check'); 410 - const syncData = await syncResponse.json(); 411 - setSession(syncData.user); 413 + try { 414 + // Try to sync one more time 415 + const syncResponse = await fetch('/api/sync-session', { 416 + method: 'POST', 417 + headers: { 418 + 'Content-Type': 'application/json' 419 + }, 420 + body: JSON.stringify(sessionData), 421 + credentials: 'include', 422 + signal: syncController.signal 423 + }); 424 + 425 + clearTimeout(syncTimeoutId); 426 + 427 + if (syncResponse.ok) { 428 + console.log('Session sync successful during status check'); 429 + const syncData = await syncResponse.json(); 430 + setSession(syncData.user); 431 + authCheckInProgress.current = false; 432 + return true; 433 + } else { 434 + console.error('Sync response was not ok:', syncResponse.status); 435 + // If server explicitly rejects our sync attempt, we should clear session 436 + setSession(null); 437 + authCheckInProgress.current = false; 438 + return false; 439 + } 440 + } catch (syncFetchError) { 441 + clearTimeout(syncTimeoutId); 442 + console.error('Network error during sync request:', syncFetchError); 443 + 444 + // On network errors, we should keep the current session state to prevent 445 + // users from being logged out due to temporary connectivity issues 412 446 authCheckInProgress.current = false; 413 - return true; 447 + return !!session; 414 448 } 415 449 } catch (syncError) { 416 - console.error('Error syncing during status check:', syncError); 450 + console.error('Error in sync logic during status check:', syncError); 451 + // If there's an error in our sync logic, keep current session 452 + authCheckInProgress.current = false; 453 + return !!session; 417 454 } 418 455 } 419 456 ··· 425 462 } 426 463 } catch (err) { 427 464 console.error('Error checking auth status:', err); 465 + // For network errors, don't log out the user 466 + if (err.name === 'AbortError') { 467 + console.warn('Auth status check timed out'); 468 + } else if (err.name === 'TypeError' && err.message.includes('Network request failed')) { 469 + console.warn('Network request failed during auth check - keeping current session state'); 470 + } 471 + 428 472 authCheckInProgress.current = false; 429 - return !!session; // Fall back to current session state 473 + return !!session; // Fall back to current session state on errors 430 474 } 431 475 }, [session, client]); 432 476
+130
src/utils/api.js
··· 1 + /** 2 + * Utility functions for API interactions 3 + */ 4 + 5 + /** 6 + * Makes a fetch request with proper error handling, timeouts, and authentication. 7 + * 8 + * @param {string} url - The URL to fetch 9 + * @param {Object} options - Fetch options 10 + * @param {number} [timeout=10000] - Timeout in milliseconds 11 + * @returns {Promise<Object>} - The JSON response or error object 12 + */ 13 + export const fetchWithTimeout = async (url, options = {}, timeout = 10000) => { 14 + // Always include credentials for session cookies 15 + const fetchOptions = { 16 + ...options, 17 + credentials: 'include', 18 + }; 19 + 20 + // Set up abort controller for timeout 21 + const controller = new AbortController(); 22 + const timeoutId = setTimeout(() => controller.abort(), timeout); 23 + 24 + try { 25 + const response = await fetch(url, { 26 + ...fetchOptions, 27 + signal: controller.signal, 28 + }); 29 + 30 + // Clear the timeout regardless of the outcome 31 + clearTimeout(timeoutId); 32 + 33 + // Check if the response was ok (status in the range 200-299) 34 + if (!response.ok) { 35 + // Try to get the error message from the response body 36 + let errorMessage; 37 + try { 38 + const errorData = await response.json(); 39 + errorMessage = errorData.error || errorData.message || errorData.details; 40 + } catch (e) { 41 + // If we can't parse the error as JSON, use the status text 42 + errorMessage = response.statusText; 43 + } 44 + 45 + // Create an error object with useful properties 46 + const error = new Error(errorMessage || `HTTP error ${response.status}`); 47 + error.status = response.status; 48 + error.statusText = response.statusText; 49 + error.url = url; 50 + 51 + // Handle auth errors specifically 52 + if (response.status === 401) { 53 + error.isAuthError = true; 54 + } 55 + 56 + throw error; 57 + } 58 + 59 + // Parse the JSON response 60 + const data = await response.json(); 61 + return data; 62 + } catch (error) { 63 + // Clear the timeout if we catch an error before it fires 64 + clearTimeout(timeoutId); 65 + 66 + // Enhance error with more context 67 + if (error.name === 'AbortError') { 68 + error.message = `Request timed out after ${timeout}ms: ${url}`; 69 + error.isTimeout = true; 70 + } else if (error.message && error.message.includes('Network request failed')) { 71 + error.isNetworkError = true; 72 + } 73 + 74 + // Add the URL to the error for context 75 + error.url = url; 76 + 77 + throw error; 78 + } 79 + }; 80 + 81 + /** 82 + * Fetch data with retries for more reliability 83 + * 84 + * @param {string} url - The URL to fetch 85 + * @param {Object} options - Fetch options 86 + * @param {number} [maxRetries=2] - Maximum number of retries 87 + * @param {number} [timeout=10000] - Timeout in milliseconds 88 + * @returns {Promise<Object>} - The JSON response or error object 89 + */ 90 + export const fetchWithRetry = async (url, options = {}, maxRetries = 2, timeout = 10000) => { 91 + let lastError; 92 + 93 + for (let attempt = 0; attempt <= maxRetries; attempt++) { 94 + try { 95 + // If not the first attempt, wait increasing time before retry 96 + if (attempt > 0) { 97 + const backoffTime = 1000 * attempt; // 1s, 2s, 3s, etc. 98 + console.log(`Retry attempt ${attempt}/${maxRetries} for ${url} after ${backoffTime}ms`); 99 + await new Promise(resolve => setTimeout(resolve, backoffTime)); 100 + } 101 + 102 + return await fetchWithTimeout(url, options, timeout); 103 + } catch (error) { 104 + lastError = error; 105 + 106 + // Don't retry if it's an auth error - those won't go away with retries 107 + if (error.status === 401 || error.status === 403) { 108 + throw error; 109 + } 110 + 111 + // Don't retry if it's a client error (4xx range) except for 408 (timeout) and 429 (rate limit) 112 + if (error.status && error.status >= 400 && error.status < 500 && 113 + error.status !== 408 && error.status !== 429) { 114 + throw error; 115 + } 116 + 117 + // Log the error but continue if we have more retries 118 + console.warn(`Fetch attempt ${attempt + 1}/${maxRetries + 1} failed for ${url}:`, error.message); 119 + 120 + // If this was the last attempt, throw the error 121 + if (attempt === maxRetries) { 122 + error.message = `Failed after ${maxRetries + 1} attempts: ${error.message}`; 123 + throw error; 124 + } 125 + } 126 + } 127 + 128 + // We shouldn't get here, but just in case 129 + throw lastError || new Error(`Failed to fetch ${url} after ${maxRetries + 1} attempts`); 130 + };