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 @@ -49,6 +49,30 @@ const MAX_REQUEST_BODY_SIZE_BYTES = 10 * 1024 * 1024 // 10MB
const BODY_SIZE_LIMIT_ERROR_MESSAGE =
'Request body size limit exceeded (10MB). The workflow data is too large to process. Try reducing the size of variables, inputs, or data being passed between blocks.'

/** Default timeout for HTTP requests in milliseconds (2 minutes) */
const DEFAULT_TIMEOUT_MS = 120000

/** Maximum allowed timeout for HTTP requests in milliseconds (10 minutes) */
const MAX_TIMEOUT_MS = 600000

/**
* Parses and validates a timeout value from params
* @param timeout - The timeout value (number or string) from params
* @returns The validated timeout in milliseconds, capped at MAX_TIMEOUT_MS
*/
function parseTimeout(timeout: number | string | undefined): number {
if (typeof timeout === 'number' && timeout > 0) {
return Math.min(timeout, MAX_TIMEOUT_MS)
}
if (typeof timeout === 'string') {
const parsed = Number.parseInt(timeout, 10)
if (!Number.isNaN(parsed) && parsed > 0) {
return Math.min(parsed, MAX_TIMEOUT_MS)
}
}
return DEFAULT_TIMEOUT_MS
}

/**
* Validates request body size and throws a user-friendly error if exceeded
* @param body - The request body string to check
Expand Down Expand Up @@ -650,14 +674,29 @@ 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 = {
const timeoutMs = parseTimeout(params.timeout)

// 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 @@ -870,11 +909,26 @@ async function handleProxyRequest(
// Check request body size before sending
validateRequestBodySize(body, requestId, `proxy:${toolId}`)

const response = await fetch(proxyUrl, {
method: 'POST',
headers,
body,
})
const timeoutMs = parseTimeout(params.timeout)

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