diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts index 6476b53f5c9..e6560c71f64 100644 --- a/apps/sim/app/api/tools/image/route.ts +++ b/apps/sim/app/api/tools/image/route.ts @@ -39,7 +39,12 @@ const MAX_IMAGE_BYTES = 25 * 1024 * 1024 const MAX_IMAGE_JSON_BYTES = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 256 * 1024 export const dynamic = 'force-dynamic' -export const maxDuration = 600 +/** + * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by + * `getMaxExecutionTimeout()` for the provider polling loop below. Next.js requires a + * static literal for `maxDuration`, so this value must be kept in sync with that source. + */ +export const maxDuration = 5400 type ImageProvider = (typeof imageProviders)[number] diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts index a320bcce008..fd9a6dc12d7 100644 --- a/apps/sim/app/api/tools/stt/route.ts +++ b/apps/sim/app/api/tools/stt/route.ts @@ -7,7 +7,7 @@ import { sttToolContract } from '@/lib/api/contracts/tools/media/stt' import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server' import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { secureFetchWithPinnedIP, validateUrlWithDNS, @@ -25,7 +25,12 @@ const logger = createLogger('SttProxyAPI') const ELEVENLABS_STT_MODEL = 'scribe_v2' export const dynamic = 'force-dynamic' -export const maxDuration = 300 // 5 minutes for large files +/** + * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by + * `getMaxExecutionTimeout()` for the transcript polling loop below. Next.js requires a + * static literal for `maxDuration`, so this value must be kept in sync with that source. + */ +export const maxDuration = 5400 export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateId() @@ -629,7 +634,7 @@ async function transcribeWithAssemblyAI( let transcript: any let attempts = 0 const pollIntervalMs = 5000 - const maxAttempts = Math.ceil(DEFAULT_EXECUTION_TIMEOUT_MS / pollIntervalMs) + const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs) while (attempts < maxAttempts) { const statusResponse = await fetch(`https://api.assemblyai.com/v2/transcript/${id}`, { diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 48e6f07899f..b93cbbed4d9 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { textractParseContract } from '@/lib/api/contracts/tools/media/document-parse' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { validateS3BucketName } from '@/lib/core/security/input-validation' import { secureFetchWithPinnedIP, @@ -22,7 +22,12 @@ import { import { assertToolFileAccess } from '@/app/api/files/authorization' export const dynamic = 'force-dynamic' -export const maxDuration = 300 // 5 minutes for large multi-page PDF processing +/** + * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by + * `getMaxExecutionTimeout()` for the job polling loop below. Next.js requires a static + * literal for `maxDuration`, so this value must be kept in sync with that source. + */ +export const maxDuration = 5400 const logger = createLogger('TextractParseAPI') @@ -184,7 +189,7 @@ async function pollForJobCompletion( requestId: string ): Promise> { const pollIntervalMs = 5000 - const maxPollTimeMs = DEFAULT_EXECUTION_TIMEOUT_MS + const maxPollTimeMs = getMaxExecutionTimeout() const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs) const getTarget = useAnalyzeDocument diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts index 1110432a473..9980121ed4c 100644 --- a/apps/sim/app/api/tools/video/route.ts +++ b/apps/sim/app/api/tools/video/route.ts @@ -28,7 +28,12 @@ const MAX_VIDEO_REFERENCE_IMAGE_BYTES = 25 * 1024 * 1024 const MAX_VIDEO_JSON_BYTES = 2 * 1024 * 1024 export const dynamic = 'force-dynamic' -export const maxDuration = 600 // 10 minutes for video generation +/** + * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by + * `getMaxExecutionTimeout()` for the provider polling loops below. Next.js requires a + * static literal for `maxDuration`, so this value must be kept in sync with that source. + */ +export const maxDuration = 5400 async function readVideoResponseBuffer(response: Response, label: string): Promise { return readResponseToBufferWithLimit(response, { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 3c83d634f01..40db9898868 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -5,7 +5,7 @@ import { randomFloat } from '@sim/utils/random' import { getBYOKKey } from '@/lib/api-key/byok' import { generateInternalToken } from '@/lib/auth/internal' import { isHosted } from '@/lib/core/config/feature-flags' -import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +import { DEFAULT_EXECUTION_TIMEOUT_MS, getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getHostedKeyRateLimiter } from '@/lib/core/rate-limiter' import { secureFetchWithPinnedIP, @@ -879,6 +879,9 @@ export async function executeTool( options: ExecuteToolOptions = {} ): Promise { const { skipPostProcess = false, executionContext, signal } = options + // Fall back to the workflow execution's abort signal so plan-based execution timeouts + // and cancellation propagate to tool fetches when the caller passes no explicit signal. + const effectiveSignal = signal ?? executionContext?.abortSignal // Capture start time for precise timing const startTime = new Date() const startTimeISO = startTime.toISOString() @@ -972,7 +975,7 @@ export async function executeTool( executionContext, requestId, startTimeISO, - signal + effectiveSignal ) } else { // For built-in tools, use the synchronous version @@ -1169,23 +1172,26 @@ export async function executeTool( // Execute the tool request directly (internal routes use regular fetch, external use SSRF-protected fetch) // Wrap with retry logic for hosted keys to handle rate limiting due to higher usage const result = hostedKeyInfo.isUsingHostedKey - ? await executeWithRetry(() => executeToolRequest(toolId, tool, contextParams, signal), { - requestId, - toolId, - envVarName: hostedKeyInfo.envVarName!, - executionContext, - reacquireAfterRetriesExhausted: async () => { - const reacquired = await reacquireHostedKey( - tool, - contextParams, - executionContext, - requestId - ) - if (!reacquired) return null - return () => executeToolRequest(toolId, tool, contextParams) - }, - }) - : await executeToolRequest(toolId, tool, contextParams, signal) + ? await executeWithRetry( + () => executeToolRequest(toolId, tool, contextParams, effectiveSignal), + { + requestId, + toolId, + envVarName: hostedKeyInfo.envVarName!, + executionContext, + reacquireAfterRetriesExhausted: async () => { + const reacquired = await reacquireHostedKey( + tool, + contextParams, + executionContext, + requestId + ) + if (!reacquired) return null + return () => executeToolRequest(toolId, tool, contextParams, effectiveSignal) + }, + } + ) + : await executeToolRequest(toolId, tool, contextParams, effectiveSignal) // Apply post-processing if available and not skipped let finalResult = result @@ -1576,7 +1582,11 @@ async function executeToolRequest( try { if (isInternalRoute) { const controller = new AbortController() - const timeout = requestParams.timeout || DEFAULT_EXECUTION_TIMEOUT_MS + // With a caller/execution abort signal present, the plan-based timeout bounds the call and + // this only acts as a ceiling; without one, keep the tighter default as the hang safety net. + const timeout = + requestParams.timeout || + (signal ? getMaxExecutionTimeout() : DEFAULT_EXECUTION_TIMEOUT_MS) const timeoutId = setTimeout( () => controller.abort(`timeout:internal_tool_fetch:${timeout}ms`), timeout