diff --git a/api/.env.example b/api/.env.example index d60e9947dd..0409eb7bca 100644 --- a/api/.env.example +++ b/api/.env.example @@ -156,5 +156,10 @@ CODE_MAX_NUMBER_ARRAY_LENGTH=1000 API_TOOL_DEFAULT_CONNECT_TIMEOUT=10 API_TOOL_DEFAULT_READ_TIMEOUT=60 +# HTTP Node configuration +HTTP_REQUEST_MAX_CONNECT_TIMEOUT=300 +HTTP_REQUEST_MAX_READ_TIMEOUT=600 +HTTP_REQUEST_MAX_WRITE_TIMEOUT=600 + # Log file path LOG_FILE= diff --git a/api/core/workflow/nodes/http_request/entities.py b/api/core/workflow/nodes/http_request/entities.py index 94ba6cb866..d88ad999b7 100644 --- a/api/core/workflow/nodes/http_request/entities.py +++ b/api/core/workflow/nodes/http_request/entities.py @@ -35,9 +35,15 @@ class HttpRequestNodeData(BaseNodeData): type: Literal['none', 'form-data', 'x-www-form-urlencoded', 'raw-text', 'json'] data: Union[None, str] + class Timeout(BaseModel): + connect: int + read: int + write: int + method: Literal['get', 'post', 'put', 'patch', 'delete', 'head'] url: str authorization: Authorization headers: str params: str - body: Optional[Body] \ No newline at end of file + body: Optional[Body] + timeout: Optional[Timeout] \ No newline at end of file diff --git a/api/core/workflow/nodes/http_request/http_executor.py b/api/core/workflow/nodes/http_request/http_executor.py index f370efa2c0..c2beb7a383 100644 --- a/api/core/workflow/nodes/http_request/http_executor.py +++ b/api/core/workflow/nodes/http_request/http_executor.py @@ -13,7 +13,6 @@ from core.workflow.entities.variable_pool import ValueType, VariablePool from core.workflow.nodes.http_request.entities import HttpRequestNodeData from core.workflow.utils.variable_template_parser import VariableTemplateParser -HTTP_REQUEST_DEFAULT_TIMEOUT = (10, 60) MAX_BINARY_SIZE = 1024 * 1024 * 10 # 10MB READABLE_MAX_BINARY_SIZE = '10MB' MAX_TEXT_SIZE = 1024 * 1024 // 10 # 0.1MB @@ -137,14 +136,16 @@ class HttpExecutor: files: Union[None, dict[str, Any]] boundary: str variable_selectors: list[VariableSelector] + timeout: HttpRequestNodeData.Timeout - def __init__(self, node_data: HttpRequestNodeData, variable_pool: Optional[VariablePool] = None): + def __init__(self, node_data: HttpRequestNodeData, timeout: HttpRequestNodeData.Timeout, variable_pool: Optional[VariablePool] = None): """ init """ self.server_url = node_data.url self.method = node_data.method self.authorization = node_data.authorization + self.timeout = timeout self.params = {} self.headers = {} self.body = None @@ -307,7 +308,7 @@ class HttpExecutor: 'url': self.server_url, 'headers': headers, 'params': self.params, - 'timeout': HTTP_REQUEST_DEFAULT_TIMEOUT, + 'timeout': (self.timeout.connect, self.timeout.read, self.timeout.write), 'follow_redirects': True } diff --git a/api/core/workflow/nodes/http_request/http_request_node.py b/api/core/workflow/nodes/http_request/http_request_node.py index 772a248bfc..cba1a11a8a 100644 --- a/api/core/workflow/nodes/http_request/http_request_node.py +++ b/api/core/workflow/nodes/http_request/http_request_node.py @@ -1,4 +1,5 @@ import logging +import os from mimetypes import guess_extension from os import path from typing import cast @@ -12,18 +13,49 @@ from core.workflow.nodes.http_request.entities import HttpRequestNodeData from core.workflow.nodes.http_request.http_executor import HttpExecutor, HttpExecutorResponse from models.workflow import WorkflowNodeExecutionStatus +MAX_CONNECT_TIMEOUT = int(os.environ.get('HTTP_REQUEST_MAX_CONNECT_TIMEOUT', '300')) +MAX_READ_TIMEOUT = int(os.environ.get('HTTP_REQUEST_MAX_READ_TIMEOUT', '600')) +MAX_WRITE_TIMEOUT = int(os.environ.get('HTTP_REQUEST_MAX_WRITE_TIMEOUT', '600')) + +HTTP_REQUEST_DEFAULT_TIMEOUT = HttpRequestNodeData.Timeout(connect=min(10, MAX_CONNECT_TIMEOUT), + read=min(60, MAX_READ_TIMEOUT), + write=min(20, MAX_WRITE_TIMEOUT)) + class HttpRequestNode(BaseNode): _node_data_cls = HttpRequestNodeData node_type = NodeType.HTTP_REQUEST + @classmethod + def get_default_config(cls) -> dict: + return { + "type": "http-request", + "config": { + "method": "get", + "authorization": { + "type": "no-auth", + }, + "body": { + "type": "none" + }, + "timeout": { + **HTTP_REQUEST_DEFAULT_TIMEOUT.dict(), + "max_connect_timeout": MAX_CONNECT_TIMEOUT, + "max_read_timeout": MAX_READ_TIMEOUT, + "max_write_timeout": MAX_WRITE_TIMEOUT, + } + }, + } + def _run(self, variable_pool: VariablePool) -> NodeRunResult: node_data: HttpRequestNodeData = cast(self._node_data_cls, self.node_data) # init http executor http_executor = None try: - http_executor = HttpExecutor(node_data=node_data, variable_pool=variable_pool) + http_executor = HttpExecutor(node_data=node_data, + timeout=self._get_request_timeout(node_data), + variable_pool=variable_pool) # invoke http executor response = http_executor.invoke() @@ -38,7 +70,7 @@ class HttpRequestNode(BaseNode): error=str(e), process_data=process_data ) - + files = self.extract_files(http_executor.server_url, response) return NodeRunResult( @@ -54,6 +86,16 @@ class HttpRequestNode(BaseNode): } ) + def _get_request_timeout(self, node_data: HttpRequestNodeData) -> HttpRequestNodeData.Timeout: + timeout = node_data.timeout + if timeout is None: + return HTTP_REQUEST_DEFAULT_TIMEOUT + + timeout.connect = min(timeout.connect, MAX_CONNECT_TIMEOUT) + timeout.read = min(timeout.read, MAX_READ_TIMEOUT) + timeout.write = min(timeout.write, MAX_WRITE_TIMEOUT) + return timeout + @classmethod def _extract_variable_selector_to_variable_mapping(cls, node_data: HttpRequestNodeData) -> dict[str, list[str]]: """ @@ -62,7 +104,7 @@ class HttpRequestNode(BaseNode): :return: """ try: - http_executor = HttpExecutor(node_data=node_data) + http_executor = HttpExecutor(node_data=node_data, timeout=HTTP_REQUEST_DEFAULT_TIMEOUT) variable_selectors = http_executor.variable_selectors @@ -84,7 +126,7 @@ class HttpRequestNode(BaseNode): # if not image, return directly if 'image' not in mimetype: return files - + if mimetype: # extract filename from url filename = path.basename(url) diff --git a/web/app/components/workflow/nodes/http/components/timeout/index.tsx b/web/app/components/workflow/nodes/http/components/timeout/index.tsx new file mode 100644 index 0000000000..70e49a80d6 --- /dev/null +++ b/web/app/components/workflow/nodes/http/components/timeout/index.tsx @@ -0,0 +1,101 @@ +'use client' +import type { FC } from 'react' +import React from 'react' +import cn from 'classnames' +import { useTranslation } from 'react-i18next' +import { useBoolean } from 'ahooks' +import type { Timeout as TimeoutPayloadType } from '../../types' +import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' + +type Props = { + readonly: boolean + nodeId: string + payload: TimeoutPayloadType + onChange: (payload: TimeoutPayloadType) => void +} + +const i18nPrefix = 'workflow.nodes.http' + +const InputField: FC<{ + title: string + description: string + placeholder: string + value?: number + onChange: (value: number) => void + readOnly?: boolean + min: number + max: number +}> = ({ title, description, placeholder, value, onChange, readOnly, min, max }) => { + return ( +
+
+ {title} + {description} +
+ { + const value = Math.max(min, Math.min(max, parseInt(e.target.value, 10))) + onChange(value) + }} placeholder={placeholder} type='number' readOnly={readOnly} min={min} max={max} /> +
+ ) +} + +const Timeout: FC = ({ readonly, payload, onChange }) => { + const { t } = useTranslation() + const { connect, read, write, max_connect_timeout, max_read_timeout, max_write_timeout } = payload ?? {} + + const [isFold, { + toggle: toggleFold, + }] = useBoolean(true) + + return ( + <> +
+
+
{t(`${i18nPrefix}.timeout.title`)}
+ +
+ {!isFold && ( +
+
+ onChange?.({ ...payload, connect: v })} + min={1} + max={max_connect_timeout ?? 300} + /> + onChange?.({ ...payload, read: v })} + min={1} + max={max_read_timeout ?? 600} + /> + onChange?.({ ...payload, write: v })} + min={1} + max={max_write_timeout ?? 600} + /> +
+
+ )} +
+ + + ) +} +export default React.memo(Timeout) diff --git a/web/app/components/workflow/nodes/http/default.ts b/web/app/components/workflow/nodes/http/default.ts index 91c6604a18..0fa72f970d 100644 --- a/web/app/components/workflow/nodes/http/default.ts +++ b/web/app/components/workflow/nodes/http/default.ts @@ -18,6 +18,11 @@ const nodeDefault: NodeDefault = { type: BodyType.none, data: '', }, + timeout: { + max_connect_timeout: 0, + max_read_timeout: 0, + max_write_timeout: 0, + }, }, getAvailablePrevNodes(isChatMode: boolean) { const nodes = isChatMode diff --git a/web/app/components/workflow/nodes/http/panel.tsx b/web/app/components/workflow/nodes/http/panel.tsx index c8f4923d03..8986f64757 100644 --- a/web/app/components/workflow/nodes/http/panel.tsx +++ b/web/app/components/workflow/nodes/http/panel.tsx @@ -8,6 +8,7 @@ import KeyValue from './components/key-value' import EditBody from './components/edit-body' import AuthorizationModal from './components/authorization' import type { HttpNodeType } from './types' +import Timeout from './components/timeout' import Field from '@/app/components/workflow/nodes/_base/components/field' import Split from '@/app/components/workflow/nodes/_base/components/split' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' @@ -40,6 +41,7 @@ const Panel: FC> = ({ showAuthorization, hideAuthorization, setAuthorization, + setTimeout, // single run isShowSingleRun, hideSingleRun, @@ -112,6 +114,15 @@ const Panel: FC> = ({ /> + +
+ +
{(isShowAuthorization && !readOnly) && ( { const { nodesReadOnly: readOnly } = useNodesReadOnly() + + const defaultConfig = useStore(s => s.nodesDefaultConfigs)[payload.type] + const { inputs, setInputs } = useNodeCrud(id, payload) const { handleVarListChange, handleAddVariable } = useVarList({ @@ -21,6 +25,17 @@ const useConfig = (id: string, payload: HttpNodeType) => { setInputs, }) + useEffect(() => { + const isReady = defaultConfig && Object.keys(defaultConfig).length > 0 + if (isReady) { + setInputs({ + ...inputs, + ...defaultConfig, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultConfig]) + const handleMethodChange = useCallback((method: Method) => { const newInputs = produce(inputs, (draft: HttpNodeType) => { draft.method = method @@ -80,6 +95,13 @@ const useConfig = (id: string, payload: HttpNodeType) => { setInputs(newInputs) }, [inputs, setInputs]) + const setTimeout = useCallback((timeout: Timeout) => { + const newInputs = produce(inputs, (draft: HttpNodeType) => { + draft.timeout = timeout + }) + setInputs(newInputs) + }, [inputs, setInputs]) + const filterVar = useCallback((varPayload: Var) => { return [VarType.string, VarType.number].includes(varPayload.type) }, []) @@ -148,6 +170,7 @@ const useConfig = (id: string, payload: HttpNodeType) => { showAuthorization, hideAuthorization, setAuthorization, + setTimeout, // single run isShowSingleRun, hideSingleRun, diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index 87b7b45753..e0a8d2531f 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -245,6 +245,15 @@ const translation = { 'header': 'Kopfzeile', }, insertVarPlaceholder: 'Tippen Sie ‘/’, um eine Variable einzufügen', + timeout: { + title: 'Zeitüberschreitung', + connectLabel: 'Verbindungszeitüberschreitung', + connectPlaceholder: 'Geben Sie die Verbindungszeitüberschreitung in Sekunden ein', + readLabel: 'Lesezeitüberschreitung', + readPlaceholder: 'Geben Sie die Lesezeitüberschreitung in Sekunden ein', + writeLabel: 'Schreibzeitüberschreitung', + writePlaceholder: 'Geben Sie die Schreibzeitüberschreitung in Sekunden ein', + }, }, code: { inputVars: 'Eingabevariablen', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 6ea3ccd734..6453c5b5c6 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -252,6 +252,15 @@ const translation = { 'header': 'Header', }, insertVarPlaceholder: 'type \'/\' to insert variable', + timeout: { + title: 'Timeout', + connectLabel: 'Connection Timeout', + connectPlaceholder: 'Enter connection timeout in seconds', + readLabel: 'Read Timeout', + readPlaceholder: 'Enter read timeout in seconds', + writeLabel: 'Write Timeout', + writePlaceholder: 'Enter write timeout in seconds', + }, }, code: { inputVars: 'Input Variables', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index a25c194254..44b070fc4a 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -248,6 +248,15 @@ const translation = { 'header': 'En-tête', }, insertVarPlaceholder: 'tapez \'/\' pour insérer une variable', + timeout: { + title: 'Délai d\'expiration', + connectLabel: 'Délai de connexion', + connectPlaceholder: 'Entrez le délai de connexion en secondes', + readLabel: 'Délai de lecture', + readPlaceholder: 'Entrez le délai de lecture en secondes', + writeLabel: 'Délai d\'écriture', + writePlaceholder: 'Entrez le délai d\'écriture en secondes', + }, }, code: { inputVars: 'Variables d\'entrée', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index fe135fec8a..b42ea56387 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -248,6 +248,15 @@ const translation = { 'header': 'ヘッダー', }, insertVarPlaceholder: '変数を挿入するには\'/\'を入力してください', + timeout: { + title: 'タイムアウト', + connectLabel: '接続タイムアウト', + connectPlaceholder: '接続タイムアウトを秒で入力', + readLabel: '読み取りタイムアウト', + readPlaceholder: '読み取りタイムアウトを秒で入力', + writeLabel: '書き込みタイムアウト', + writePlaceholder: '書き込みタイムアウトを秒で入力', + }, }, code: { inputVars: '入力変数', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index 20a1903903..09c608f73b 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -248,6 +248,15 @@ const translation = { 'header': 'Cabeçalho', }, insertVarPlaceholder: 'digite \'/\' para inserir variável', + timeout: { + title: 'Tempo esgotado', + connectLabel: 'Tempo de conexão', + connectPlaceholder: 'Insira o tempo de conexão em segundos', + readLabel: 'Tempo de leitura', + readPlaceholder: 'Insira o tempo de leitura em segundos', + writeLabel: 'Tempo de escrita', + writePlaceholder: 'Insira o tempo de escrita em segundos', + }, }, code: { inputVars: 'Variáveis de entrada', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index c4e1ea7aa4..9362b348fd 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -248,6 +248,15 @@ const translation = { 'header': 'Заголовок', }, insertVarPlaceholder: 'наберіть \'/\' для вставки змінної', + timeout: { + title: 'Час вичерпано', + connectLabel: 'Тайм-аут з’єднання', + connectPlaceholder: 'Введіть час тайм-ауту з’єднання у секундах', + readLabel: 'Тайм-аут читання', + readPlaceholder: 'Введіть час тайм-ауту читання у секундах', + writeLabel: 'Тайм-аут запису', + writePlaceholder: 'Введіть час тайм-ауту запису у секундах', + }, }, code: { inputVars: 'Вхідні змінні', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index 4bb964db69..609c09e0e4 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -248,6 +248,15 @@ const translation = { 'header': 'Tiêu đề', }, insertVarPlaceholder: 'nhập \'/\' để chèn biến', + timeout: { + title: 'Hết thời gian', + connectLabel: 'Hết thời gian kết nối', + connectPlaceholder: 'Nhập thời gian kết nối bằng giây', + readLabel: 'Hết thời gian đọc', + readPlaceholder: 'Nhập thời gian đọc bằng giây', + writeLabel: 'Hết thời gian ghi', + writePlaceholder: 'Nhập thời gian ghi bằng giây', + }, }, code: { inputVars: 'Biến đầu vào', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 6b6c408a62..8746f1060d 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -252,6 +252,15 @@ const translation = { 'header': 'Header', }, insertVarPlaceholder: '键入 \'/\' 键快速插入变量', + timeout: { + title: '超时设置', + connectLabel: '连接超时', + connectPlaceholder: '输入连接超时(以秒为单位)', + readLabel: '读取超时', + readPlaceholder: '输入读取超时(以秒为单位)', + writeLabel: '写入超时', + writePlaceholder: '输入写入超时(以秒为单位)', + }, }, code: { inputVars: '输入变量',