diff --git a/frontend/src/pages/App.jsx b/frontend/src/pages/App.jsx index b743fbe..38a85d5 100644 --- a/frontend/src/pages/App.jsx +++ b/frontend/src/pages/App.jsx @@ -6,6 +6,10 @@ import { apiService } from "../services/api"; const POLL_INTERVAL = 600; // 0.6 seconds const INITIAL_ERROR_STATE = { visible: false, message: '' }; const DEBOUNCE_DELAY = 300; // 300ms debounce for user input +const CONVERSATION_FETCH_ERROR_DELAY_MS = 10000; // wait 10s before showing fetch errors +const CONVERSATION_FETCH_ERROR_THRESHOLD = Math.ceil( + CONVERSATION_FETCH_ERROR_DELAY_MS / POLL_INTERVAL +); function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); @@ -39,15 +43,36 @@ export default function App() { const debouncedUserInput = useDebounce(userInput, DEBOUNCE_DELAY); const errorTimerRef = useRef(null); + const conversationFetchErrorCountRef = useRef(0); const handleError = useCallback((error, context) => { console.error(`${context}:`, error); - - const isConversationFetchError = error.status === 404; - const errorMessage = isConversationFetchError - ? "Error fetching conversation. Retrying..." // Updated message + + const isConversationFetchError = + context === "fetching conversation" && (error.status === 404 || error.status === 408); + + if (isConversationFetchError) { + if (error.status === 404) { + conversationFetchErrorCountRef.current += 1; + + const hasExceededThreshold = + conversationFetchErrorCountRef.current >= CONVERSATION_FETCH_ERROR_THRESHOLD; + + if (!hasExceededThreshold) { + return; + } + } else { + // For timeouts or other connectivity errors surface immediately + conversationFetchErrorCountRef.current = CONVERSATION_FETCH_ERROR_THRESHOLD; + } + } else { + conversationFetchErrorCountRef.current = 0; + } + + const errorMessage = isConversationFetchError + ? "Error fetching conversation. Retrying..." : `Error ${context.toLowerCase()}. Please try again.`; - + setError(prevError => { // If the same 404 error is already being displayed, don't reset state (prevents flickering) if (prevError.visible && prevError.message === errorMessage) { @@ -55,12 +80,12 @@ export default function App() { } return { visible: true, message: errorMessage }; }); - + // Clear any existing timeout if (errorTimerRef.current) { clearTimeout(errorTimerRef.current); } - + // Only auto-dismiss non-404 errors after 3 seconds if (!isConversationFetchError) { errorTimerRef.current = setTimeout(() => setError(INITIAL_ERROR_STATE), 3000); @@ -72,6 +97,7 @@ export default function App() { if (errorTimerRef.current) { clearTimeout(errorTimerRef.current); } + conversationFetchErrorCountRef.current = 0; setError(INITIAL_ERROR_STATE); }, []); diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 02bdd73..62199ca 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,5 +1,17 @@ const API_BASE_URL = 'http://127.0.0.1:8000'; +const resolveRequestTimeout = () => { + const env = typeof import.meta !== 'undefined' ? import.meta.env : undefined; + const configured = env?.VITE_API_TIMEOUT_MS; + const parsed = Number.parseInt(configured, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return 15000; +}; + +const REQUEST_TIMEOUT_MS = resolveRequestTimeout(); // default to 15s, overridable via Vite env + class ApiError extends Error { constructor(message, status) { super(message); @@ -19,12 +31,31 @@ async function handleResponse(response) { return response.json(); } +async function fetchWithTimeout(url, options = {}, timeout = REQUEST_TIMEOUT_MS) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (error) { + if (error.name === 'AbortError') { + throw new ApiError('Request timed out', 408); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + export const apiService = { async getConversationHistory() { try { - const res = await fetch(`${API_BASE_URL}/get-conversation-history`); + const res = await fetchWithTimeout(`${API_BASE_URL}/get-conversation-history`); return handleResponse(res); } catch (error) { + if (error instanceof ApiError) { + throw error; + } throw new ApiError( 'Failed to fetch conversation history', error.status || 500 @@ -38,7 +69,7 @@ export const apiService = { } try { - const res = await fetch( + const res = await fetchWithTimeout( `${API_BASE_URL}/send-prompt?prompt=${encodeURIComponent(message)}`, { method: 'POST', @@ -49,6 +80,9 @@ export const apiService = { ); return handleResponse(res); } catch (error) { + if (error instanceof ApiError) { + throw error; + } throw new ApiError( 'Failed to send message', error.status || 500 @@ -58,7 +92,7 @@ export const apiService = { async startWorkflow() { try { - const res = await fetch( + const res = await fetchWithTimeout( `${API_BASE_URL}/start-workflow`, { method: 'POST', @@ -69,6 +103,9 @@ export const apiService = { ); return handleResponse(res); } catch (error) { + if (error instanceof ApiError) { + throw error; + } throw new ApiError( 'Failed to start workflow', error.status || 500 @@ -78,7 +115,7 @@ export const apiService = { async confirm() { try { - const res = await fetch(`${API_BASE_URL}/confirm`, { + const res = await fetchWithTimeout(`${API_BASE_URL}/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -86,10 +123,13 @@ export const apiService = { }); return handleResponse(res); } catch (error) { + if (error instanceof ApiError) { + throw error; + } throw new ApiError( 'Failed to confirm action', error.status || 500 ); } } -}; \ No newline at end of file +};