From a763f57af5c7e3803566be18c63b8ba106aaa160 Mon Sep 17 00:00:00 2001 From: Cristhian Zanforlin Lousa Date: Mon, 26 Aug 2024 18:21:05 -0300 Subject: [PATCH] feat: add timeout handler on FE (#3537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ (frontend): Add TimeoutErrorComponent to handle timeout errors in API requests 🔧 (frontend): Add healthCheckTimeout state and setHealthCheckTimeout function to utilityStore for managing timeout errors in API requests * 📝 (constants.ts): add constants for server health check intervals to improve code readability and maintainability 🐛 (use-get-health.ts): fix refetch interval value to use the newly added constant REFETCH_SERVER_HEALTH_INTERVAL for consistency and easier maintenance * 📝 (api.tsx): Add utilityStore import to improve code organization 📝 (api.tsx): Add createNewError503 function to handle custom 503 errors 📝 (api.tsx): Add AxiosError import for type checking 📝 (api.tsx): Refactor error handling logic for authentication errors 📝 (use-get-health.ts): Add createNewError503 import for custom 503 errors 📝 (use-get-health.ts): Add AxiosError import for type checking 📝 (use-get-health.ts): Refactor error handling logic for server busy status 📝 (index.tsx): Add AxiosError import for type checking 📝 (index.tsx): Add useEffect and useState imports for state management 📝 (index.tsx): Refactor error handling logic for server status and retries 📝 (utilityStore.ts): Add retriesApiRequest state and setRetriesApiRequest function 📝 (axios-error-503.ts): Create function to generate custom 503 error responses 📝 (index.ts): Add retriesApiRequest state and setRetriesApiRequest function to UtilityStoreType * 🔧 (utilityStore.ts): remove unused retriesApiRequest and setRetriesApiRequest functions to clean up code and improve maintainability 🔧 (index.ts): remove unused retriesApiRequest and setRetriesApiRequest types to keep type definitions consistent and up to date * ✨ (nginx.conf): add new health endpoint to proxy_pass requests to the backend server for health checks * ✨ (langflow): Add support for configuring the number of retries for the health check feature. This change introduces a new option `health_check_max_retries` that can be set via environment variable or command line argument to control the maximum number of retries for the health check process. * check if value is none --- docker/frontend/nginx.conf | 3 + src/backend/base/langflow/__main__.py | 6 ++ src/backend/base/langflow/api/v1/schemas.py | 1 + .../base/langflow/services/settings/base.py | 2 + src/backend/base/langflow/utils/util.py | 6 +- .../timeoutErrorComponent/index.tsx | 37 +++++++ src/frontend/src/constants/constants.ts | 7 ++ src/frontend/src/controllers/API/api.tsx | 16 ++-- .../API/queries/config/use-get-config.ts | 1 + .../API/queries/health/use-get-health.ts | 39 +++++++- src/frontend/src/hooks/use-save-config.ts | 4 + .../src/pages/AppWrapperPage/index.tsx | 96 ++++++++++++++++--- src/frontend/src/stores/flowsManagerStore.ts | 3 + src/frontend/src/stores/utilityStore.ts | 6 +- .../src/types/factory/axios-error-503.ts | 23 +++++ .../src/types/zustand/flowsManager/index.ts | 2 + .../src/types/zustand/utility/index.ts | 6 ++ 17 files changed, 229 insertions(+), 29 deletions(-) create mode 100644 src/frontend/src/components/timeoutErrorComponent/index.tsx create mode 100644 src/frontend/src/types/factory/axios-error-503.ts create mode 100644 src/frontend/src/types/zustand/utility/index.ts diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index 70d49d1ee..b064a5a79 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -20,6 +20,9 @@ server { location /health_check { proxy_pass __BACKEND_URL__; } + location /health { + proxy_pass __BACKEND_URL__; + } include /etc/nginx/extra-conf.d/*.conf; } diff --git a/src/backend/base/langflow/__main__.py b/src/backend/base/langflow/__main__.py index 505f24000..d08463367 100644 --- a/src/backend/base/langflow/__main__.py +++ b/src/backend/base/langflow/__main__.py @@ -130,6 +130,11 @@ def run( help="Defines the debounce time for the auto save.", envvar="LANGFLOW_AUTO_SAVING_INTERVAL", ), + health_check_max_retries: bool = typer.Option( + True, + help="Defines the number of retries for the health check.", + envvar="LANGFLOW_HEALTH_CHECK_MAX_RETRIES", + ), ): """ Run Langflow. @@ -149,6 +154,7 @@ def run( store=store, auto_saving=auto_saving, auto_saving_interval=auto_saving_interval, + health_check_max_retries=health_check_max_retries, ) # create path object if path is provided static_files_dir: Optional[Path] = Path(path) if path else None diff --git a/src/backend/base/langflow/api/v1/schemas.py b/src/backend/base/langflow/api/v1/schemas.py index 4c1e3726f..da55fd5e9 100644 --- a/src/backend/base/langflow/api/v1/schemas.py +++ b/src/backend/base/langflow/api/v1/schemas.py @@ -340,3 +340,4 @@ class ConfigResponse(BaseModel): frontend_timeout: int auto_saving: bool auto_saving_interval: int + health_check_max_retries: int diff --git a/src/backend/base/langflow/services/settings/base.py b/src/backend/base/langflow/services/settings/base.py index c123c0127..2c953b5d1 100644 --- a/src/backend/base/langflow/services/settings/base.py +++ b/src/backend/base/langflow/services/settings/base.py @@ -159,6 +159,8 @@ class Settings(BaseSettings): """If set to True, Langflow will auto save flows.""" auto_saving_interval: int = 300 """The interval in ms at which Langflow will auto save flows.""" + health_check_max_retries: int = 5 + """The maximum number of retries for the health check.""" @field_validator("dev") @classmethod diff --git a/src/backend/base/langflow/utils/util.py b/src/backend/base/langflow/utils/util.py index eb166cf0e..aa0cea812 100644 --- a/src/backend/base/langflow/utils/util.py +++ b/src/backend/base/langflow/utils/util.py @@ -430,6 +430,7 @@ def update_settings( store: bool = True, auto_saving: bool = True, auto_saving_interval: int = 300, + health_check_max_retries: int = 5, ): """Update the settings from a config file.""" from langflow.services.utils import initialize_settings_service @@ -456,9 +457,12 @@ def update_settings( if not auto_saving: logger.debug("Setting auto_saving to False") settings_service.settings.update_settings(auto_saving=False) - if auto_saving_interval: + if auto_saving_interval is not None: logger.debug(f"Setting auto_saving_interval to {auto_saving_interval}") settings_service.settings.update_settings(auto_saving_interval=auto_saving_interval) + if health_check_max_retries is not None: + logger.debug(f"Setting health_check_max_retries to {health_check_max_retries}") + settings_service.settings.update_settings(health_check_max_retries=health_check_max_retries) def is_class_method(func, cls): diff --git a/src/frontend/src/components/timeoutErrorComponent/index.tsx b/src/frontend/src/components/timeoutErrorComponent/index.tsx new file mode 100644 index 000000000..d6df5e95f --- /dev/null +++ b/src/frontend/src/components/timeoutErrorComponent/index.tsx @@ -0,0 +1,37 @@ +import BaseModal from "../../modals/baseModal"; +import { fetchErrorComponentType } from "../../types/components"; +import IconComponent from "../genericIconComponent"; +import Loading from "../ui/loading"; + +export default function TimeoutErrorComponent({ + message, + description, + openModal, + setRetry, +}: fetchErrorComponentType) { + return ( + <> + { + setRetry(); + }} + > + +
+ +

+ {message} + + {description} + +
+
+ + +
+ + ); +} diff --git a/src/frontend/src/constants/constants.ts b/src/frontend/src/constants/constants.ts index 5e7d5b18c..2b66818db 100644 --- a/src/frontend/src/constants/constants.ts +++ b/src/frontend/src/constants/constants.ts @@ -623,6 +623,10 @@ export const FETCH_ERROR_MESSAGE = "Couldn't establish a connection."; export const FETCH_ERROR_DESCRIPION = "Check if everything is working properly and try again."; +export const TIMEOUT_ERROR_MESSAGE = + "Please wait a few seconds to server process your request."; +export const TIMEOUT_ERROR_DESCRIPION = "Server is busy."; + export const SIGN_UP_SUCCESS = "Account created! Await admin activation. "; export const API_PAGE_PARAGRAPH = @@ -890,3 +894,6 @@ export const NODE_WIDTH = 400; export const NODE_HEIGHT = NODE_WIDTH * 3; export const SHORTCUT_KEYS = ["cmd", "ctrl", "alt", "shift"]; + +export const SERVER_HEALTH_INTERVAL = 10000; +export const REFETCH_SERVER_HEALTH_INTERVAL = 20000; diff --git a/src/frontend/src/controllers/API/api.tsx b/src/frontend/src/controllers/API/api.tsx index d9b069455..57aa2fb9c 100644 --- a/src/frontend/src/controllers/API/api.tsx +++ b/src/frontend/src/controllers/API/api.tsx @@ -1,5 +1,6 @@ import { LANGFLOW_ACCESS_TOKEN } from "@/constants/constants"; import useAuthStore from "@/stores/authStore"; +import { useUtilityStore } from "@/stores/utilityStore"; import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; import { useContext, useEffect } from "react"; import { Cookies } from "react-cookie"; @@ -29,10 +30,10 @@ function ApiInterceptor() { const interceptor = api.interceptors.response.use( (response) => response, async (error: AxiosError) => { - if ( - error?.response?.status === 403 || - error?.response?.status === 401 - ) { + const isAuthenticationError = + error?.response?.status === 403 || error?.response?.status === 401; + + if (isAuthenticationError) { if (!autoLogin) { if (error?.config?.url?.includes("github")) { return Promise.reject(error); @@ -50,11 +51,10 @@ function ApiInterceptor() { } } } + await clearBuildVerticesState(error); - if ( - error?.response?.status !== 401 && - error?.response?.status !== 403 - ) { + + if (!isAuthenticationError) { return Promise.reject(error); } }, diff --git a/src/frontend/src/controllers/API/queries/config/use-get-config.ts b/src/frontend/src/controllers/API/queries/config/use-get-config.ts index a3df855a3..72a7e4318 100644 --- a/src/frontend/src/controllers/API/queries/config/use-get-config.ts +++ b/src/frontend/src/controllers/API/queries/config/use-get-config.ts @@ -7,6 +7,7 @@ export interface ConfigResponse { frontend_timeout: number; auto_saving: boolean; auto_saving_interval: number; + health_check_max_retries: number; } export const useGetConfigQuery: useQueryFunctionType< diff --git a/src/frontend/src/controllers/API/queries/health/use-get-health.ts b/src/frontend/src/controllers/API/queries/health/use-get-health.ts index 95ec34f9e..959d2e202 100644 --- a/src/frontend/src/controllers/API/queries/health/use-get-health.ts +++ b/src/frontend/src/controllers/API/queries/health/use-get-health.ts @@ -1,4 +1,11 @@ +import { + REFETCH_SERVER_HEALTH_INTERVAL, + SERVER_HEALTH_INTERVAL, +} from "@/constants/constants"; +import { useUtilityStore } from "@/stores/utilityStore"; +import { createNewError503 } from "@/types/factory/axios-error-503"; import { keepPreviousData } from "@tanstack/react-query"; +import { AxiosError, AxiosHeaders } from "axios"; import { useQueryFunctionType } from "../../../../types/api"; import { api } from "../../api"; import { UseRequestProcessor } from "../../services/request-processor"; @@ -16,20 +23,44 @@ export const useGetHealthQuery: useQueryFunctionType< getHealthResponse > = (_, options) => { const { query } = UseRequestProcessor(); - + const setHealthCheckTimeout = useUtilityStore( + (state) => state.setHealthCheckTimeout, + ); + const healthCheckTimeout = useUtilityStore( + (state) => state.healthCheckTimeout, + ); /** * Fetches the health status of the API. * * @returns {Promise>} A promise that resolves to an AxiosResponse containing the health status. */ async function getHealthFn() { - return (await api.get("/health_check")).data; - // Health is the only endpoint that doesn't require /api/v1 + try { + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(createNewError503()), SERVER_HEALTH_INTERVAL), + ); + + const apiPromise = api.get<{ data: getHealthResponse }>("/health"); + const response = await Promise.race([apiPromise, timeoutPromise]); + setHealthCheckTimeout(null); + return response.data; + } catch (error) { + const isServerBusy = + healthCheckTimeout === null && + (error as AxiosError)?.response?.status === 503; + + if (isServerBusy) { + setHealthCheckTimeout("timeout"); + } else if (healthCheckTimeout === null) { + setHealthCheckTimeout("serverDown"); + } + throw error; + } } const queryResult = query(["useGetHealthQuery"], getHealthFn, { placeholderData: keepPreviousData, - refetchInterval: 20000, + refetchInterval: REFETCH_SERVER_HEALTH_INTERVAL, retry: false, ...options, }); diff --git a/src/frontend/src/hooks/use-save-config.ts b/src/frontend/src/hooks/use-save-config.ts index 869a4b2cf..5df3674d4 100644 --- a/src/frontend/src/hooks/use-save-config.ts +++ b/src/frontend/src/hooks/use-save-config.ts @@ -9,6 +9,9 @@ function useSaveConfig() { const setAutoSavingInterval = useFlowsManagerStore( (state) => state.setAutoSavingInterval, ); + const setHealthCheckMaxRetries = useFlowsManagerStore( + (state) => state.setHealthCheckMaxRetries, + ); useEffect(() => { if (data) { @@ -19,6 +22,7 @@ function useSaveConfig() { axios.defaults.timeout = timeoutInMilliseconds; setAutoSaving(data.auto_saving); setAutoSavingInterval(data.auto_saving_interval); + setHealthCheckMaxRetries(data.health_check_max_retries); } }, [data]); } diff --git a/src/frontend/src/pages/AppWrapperPage/index.tsx b/src/frontend/src/pages/AppWrapperPage/index.tsx index 6e50023d6..154a82733 100644 --- a/src/frontend/src/pages/AppWrapperPage/index.tsx +++ b/src/frontend/src/pages/AppWrapperPage/index.tsx @@ -2,14 +2,20 @@ import AlertDisplayArea from "@/alerts/displayArea"; import CrashErrorComponent from "@/components/crashErrorComponent"; import FetchErrorComponent from "@/components/fetchErrorComponent"; import LoadingComponent from "@/components/loadingComponent"; +import TimeoutErrorComponent from "@/components/timeoutErrorComponent"; import { FETCH_ERROR_DESCRIPION, FETCH_ERROR_MESSAGE, + TIMEOUT_ERROR_DESCRIPION, + TIMEOUT_ERROR_MESSAGE, } from "@/constants/constants"; import { useGetHealthQuery } from "@/controllers/API/queries/health"; import useTrackLastVisitedPath from "@/hooks/use-track-last-visited-path"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import { useUtilityStore } from "@/stores/utilityStore"; import { cn } from "@/utils/utils"; +import { AxiosError } from "axios"; +import { useEffect, useMemo, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { Outlet } from "react-router-dom"; @@ -17,12 +23,86 @@ export function AppWrapperPage() { useTrackLastVisitedPath(); const isLoading = useFlowsManagerStore((state) => state.isLoading); + + const healthCheckMaxRetries = useFlowsManagerStore( + (state) => state.healthCheckMaxRetries, + ); + + const healthCheckTimeout = useUtilityStore( + (state) => state.healthCheckTimeout, + ); + const { data: healthData, isFetching: fetchingHealth, isError: isErrorHealth, + error, refetch, } = useGetHealthQuery(); + + const isServerDown = + isErrorHealth || + (healthData && Object.values(healthData).some((value) => value !== "ok")) || + healthCheckTimeout === "serverDown"; + + const isTimeoutResponseServer = healthCheckTimeout === "timeout"; + + const [retryCount, setRetryCount] = useState(0); + + console.log(healthCheckMaxRetries); + + useEffect(() => { + const isServerBusy = + (error as AxiosError)?.response?.status === 503 || + (error as AxiosError)?.response?.status === 429; + + if (isServerBusy && isErrorHealth) { + const maxRetries = healthCheckMaxRetries; + if (retryCount < maxRetries) { + const delay = Math.pow(2, retryCount) * 1000; + const timer = setTimeout(() => { + refetch(); + setRetryCount(retryCount + 1); + }, delay); + + return () => clearTimeout(timer); + } + } else { + setRetryCount(0); + } + }, [isErrorHealth, retryCount, refetch]); + + const modalErrorComponent = useMemo(() => { + switch (healthCheckTimeout) { + case "serverDown": + return ( + { + refetch(); + }} + isLoadingHealth={fetchingHealth} + > + ); + case "timeout": + return ( + { + refetch(); + }} + isLoadingHealth={fetchingHealth} + > + ); + default: + return null; + } + }, [healthCheckTimeout, fetchingHealth]); + return (
<> - { - value !== "ok")) - } - setRetry={() => { - refetch(); - }} - isLoadingHealth={fetchingHealth} - > - } + {modalErrorComponent}
((set, get) => ({ + healthCheckMaxRetries: 5, + setHealthCheckMaxRetries: (healthCheckMaxRetries: number) => + set({ healthCheckMaxRetries }), autoSaving: true, setAutoSaving: (autoSaving: boolean) => set({ autoSaving }), autoSavingInterval: SAVE_DEBOUNCE_TIME, diff --git a/src/frontend/src/stores/utilityStore.ts b/src/frontend/src/stores/utilityStore.ts index 24889bc04..fc8816bd6 100644 --- a/src/frontend/src/stores/utilityStore.ts +++ b/src/frontend/src/stores/utilityStore.ts @@ -1,6 +1,7 @@ +import { UtilityStoreType } from "@/types/zustand/utility"; import { create } from "zustand"; -export const useUtilityStore = create((set, get) => ({ +export const useUtilityStore = create((set, get) => ({ selectedItems: [], setSelectedItems: (itemId) => { if (get().selectedItems.includes(itemId)) { @@ -11,4 +12,7 @@ export const useUtilityStore = create((set, get) => ({ set({ selectedItems: get().selectedItems.concat(itemId) }); } }, + healthCheckTimeout: null, + setHealthCheckTimeout: (timeout: string | null) => + set({ healthCheckTimeout: timeout }), })); diff --git a/src/frontend/src/types/factory/axios-error-503.ts b/src/frontend/src/types/factory/axios-error-503.ts new file mode 100644 index 000000000..b61426b04 --- /dev/null +++ b/src/frontend/src/types/factory/axios-error-503.ts @@ -0,0 +1,23 @@ +import { AxiosError, AxiosHeaders } from "axios"; + +export const createNewError503 = (): AxiosError => { + const headers = new AxiosHeaders({ + "Content-Type": "application/json", + }); + + const config = { + url: "/", + method: "get", + headers: headers, + }; + + const error = new AxiosError("Server Busy", "ECONNABORTED", config, null, { + status: 503, + statusText: "Service Unavailable", + data: "Server is currently busy, please try again later.", + headers: {}, + config: config, + }); + + return error; +}; diff --git a/src/frontend/src/types/zustand/flowsManager/index.ts b/src/frontend/src/types/zustand/flowsManager/index.ts index 97b389b86..fe85b3d8d 100644 --- a/src/frontend/src/types/zustand/flowsManager/index.ts +++ b/src/frontend/src/types/zustand/flowsManager/index.ts @@ -24,6 +24,8 @@ export type FlowsManagerStoreType = { setSelectedFlowsComponentsCards: (selected: string[]) => void; autoSavingInterval: number; setAutoSavingInterval: (autoSavingInterval: number) => void; + healthCheckMaxRetries: number; + setHealthCheckMaxRetries: (healthCheckMaxRetries: number) => void; }; export type UseUndoRedoOptions = { diff --git a/src/frontend/src/types/zustand/utility/index.ts b/src/frontend/src/types/zustand/utility/index.ts new file mode 100644 index 000000000..f16a08021 --- /dev/null +++ b/src/frontend/src/types/zustand/utility/index.ts @@ -0,0 +1,6 @@ +export type UtilityStoreType = { + selectedItems: any[]; + setSelectedItems: (itemId: any) => void; + healthCheckTimeout: string | null; + setHealthCheckTimeout: (timeout: string | null) => void; +};