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:
Cristhian Zanforlin Lousa 2024-08-26 18:21:05 -03:00 committed by GitHub
commit a763f57af5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 229 additions and 29 deletions

View file

@ -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;
}

View file

@ -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

View file

@ -340,3 +340,4 @@ class ConfigResponse(BaseModel):
frontend_timeout: int
auto_saving: bool
auto_saving_interval: int
health_check_max_retries: int

View file

@ -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

View file

@ -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):

View 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>
</>
);
}

View file

@ -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;

View file

@ -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);
}
},

View file

@ -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<

View file

@ -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,
});

View file

@ -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]);
}

View file

@ -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(

View file

@ -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,

View file

@ -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 }),
}));

View 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;
};

View file

@ -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 = {

View file

@ -0,0 +1,6 @@
export type UtilityStoreType = {
selectedItems: any[];
setSelectedItems: (itemId: any) => void;
healthCheckTimeout: string | null;
setHealthCheckTimeout: (timeout: string | null) => void;
};