Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/sim/blocks/blocks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ Example:
generationType: 'json-object',
},
},
{
id: 'timeout',
title: 'Timeout (ms)',
type: 'short-input',
placeholder: '120000',
description:
'Request timeout in milliseconds. Default: 120000ms (2 min). Max: 600000ms (10 min).',
},
],
tools: {
access: ['http_request'],
Expand All @@ -90,6 +98,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)' },
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/tools/http/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
type: 'object',
description: 'Form data to send (will set appropriate Content-Type)',
},
timeout: {
type: 'number',
default: 120000,
description:
'Request timeout in milliseconds. Default is 120000ms (2 minutes). Max is 600000ms (10 minutes).',
},
},

request: {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/tools/http/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
70 changes: 62 additions & 8 deletions apps/sim/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,14 +648,41 @@ async function handleInternalRequest(
// Check request body size before sending to detect potential size limit issues
validateRequestBodySize(requestParams.body, requestId, toolId)

// Prepare request options
const requestOptions = {
// Determine timeout: use params.timeout if provided, otherwise default to 120000ms (2 min)
// Max timeout is 600000ms (10 minutes) to prevent indefinite waits
const DEFAULT_TIMEOUT_MS = 120000
const MAX_TIMEOUT_MS = 600000
let timeoutMs = DEFAULT_TIMEOUT_MS
if (typeof params.timeout === 'number' && params.timeout > 0) {
timeoutMs = Math.min(params.timeout, MAX_TIMEOUT_MS)
} else if (typeof params.timeout === 'string') {
const parsed = Number.parseInt(params.timeout, 10)
if (!Number.isNaN(parsed) && parsed > 0) {
timeoutMs = Math.min(parsed, MAX_TIMEOUT_MS)
}
}

// Prepare request options with timeout signal
const requestOptions: RequestInit = {
method: requestParams.method,
headers: headers,
body: requestParams.body,
signal: AbortSignal.timeout(timeoutMs),
}

const response = await fetch(fullUrl, requestOptions)
let response: Response
try {
response = await fetch(fullUrl, requestOptions)
} catch (fetchError) {
// Handle timeout error specifically
if (fetchError instanceof Error && fetchError.name === 'TimeoutError') {
logger.error(`[${requestId}] Request timed out for ${toolId} after ${timeoutMs}ms`)
throw new Error(
`Request timed out after ${timeoutMs}ms. Consider increasing the timeout value.`
)
}
throw fetchError
}

// For non-OK responses, attempt JSON first; if parsing fails, fall back to text
if (!response.ok) {
Expand Down Expand Up @@ -866,11 +893,38 @@ async function handleProxyRequest(
// Check request body size before sending
validateRequestBodySize(body, requestId, `proxy:${toolId}`)

const response = await fetch(proxyUrl, {
method: 'POST',
headers,
body,
})
// Determine timeout for proxy request: use params.timeout if provided, otherwise default
// This ensures the proxy fetch itself doesn't timeout before the actual API request
const DEFAULT_TIMEOUT_MS = 120000
const MAX_TIMEOUT_MS = 600000
let timeoutMs = DEFAULT_TIMEOUT_MS
if (typeof params.timeout === 'number' && params.timeout > 0) {
timeoutMs = Math.min(params.timeout, MAX_TIMEOUT_MS)
} else if (typeof params.timeout === 'string') {
const parsed = Number.parseInt(params.timeout, 10)
if (!Number.isNaN(parsed) && parsed > 0) {
timeoutMs = Math.min(parsed, MAX_TIMEOUT_MS)
}
}

let response: Response
try {
response = await fetch(proxyUrl, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(timeoutMs),
})
} catch (fetchError) {
// Handle timeout error specifically
if (fetchError instanceof Error && fetchError.name === 'TimeoutError') {
logger.error(`[${requestId}] Proxy request timed out for ${toolId} after ${timeoutMs}ms`)
throw new Error(
`Request timed out after ${timeoutMs}ms. Consider increasing the timeout value.`
)
}
throw fetchError
}

if (!response.ok) {
// Check for 413 (Entity Too Large) - body size limit exceeded
Expand Down
221 changes: 221 additions & 0 deletions apps/sim/tools/timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

/**
* Tests for timeout functionality in handleProxyRequest and handleInternalRequest
*/
describe('HTTP Timeout Support', () => {
const originalFetch = global.fetch

beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
global.fetch = originalFetch
vi.useRealTimers()
vi.restoreAllMocks()
})

describe('Timeout Parameter Parsing', () => {
it('should parse numeric timeout correctly', () => {
const params = { timeout: 5000 }
const DEFAULT_TIMEOUT_MS = 120000
const MAX_TIMEOUT_MS = 600000

let timeoutMs = DEFAULT_TIMEOUT_MS
if (typeof params.timeout === 'number' && params.timeout > 0) {
timeoutMs = Math.min(params.timeout, MAX_TIMEOUT_MS)
}

expect(timeoutMs).toBe(5000)
})

it('should parse string timeout correctly', () => {
const params = { timeout: '30000' }
const DEFAULT_TIMEOUT_MS = 120000
const MAX_TIMEOUT_MS = 600000

let timeoutMs = DEFAULT_TIMEOUT_MS
if (typeof params.timeout === 'number' && params.timeout > 0) {
timeoutMs = Math.min(params.timeout, MAX_TIMEOUT_MS)
} else if (typeof params.timeout === 'string') {
const parsed = Number.parseInt(params.timeout, 10)
if (!Number.isNaN(parsed) && parsed > 0) {
timeoutMs = Math.min(parsed, MAX_TIMEOUT_MS)
}
}

expect(timeoutMs).toBe(30000)
})

it('should cap timeout at MAX_TIMEOUT_MS', () => {
const params = { timeout: 1000000 } // 1000 seconds, exceeds max
const DEFAULT_TIMEOUT_MS = 120000
const MAX_TIMEOUT_MS = 600000

let timeoutMs = DEFAULT_TIMEOUT_MS
if (typeof params.timeout === 'number' && params.timeout > 0) {
timeoutMs = Math.min(params.timeout, MAX_TIMEOUT_MS)
}

expect(timeoutMs).toBe(MAX_TIMEOUT_MS)
})

it('should use default timeout when no timeout provided', () => {
const params = {}
const DEFAULT_TIMEOUT_MS = 120000
const MAX_TIMEOUT_MS = 600000

let timeoutMs = DEFAULT_TIMEOUT_MS
if (typeof (params as any).timeout === 'number' && (params as any).timeout > 0) {
timeoutMs = Math.min((params as any).timeout, MAX_TIMEOUT_MS)
}

expect(timeoutMs).toBe(DEFAULT_TIMEOUT_MS)
})

it('should use default timeout for invalid string', () => {
const params = { timeout: 'invalid' }
const DEFAULT_TIMEOUT_MS = 120000
const MAX_TIMEOUT_MS = 600000

let timeoutMs = DEFAULT_TIMEOUT_MS
if (typeof params.timeout === 'number' && params.timeout > 0) {
timeoutMs = Math.min(params.timeout, MAX_TIMEOUT_MS)
} else if (typeof params.timeout === 'string') {
const parsed = Number.parseInt(params.timeout, 10)
if (!Number.isNaN(parsed) && parsed > 0) {
timeoutMs = Math.min(parsed, MAX_TIMEOUT_MS)
}
}

expect(timeoutMs).toBe(DEFAULT_TIMEOUT_MS)
})

it('should use default timeout for zero or negative values', () => {
const testCases = [{ timeout: 0 }, { timeout: -1000 }, { timeout: '0' }, { timeout: '-500' }]
const DEFAULT_TIMEOUT_MS = 120000
const MAX_TIMEOUT_MS = 600000

for (const params of testCases) {
let timeoutMs = DEFAULT_TIMEOUT_MS
if (typeof params.timeout === 'number' && params.timeout > 0) {
timeoutMs = Math.min(params.timeout, MAX_TIMEOUT_MS)
} else if (typeof params.timeout === 'string') {
const parsed = Number.parseInt(params.timeout, 10)
if (!Number.isNaN(parsed) && parsed > 0) {
timeoutMs = Math.min(parsed, MAX_TIMEOUT_MS)
}
}

expect(timeoutMs).toBe(DEFAULT_TIMEOUT_MS)
}
})
})

describe('AbortSignal.timeout Integration', () => {
it('should create AbortSignal with correct timeout', () => {
const timeoutMs = 5000
const signal = AbortSignal.timeout(timeoutMs)

expect(signal).toBeDefined()
expect(signal.aborted).toBe(false)
})

it('should abort after timeout period', async () => {
vi.useRealTimers() // Need real timers for this test

const timeoutMs = 100 // Very short timeout for testing
const signal = AbortSignal.timeout(timeoutMs)

// Wait for timeout to trigger
await new Promise((resolve) => setTimeout(resolve, timeoutMs + 50))

expect(signal.aborted).toBe(true)
})
})

describe('Timeout Error Handling', () => {
it('should identify TimeoutError correctly', () => {
const timeoutError = new Error('The operation was aborted')
timeoutError.name = 'TimeoutError'

const isTimeoutError = timeoutError instanceof Error && timeoutError.name === 'TimeoutError'

expect(isTimeoutError).toBe(true)
})

it('should generate user-friendly timeout message', () => {
const timeoutMs = 5000
const errorMessage = `Request timed out after ${timeoutMs}ms. Consider increasing the timeout value.`

expect(errorMessage).toBe(
'Request timed out after 5000ms. Consider increasing the timeout value.'
)
})
})

describe('Fetch with Timeout Signal', () => {
it('should pass signal to fetch options', async () => {
vi.useRealTimers()

const mockFetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
global.fetch = mockFetch

const timeoutMs = 5000
await fetch('https://example.com/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test: true }),
signal: AbortSignal.timeout(timeoutMs),
})

expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api',
expect.objectContaining({
signal: expect.any(AbortSignal),
})
)
})

it('should throw TimeoutError when request times out', async () => {
vi.useRealTimers()

// Mock a slow fetch that will be aborted
global.fetch = vi.fn().mockImplementation(
(_url: string, options: RequestInit) =>
new Promise((_resolve, reject) => {
if (options?.signal) {
options.signal.addEventListener('abort', () => {
const error = new Error('The operation was aborted')
error.name = 'TimeoutError'
reject(error)
})
}
})
)

const timeoutMs = 100
let caughtError: Error | null = null

try {
await fetch('https://example.com/slow-api', {
signal: AbortSignal.timeout(timeoutMs),
})
} catch (error) {
caughtError = error as Error
}

// Wait a bit for the timeout to trigger
await new Promise((resolve) => setTimeout(resolve, timeoutMs + 50))

expect(caughtError).not.toBeNull()
expect(caughtError?.name).toBe('TimeoutError')
})
})
})
4 changes: 4 additions & 0 deletions helm/sim/templates/networkpolicy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ spec:
ports:
- protocol: TCP
port: 443
# Allow custom egress rules
{{- with .Values.networkPolicy.egress }}
{{- toYaml . | nindent 2 }}
{{- end }}
{{- end }}

{{- if .Values.postgresql.enabled }}
Expand Down
Loading