fix(polling-tools): pass plan execution timeout to internal polling tool routes (#4884)

* fix(polling-tools): pass plan execution timeout to internal polling tool routes

* address comments
This commit is contained in:
Vikhyath Mondreti
2026-06-04 14:35:24 -07:00
committed by GitHub
parent e761043da7
commit 71f693645c
5 changed files with 58 additions and 28 deletions
+6 -1
View File
@@ -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]
+8 -3
View File
@@ -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}`, {
@@ -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<Record<string, unknown>> {
const pollIntervalMs = 5000
const maxPollTimeMs = DEFAULT_EXECUTION_TIMEOUT_MS
const maxPollTimeMs = getMaxExecutionTimeout()
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)
const getTarget = useAnalyzeDocument
+6 -1
View File
@@ -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<Buffer> {
return readResponseToBufferWithLimit(response, {
+30 -20
View File
@@ -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<ToolResponse> {
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