mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-29 00:38:03 -05:00
feat(timeout): add API block timeout configuration (#3053)
* feat(timeout): add timeout subblock to the api block * fix(timeout): honor timeout config for internal routes and fix type coercion - Add AbortController support for internal routes (/api/*) to honor timeout - Fix type coercion: convert string timeout from short-input to number - Handle NaN gracefully by falling back to undefined (default timeout) Fixes #2786 Fixes #2242 * fix: remove redundant clearTimeout in catch block * fix: validate timeout is positive number Negative timeout values would cause immediate request abort since JavaScript treats negative setTimeout delays as 0. * update docs image, update search modal performance * removed unused keywords type * ack comments * cleanup * fix: add default timeout for internal routes and validate finite timeout - Internal routes now use same 5-minute default as external routes - Added Number.isFinite() check to reject Infinity values * fix: enforce max timeout and improve error message consistency - Clamp timeout to max 600000ms (10 minutes) as documented - External routes now report timeout value in error message * remove unused code
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 144 KiB |
@@ -180,20 +180,6 @@ function resolveCustomToolFromReference(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a stored custom tool uses the reference-only format.
|
||||
*
|
||||
* @remarks
|
||||
* Reference-only format means the tool has a customToolId but no inline code/schema,
|
||||
* requiring resolution from the database at runtime.
|
||||
*
|
||||
* @param storedTool - The stored tool to check
|
||||
* @returns `true` if the tool is a reference-only custom tool, `false` otherwise
|
||||
*/
|
||||
function isCustomToolReference(storedTool: StoredTool): boolean {
|
||||
return storedTool.type === 'custom-tool' && !!storedTool.customToolId && !storedTool.code
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic sync wrapper that synchronizes store values with local component state.
|
||||
*
|
||||
@@ -1155,21 +1141,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
return filterBlocks(allToolBlocks)
|
||||
}, [filterBlocks])
|
||||
|
||||
const customFilter = useCallback((value: string, search: string) => {
|
||||
if (!search.trim()) return 1
|
||||
|
||||
const normalizedValue = value.toLowerCase()
|
||||
const normalizedSearch = search.toLowerCase()
|
||||
|
||||
if (normalizedValue === normalizedSearch) return 1
|
||||
|
||||
if (normalizedValue.startsWith(normalizedSearch)) return 0.8
|
||||
|
||||
if (normalizedValue.includes(normalizedSearch)) return 0.6
|
||||
|
||||
return 0
|
||||
}, [])
|
||||
|
||||
const hasBackfilledRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Database, HelpCircle, Layout, Settings } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Library } from '@/components/emcn'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import type {
|
||||
SearchBlockItem,
|
||||
@@ -18,6 +18,23 @@ import type {
|
||||
} from '@/stores/modals/search/types'
|
||||
import { useSettingsModalStore } from '@/stores/modals/settings/store'
|
||||
|
||||
function customFilter(value: string, search: string): number {
|
||||
const searchLower = search.toLowerCase()
|
||||
const valueLower = value.toLowerCase()
|
||||
|
||||
if (valueLower === searchLower) return 1
|
||||
if (valueLower.startsWith(searchLower)) return 0.9
|
||||
if (valueLower.includes(searchLower)) return 0.7
|
||||
|
||||
const searchWords = searchLower.split(/\s+/).filter(Boolean)
|
||||
if (searchWords.length > 1) {
|
||||
const allWordsMatch = searchWords.every((word) => valueLower.includes(word))
|
||||
if (allWordsMatch) return 0.5
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -48,6 +65,7 @@ interface PageItem {
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
shortcut?: string
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export function SearchModal({
|
||||
@@ -60,11 +78,10 @@ export function SearchModal({
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const brand = useBrandConfig()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const openSettingsModal = useSettingsModalStore((state) => state.openModal)
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -79,54 +96,66 @@ export function SearchModal({
|
||||
}, [])
|
||||
|
||||
const pages = useMemo(
|
||||
(): PageItem[] => [
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Logs',
|
||||
icon: Library,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
shortcut: '⌘⇧L',
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Templates',
|
||||
icon: Layout,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base',
|
||||
name: 'Knowledge Base',
|
||||
icon: Database,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
name: 'Help',
|
||||
icon: HelpCircle,
|
||||
onClick: openHelpModal,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
onClick: openSettingsModal,
|
||||
shortcut: '⌘,',
|
||||
},
|
||||
],
|
||||
[workspaceId, openHelpModal, openSettingsModal]
|
||||
(): PageItem[] =>
|
||||
[
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Logs',
|
||||
icon: Library,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
shortcut: '⌘⇧L',
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Templates',
|
||||
icon: Layout,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
hidden: permissionConfig.hideTemplates,
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base',
|
||||
name: 'Knowledge Base',
|
||||
icon: Database,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
hidden: permissionConfig.hideKnowledgeBaseTab,
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
name: 'Help',
|
||||
icon: HelpCircle,
|
||||
onClick: openHelpModal,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
onClick: openSettingsModal,
|
||||
},
|
||||
].filter((page) => !page.hidden),
|
||||
[
|
||||
workspaceId,
|
||||
openHelpModal,
|
||||
openSettingsModal,
|
||||
permissionConfig.hideTemplates,
|
||||
permissionConfig.hideKnowledgeBaseTab,
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch('')
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
if (open && inputRef.current) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
)?.set
|
||||
if (nativeInputValueSetter) {
|
||||
nativeInputValueSetter.call(inputRef.current, '')
|
||||
inputRef.current.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value)
|
||||
const handleSearchChange = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const list = document.querySelector('[cmdk-list]')
|
||||
if (list) {
|
||||
@@ -228,28 +257,6 @@ export function SearchModal({
|
||||
const showToolOperations = isOnWorkflowPage && toolOperations.length > 0
|
||||
const showDocs = isOnWorkflowPage && docs.length > 0
|
||||
|
||||
const customFilter = useCallback((value: string, search: string, keywords?: string[]) => {
|
||||
const searchLower = search.toLowerCase()
|
||||
const valueLower = value.toLowerCase()
|
||||
|
||||
if (valueLower === searchLower) return 1
|
||||
if (valueLower.startsWith(searchLower)) return 0.8
|
||||
if (valueLower.includes(searchLower)) return 0.6
|
||||
|
||||
const searchWords = searchLower.split(/\s+/).filter(Boolean)
|
||||
const allWordsMatch = searchWords.every((word) => valueLower.includes(word))
|
||||
if (allWordsMatch && searchWords.length > 0) return 0.4
|
||||
|
||||
if (keywords?.length) {
|
||||
const keywordsLower = keywords.join(' ').toLowerCase()
|
||||
if (keywordsLower.includes(searchLower)) return 0.3
|
||||
const keywordWordsMatch = searchWords.every((word) => keywordsLower.includes(word))
|
||||
if (keywordWordsMatch && searchWords.length > 0) return 0.2
|
||||
}
|
||||
|
||||
return 0
|
||||
}, [])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
@@ -278,7 +285,6 @@ export function SearchModal({
|
||||
<Command label='Search' filter={customFilter}>
|
||||
<Command.Input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
autoFocus
|
||||
onValueChange={handleSearchChange}
|
||||
placeholder='Search anything...'
|
||||
@@ -295,7 +301,6 @@ export function SearchModal({
|
||||
<CommandItem
|
||||
key={block.id}
|
||||
value={`${block.name} block-${block.id}`}
|
||||
keywords={[block.description]}
|
||||
onSelect={() => handleBlockSelect(block, 'block')}
|
||||
icon={block.icon}
|
||||
bgColor={block.bgColor}
|
||||
@@ -313,7 +318,6 @@ export function SearchModal({
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
value={`${tool.name} tool-${tool.id}`}
|
||||
keywords={[tool.description]}
|
||||
onSelect={() => handleBlockSelect(tool, 'tool')}
|
||||
icon={tool.icon}
|
||||
bgColor={tool.bgColor}
|
||||
@@ -331,7 +335,6 @@ export function SearchModal({
|
||||
<CommandItem
|
||||
key={trigger.id}
|
||||
value={`${trigger.name} trigger-${trigger.id}`}
|
||||
keywords={[trigger.description]}
|
||||
onSelect={() => handleBlockSelect(trigger, 'trigger')}
|
||||
icon={trigger.icon}
|
||||
bgColor={trigger.bgColor}
|
||||
@@ -371,7 +374,6 @@ export function SearchModal({
|
||||
<CommandItem
|
||||
key={op.id}
|
||||
value={`${op.searchValue} operation-${op.id}`}
|
||||
keywords={op.keywords}
|
||||
onSelect={() => handleToolOperationSelect(op)}
|
||||
icon={op.icon}
|
||||
bgColor={op.bgColor}
|
||||
@@ -458,7 +460,6 @@ const groupHeadingClassName =
|
||||
|
||||
interface CommandItemProps {
|
||||
value: string
|
||||
keywords?: string[]
|
||||
onSelect: () => void
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
bgColor: string
|
||||
@@ -468,7 +469,6 @@ interface CommandItemProps {
|
||||
|
||||
function CommandItem({
|
||||
value,
|
||||
keywords,
|
||||
onSelect,
|
||||
icon: Icon,
|
||||
bgColor,
|
||||
@@ -478,7 +478,6 @@ function CommandItem({
|
||||
return (
|
||||
<Command.Item
|
||||
value={value}
|
||||
keywords={keywords}
|
||||
onSelect={onSelect}
|
||||
className='group flex h-[28px] w-full cursor-pointer items-center gap-[8px] rounded-[6px] px-[10px] text-left text-[15px] aria-selected:bg-[var(--border)] aria-selected:shadow-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
|
||||
>
|
||||
|
||||
@@ -80,6 +80,15 @@ Example:
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
title: 'Timeout (ms)',
|
||||
type: 'short-input',
|
||||
placeholder: '300000',
|
||||
description:
|
||||
'Request timeout in milliseconds (default: 300000 = 5 minutes, max: 600000 = 10 minutes)',
|
||||
mode: 'advanced',
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['http_request'],
|
||||
@@ -90,6 +99,7 @@ Example:
|
||||
headers: { type: 'json', description: 'Request headers' },
|
||||
body: { type: 'json', description: 'Request body data' },
|
||||
params: { type: 'json', description: 'URL query parameters' },
|
||||
timeout: { type: 'number', description: 'Request timeout in milliseconds' },
|
||||
},
|
||||
outputs: {
|
||||
data: { type: 'json', description: 'API response data (JSON, text, or other formats)' },
|
||||
|
||||
@@ -931,7 +931,7 @@ export async function secureFetchWithPinnedIP(
|
||||
method: options.method || 'GET',
|
||||
headers: sanitizedHeaders,
|
||||
agent,
|
||||
timeout: options.timeout || 30000,
|
||||
timeout: options.timeout || 300000, // Default 5 minutes
|
||||
}
|
||||
|
||||
const protocol = isHttps ? https : http
|
||||
@@ -1011,7 +1011,7 @@ export async function secureFetchWithPinnedIP(
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy()
|
||||
reject(new Error('Request timeout'))
|
||||
reject(new Error(`Request timed out after ${requestOptions.timeout}ms`))
|
||||
})
|
||||
|
||||
if (options.body) {
|
||||
|
||||
@@ -23,7 +23,7 @@ const initialData: SearchData = {
|
||||
|
||||
export const useSearchModalStore = create<SearchModalState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
(set, _) => ({
|
||||
isOpen: false,
|
||||
data: initialData,
|
||||
|
||||
@@ -53,7 +53,6 @@ export const useSearchModalStore = create<SearchModalState>()(
|
||||
const searchItem: SearchBlockItem = {
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
description: block.description || '',
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
@@ -79,7 +78,6 @@ export const useSearchModalStore = create<SearchModalState>()(
|
||||
{
|
||||
id: 'loop',
|
||||
name: 'Loop',
|
||||
description: 'Create a Loop',
|
||||
icon: RepeatIcon,
|
||||
bgColor: '#2FB3FF',
|
||||
type: 'loop',
|
||||
@@ -87,7 +85,6 @@ export const useSearchModalStore = create<SearchModalState>()(
|
||||
{
|
||||
id: 'parallel',
|
||||
name: 'Parallel',
|
||||
description: 'Parallel Execution',
|
||||
icon: SplitIcon,
|
||||
bgColor: '#FEE12B',
|
||||
type: 'parallel',
|
||||
@@ -116,7 +113,6 @@ export const useSearchModalStore = create<SearchModalState>()(
|
||||
(block): SearchBlockItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
description: block.description || '',
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
@@ -127,16 +123,18 @@ export const useSearchModalStore = create<SearchModalState>()(
|
||||
const allowedBlockTypes = new Set(tools.map((t) => t.type))
|
||||
const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex()
|
||||
.filter((op) => allowedBlockTypes.has(op.blockType))
|
||||
.map((op) => ({
|
||||
id: op.id,
|
||||
name: op.operationName,
|
||||
searchValue: `${op.serviceName} ${op.operationName}`,
|
||||
icon: op.icon,
|
||||
bgColor: op.bgColor,
|
||||
blockType: op.blockType,
|
||||
operationId: op.operationId,
|
||||
keywords: op.aliases,
|
||||
}))
|
||||
.map((op) => {
|
||||
const aliasesStr = op.aliases?.length ? ` ${op.aliases.join(' ')}` : ''
|
||||
return {
|
||||
id: op.id,
|
||||
name: op.operationName,
|
||||
searchValue: `${op.serviceName} ${op.operationName}${aliasesStr}`,
|
||||
icon: op.icon,
|
||||
bgColor: op.bgColor,
|
||||
blockType: op.blockType,
|
||||
operationId: op.operationId,
|
||||
}
|
||||
})
|
||||
|
||||
set({
|
||||
data: {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { BlockConfig } from '@/blocks/types'
|
||||
export interface SearchBlockItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
bgColor: string
|
||||
type: string
|
||||
@@ -25,7 +24,6 @@ export interface SearchToolOperationItem {
|
||||
bgColor: string
|
||||
blockType: string
|
||||
operationId: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,11 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Form data to send (will set appropriate Content-Type)',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
visibility: 'user-only',
|
||||
description: 'Request timeout in milliseconds (default: 300000 = 5 minutes)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface RequestParams {
|
||||
params?: TableRow[]
|
||||
pathParams?: Record<string, string>
|
||||
formData?: Record<string, string | Blob>
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface RequestResponse extends ToolResponse {
|
||||
|
||||
@@ -625,11 +625,27 @@ async function executeToolRequest(
|
||||
let response: Response
|
||||
|
||||
if (isInternalRoute) {
|
||||
response = await fetch(fullUrl, {
|
||||
method: requestParams.method,
|
||||
headers: headers,
|
||||
body: requestParams.body,
|
||||
})
|
||||
// Set up AbortController for timeout support on internal routes
|
||||
const controller = new AbortController()
|
||||
const timeout = requestParams.timeout || 300000
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
response = await fetch(fullUrl, {
|
||||
method: requestParams.method,
|
||||
headers: headers,
|
||||
body: requestParams.body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
// Convert AbortError to a timeout error message
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(`Request timed out after ${timeout}ms`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
} else {
|
||||
const urlValidation = await validateUrlWithDNS(fullUrl, 'toolUrl')
|
||||
if (!urlValidation.isValid) {
|
||||
@@ -640,6 +656,7 @@ async function executeToolRequest(
|
||||
method: requestParams.method,
|
||||
headers: headersRecord,
|
||||
body: requestParams.body ?? undefined,
|
||||
timeout: requestParams.timeout,
|
||||
})
|
||||
|
||||
const responseHeaders = new Headers(secureResponse.headers.toRecord())
|
||||
|
||||
@@ -75,6 +75,7 @@ interface RequestParams {
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body?: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,7 +123,17 @@ export function formatRequestParams(tool: ToolConfig, params: Record<string, any
|
||||
}
|
||||
}
|
||||
|
||||
return { url, method, headers, body }
|
||||
// Get timeout from params (if specified) and validate
|
||||
// Must be a finite positive number, max 600000ms (10 minutes) as documented
|
||||
const MAX_TIMEOUT_MS = 600000
|
||||
const rawTimeout = params.timeout
|
||||
const timeout = rawTimeout != null ? Number(rawTimeout) : undefined
|
||||
const validTimeout =
|
||||
timeout != null && Number.isFinite(timeout) && timeout > 0
|
||||
? Math.min(timeout, MAX_TIMEOUT_MS)
|
||||
: undefined
|
||||
|
||||
return { url, method, headers, body, timeout: validTimeout }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user