feat: add timeout handler on FE (#3537)
* ✨ (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
This commit is contained in:
parent
9bf72ab444
commit
a763f57af5
17 changed files with 229 additions and 29 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -340,3 +340,4 @@ class ConfigResponse(BaseModel):
|
|||
frontend_timeout: int
|
||||
auto_saving: bool
|
||||
auto_saving_interval: int
|
||||
health_check_max_retries: int
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
37
src/frontend/src/components/timeoutErrorComponent/index.tsx
Normal file
37
src/frontend/src/components/timeoutErrorComponent/index.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<BaseModal
|
||||
size="small-h-full"
|
||||
open={openModal}
|
||||
type="modal"
|
||||
onSubmit={() => {
|
||||
setRetry();
|
||||
}}
|
||||
>
|
||||
<BaseModal.Content>
|
||||
<div role="status" className="m-auto flex flex-col items-center">
|
||||
<Loading className={`h-16 w-16`} />
|
||||
<br></br>
|
||||
<span className="text-lg text-primary">{message}</span>
|
||||
<span className="text-center text-lg text-primary">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</BaseModal.Content>
|
||||
|
||||
<BaseModal.Footer />
|
||||
</BaseModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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<AxiosResponse<TransactionsResponse>>} 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<never>((_, 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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<FetchErrorComponent
|
||||
description={FETCH_ERROR_DESCRIPION}
|
||||
message={FETCH_ERROR_MESSAGE}
|
||||
openModal={isServerDown}
|
||||
setRetry={() => {
|
||||
refetch();
|
||||
}}
|
||||
isLoadingHealth={fetchingHealth}
|
||||
></FetchErrorComponent>
|
||||
);
|
||||
case "timeout":
|
||||
return (
|
||||
<TimeoutErrorComponent
|
||||
description={TIMEOUT_ERROR_MESSAGE}
|
||||
message={TIMEOUT_ERROR_DESCRIPION}
|
||||
openModal={isTimeoutResponseServer}
|
||||
setRetry={() => {
|
||||
refetch();
|
||||
}}
|
||||
isLoadingHealth={fetchingHealth}
|
||||
></TimeoutErrorComponent>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [healthCheckTimeout, fetchingHealth]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<ErrorBoundary
|
||||
|
|
@ -32,21 +112,7 @@ export function AppWrapperPage() {
|
|||
FallbackComponent={CrashErrorComponent}
|
||||
>
|
||||
<>
|
||||
{
|
||||
<FetchErrorComponent
|
||||
description={FETCH_ERROR_DESCRIPION}
|
||||
message={FETCH_ERROR_MESSAGE}
|
||||
openModal={
|
||||
isErrorHealth ||
|
||||
(healthData &&
|
||||
Object.values(healthData).some((value) => value !== "ok"))
|
||||
}
|
||||
setRetry={() => {
|
||||
refetch();
|
||||
}}
|
||||
isLoadingHealth={fetchingHealth}
|
||||
></FetchErrorComponent>
|
||||
}
|
||||
{modalErrorComponent}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ const past = {};
|
|||
const future = {};
|
||||
|
||||
const useFlowsManagerStore = create<FlowsManagerStoreType>((set, get) => ({
|
||||
healthCheckMaxRetries: 5,
|
||||
setHealthCheckMaxRetries: (healthCheckMaxRetries: number) =>
|
||||
set({ healthCheckMaxRetries }),
|
||||
autoSaving: true,
|
||||
setAutoSaving: (autoSaving: boolean) => set({ autoSaving }),
|
||||
autoSavingInterval: SAVE_DEBOUNCE_TIME,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { UtilityStoreType } from "@/types/zustand/utility";
|
||||
import { create } from "zustand";
|
||||
|
||||
export const useUtilityStore = create<any>((set, get) => ({
|
||||
export const useUtilityStore = create<UtilityStoreType>((set, get) => ({
|
||||
selectedItems: [],
|
||||
setSelectedItems: (itemId) => {
|
||||
if (get().selectedItems.includes(itemId)) {
|
||||
|
|
@ -11,4 +12,7 @@ export const useUtilityStore = create<any>((set, get) => ({
|
|||
set({ selectedItems: get().selectedItems.concat(itemId) });
|
||||
}
|
||||
},
|
||||
healthCheckTimeout: null,
|
||||
setHealthCheckTimeout: (timeout: string | null) =>
|
||||
set({ healthCheckTimeout: timeout }),
|
||||
}));
|
||||
|
|
|
|||
23
src/frontend/src/types/factory/axios-error-503.ts
Normal file
23
src/frontend/src/types/factory/axios-error-503.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
6
src/frontend/src/types/zustand/utility/index.ts
Normal file
6
src/frontend/src/types/zustand/utility/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export type UtilityStoreType = {
|
||||
selectedItems: any[];
|
||||
setSelectedItems: (itemId: any) => void;
|
||||
healthCheckTimeout: string | null;
|
||||
setHealthCheckTimeout: (timeout: string | null) => void;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue