diff --git a/apps/sim/blocks/blocks/api.ts b/apps/sim/blocks/blocks/api.ts index 50ee12eb3f..5720c85e5d 100644 --- a/apps/sim/blocks/blocks/api.ts +++ b/apps/sim/blocks/blocks/api.ts @@ -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'], @@ -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)' }, diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index dfb26dd24d..6fbde34947 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -40,6 +40,12 @@ export const requestTool: ToolConfig = { 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: { diff --git a/apps/sim/tools/http/types.ts b/apps/sim/tools/http/types.ts index aee763469a..feff927f17 100644 --- a/apps/sim/tools/http/types.ts +++ b/apps/sim/tools/http/types.ts @@ -8,6 +8,7 @@ export interface RequestParams { params?: TableRow[] pathParams?: Record formData?: Record + timeout?: number } export interface RequestResponse extends ToolResponse { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index b4898a6860..bfad82759f 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -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) { @@ -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 diff --git a/apps/sim/tools/timeout.test.ts b/apps/sim/tools/timeout.test.ts new file mode 100644 index 0000000000..464d1a72d9 --- /dev/null +++ b/apps/sim/tools/timeout.test.ts @@ -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') + }) + }) +}) diff --git a/helm/sim/templates/networkpolicy.yaml b/helm/sim/templates/networkpolicy.yaml index deac5a5dba..7ef8697417 100644 --- a/helm/sim/templates/networkpolicy.yaml +++ b/helm/sim/templates/networkpolicy.yaml @@ -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 }}