Skip to content

Commit a627faa

Browse files
feat(timeouts): execution timeout limits (#3120)
* feat(timeouts): execution timeout limits * fix type issues * add to docs * update stale exec cleanup route * update more callsites * update tests * address bugbot comments * remove import expression * support streaming and async paths' * fix streaming path * add hitl and workflow handler * make sync path match * consolidate * timeout errors * validation errors typed * import order * Merge staging into feat/timeout-lims Resolved conflicts: - stt/route.ts: Keep both execution timeout and security imports - textract/parse/route.ts: Keep both execution timeout and validation imports - use-workflow-execution.ts: Keep cancellation console entry from feature branch - input-validation.ts: Remove server functions (moved to .server.ts in staging) - tools/index.ts: Keep execution timeout, use .server import for security * make run from block consistent * revert console update change * fix subflow errors * clean up base 64 cache correctly * update docs * consolidate workflow execution and run from block hook code * remove unused constant * fix cleanup base64 sse * fix run from block tracespan
1 parent f811594 commit a627faa

File tree

53 files changed

+1149
-484
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+1149
-484
lines changed

apps/docs/content/docs/en/execution/costs.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,25 @@ Different subscription plans have different usage limits:
213213
| **Team** | $40/seat (pooled, adjustable) | 300 sync, 2,500 async |
214214
| **Enterprise** | Custom | Custom |
215215

216+
## Execution Time Limits
217+
218+
Workflows have maximum execution time limits based on your subscription plan:
219+
220+
| Plan | Sync Execution | Async Execution |
221+
|------|----------------|-----------------|
222+
| **Free** | 5 minutes | 10 minutes |
223+
| **Pro** | 60 minutes | 90 minutes |
224+
| **Team** | 60 minutes | 90 minutes |
225+
| **Enterprise** | 60 minutes | 90 minutes |
226+
227+
**Sync executions** run immediately and return results directly. These are triggered via the API with `async: false` (default) or through the UI.
228+
**Async executions** (triggered via API with `async: true`, webhooks, or schedules) run in the background. Async time limits are up to 2x the sync limit, capped at 90 minutes.
229+
230+
231+
<Callout type="info">
232+
If a workflow exceeds its time limit, it will be terminated and marked as failed with a timeout error. Design long-running workflows to use async execution or break them into smaller workflows.
233+
</Callout>
234+
216235
## Billing Model
217236

218237
Sim uses a **base subscription + overage** billing model:

apps/sim/app/(landing)/components/landing-pricing/landing-pricing.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
Database,
1212
DollarSign,
1313
HardDrive,
14-
Workflow,
14+
Timer,
1515
} from 'lucide-react'
1616
import { useRouter } from 'next/navigation'
1717
import { cn } from '@/lib/core/utils/cn'
@@ -44,7 +44,7 @@ interface PricingTier {
4444
const FREE_PLAN_FEATURES: PricingFeature[] = [
4545
{ icon: DollarSign, text: '$20 usage limit' },
4646
{ icon: HardDrive, text: '5GB file storage' },
47-
{ icon: Workflow, text: 'Public template access' },
47+
{ icon: Timer, text: '5 min execution limit' },
4848
{ icon: Database, text: 'Limited log retention' },
4949
{ icon: Code2, text: 'CLI/SDK Access' },
5050
]

apps/sim/app/api/cron/cleanup-stale-executions/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import { createLogger } from '@sim/logger'
44
import { and, eq, lt, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { verifyCronAuth } from '@/lib/auth/internal'
7+
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
78

89
const logger = createLogger('CleanupStaleExecutions')
910

10-
const STALE_THRESHOLD_MINUTES = 30
11+
const STALE_THRESHOLD_MS = getMaxExecutionTimeout() + 5 * 60 * 1000
12+
const STALE_THRESHOLD_MINUTES = Math.ceil(STALE_THRESHOLD_MS / 60000)
1113
const MAX_INT32 = 2_147_483_647
1214

1315
export async function GET(request: NextRequest) {

apps/sim/app/api/mcp/serve/[serverId]/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { and, eq } from 'drizzle-orm'
2121
import { type NextRequest, NextResponse } from 'next/server'
2222
import { checkHybridAuth } from '@/lib/auth/hybrid'
2323
import { generateInternalToken } from '@/lib/auth/internal'
24+
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
2425
import { getBaseUrl } from '@/lib/core/utils/urls'
2526

2627
const logger = createLogger('WorkflowMcpServeAPI')
@@ -264,7 +265,7 @@ async function handleToolsCall(
264265
method: 'POST',
265266
headers,
266267
body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }),
267-
signal: AbortSignal.timeout(600000), // 10 minute timeout
268+
signal: AbortSignal.timeout(getMaxExecutionTimeout()),
268269
})
269270

270271
const executeResult = await response.json()

apps/sim/app/api/mcp/tools/execute/route.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { createLogger } from '@sim/logger'
22
import type { NextRequest } from 'next/server'
3+
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
4+
import { getExecutionTimeout } from '@/lib/core/execution-limits'
5+
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
36
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
47
import { mcpService } from '@/lib/mcp/service'
58
import type { McpTool, McpToolCall, McpToolResult } from '@/lib/mcp/types'
69
import {
710
categorizeError,
811
createMcpErrorResponse,
912
createMcpSuccessResponse,
10-
MCP_CONSTANTS,
1113
validateStringParam,
1214
} from '@/lib/mcp/utils'
1315

@@ -171,13 +173,16 @@ export const POST = withMcpAuth('read')(
171173
arguments: args,
172174
}
173175

176+
const userSubscription = await getHighestPrioritySubscription(userId)
177+
const executionTimeout = getExecutionTimeout(
178+
userSubscription?.plan as SubscriptionPlan | undefined,
179+
'sync'
180+
)
181+
174182
const result = await Promise.race([
175183
mcpService.executeTool(userId, serverId, toolCall, workspaceId),
176184
new Promise<never>((_, reject) =>
177-
setTimeout(
178-
() => reject(new Error('Tool execution timeout')),
179-
MCP_CONSTANTS.EXECUTION_TIMEOUT
180-
)
185+
setTimeout(() => reject(new Error('Tool execution timeout')), executionTimeout)
181186
),
182187
])
183188

apps/sim/app/api/tools/dropbox/upload/route.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { z } from 'zod'
44
import { checkInternalAuth } from '@/lib/auth/hybrid'
55
import { generateRequestId } from '@/lib/core/utils/request'
6+
import { httpHeaderSafeJson } from '@/lib/core/utils/validation'
67
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
78
import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils'
89
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -11,16 +12,6 @@ export const dynamic = 'force-dynamic'
1112

1213
const logger = createLogger('DropboxUploadAPI')
1314

14-
/**
15-
* Escapes non-ASCII characters in JSON string for HTTP header safety.
16-
* Dropbox API requires characters 0x7F and all non-ASCII to be escaped as \uXXXX.
17-
*/
18-
function httpHeaderSafeJson(value: object): string {
19-
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => {
20-
return '\\u' + ('0000' + c.charCodeAt(0).toString(16)).slice(-4)
21-
})
22-
}
23-
2415
const DropboxUploadSchema = z.object({
2516
accessToken: z.string().min(1, 'Access token is required'),
2617
path: z.string().min(1, 'Destination path is required'),

apps/sim/app/api/tools/stt/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor'
44
import { checkInternalAuth } from '@/lib/auth/hybrid'
5+
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
56
import {
67
secureFetchWithPinnedIP,
78
validateUrlWithDNS,
@@ -636,7 +637,8 @@ async function transcribeWithAssemblyAI(
636637

637638
let transcript: any
638639
let attempts = 0
639-
const maxAttempts = 60 // 5 minutes with 5-second intervals
640+
const pollIntervalMs = 5000
641+
const maxAttempts = Math.ceil(DEFAULT_EXECUTION_TIMEOUT_MS / pollIntervalMs)
640642

641643
while (attempts < maxAttempts) {
642644
const statusResponse = await fetch(`https://api.assemblyai.com/v2/transcript/${id}`, {

apps/sim/app/api/tools/textract/parse/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { z } from 'zod'
55
import { checkInternalAuth } from '@/lib/auth/hybrid'
6+
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
67
import { validateAwsRegion, validateS3BucketName } from '@/lib/core/security/input-validation'
78
import {
89
secureFetchWithPinnedIP,
@@ -226,8 +227,8 @@ async function pollForJobCompletion(
226227
useAnalyzeDocument: boolean,
227228
requestId: string
228229
): Promise<Record<string, unknown>> {
229-
const pollIntervalMs = 5000 // 5 seconds between polls
230-
const maxPollTimeMs = 180000 // 3 minutes maximum polling time
230+
const pollIntervalMs = 5000
231+
const maxPollTimeMs = DEFAULT_EXECUTION_TIMEOUT_MS
231232
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)
232233

233234
const getTarget = useAnalyzeDocument

apps/sim/app/api/tools/tts/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import type { NextRequest } from 'next/server'
33
import { NextResponse } from 'next/server'
44
import { checkInternalAuth } from '@/lib/auth/hybrid'
5+
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
56
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
67
import { getBaseUrl } from '@/lib/core/utils/urls'
78
import { StorageService } from '@/lib/uploads'
@@ -60,7 +61,7 @@ export async function POST(request: NextRequest) {
6061
text,
6162
model_id: modelId,
6263
}),
63-
signal: AbortSignal.timeout(60000),
64+
signal: AbortSignal.timeout(DEFAULT_EXECUTION_TIMEOUT_MS),
6465
})
6566

6667
if (!response.ok) {

apps/sim/app/api/tools/video/route.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { checkInternalAuth } from '@/lib/auth/hybrid'
4+
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
45
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
56
import type { UserFile } from '@/executor/types'
67
import type { VideoRequestBody } from '@/tools/video/types'
@@ -326,11 +327,12 @@ async function generateWithRunway(
326327

327328
logger.info(`[${requestId}] Runway task created: ${taskId}`)
328329

329-
const maxAttempts = 120 // 10 minutes with 5-second intervals
330+
const pollIntervalMs = 5000
331+
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
330332
let attempts = 0
331333

332334
while (attempts < maxAttempts) {
333-
await sleep(5000) // Poll every 5 seconds
335+
await sleep(pollIntervalMs)
334336

335337
const statusResponse = await fetch(`https://api.dev.runwayml.com/v1/tasks/${taskId}`, {
336338
headers: {
@@ -370,7 +372,7 @@ async function generateWithRunway(
370372
attempts++
371373
}
372374

373-
throw new Error('Runway generation timed out after 10 minutes')
375+
throw new Error('Runway generation timed out')
374376
}
375377

376378
async function generateWithVeo(
@@ -429,11 +431,12 @@ async function generateWithVeo(
429431

430432
logger.info(`[${requestId}] Veo operation created: ${operationName}`)
431433

432-
const maxAttempts = 60 // 5 minutes with 5-second intervals
434+
const pollIntervalMs = 5000
435+
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
433436
let attempts = 0
434437

435438
while (attempts < maxAttempts) {
436-
await sleep(5000)
439+
await sleep(pollIntervalMs)
437440

438441
const statusResponse = await fetch(
439442
`https://generativelanguage.googleapis.com/v1beta/${operationName}`,
@@ -485,7 +488,7 @@ async function generateWithVeo(
485488
attempts++
486489
}
487490

488-
throw new Error('Veo generation timed out after 5 minutes')
491+
throw new Error('Veo generation timed out')
489492
}
490493

491494
async function generateWithLuma(
@@ -541,11 +544,12 @@ async function generateWithLuma(
541544

542545
logger.info(`[${requestId}] Luma generation created: ${generationId}`)
543546

544-
const maxAttempts = 120 // 10 minutes
547+
const pollIntervalMs = 5000
548+
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
545549
let attempts = 0
546550

547551
while (attempts < maxAttempts) {
548-
await sleep(5000)
552+
await sleep(pollIntervalMs)
549553

550554
const statusResponse = await fetch(
551555
`https://api.lumalabs.ai/dream-machine/v1/generations/${generationId}`,
@@ -592,7 +596,7 @@ async function generateWithLuma(
592596
attempts++
593597
}
594598

595-
throw new Error('Luma generation timed out after 10 minutes')
599+
throw new Error('Luma generation timed out')
596600
}
597601

598602
async function generateWithMiniMax(
@@ -658,14 +662,13 @@ async function generateWithMiniMax(
658662

659663
logger.info(`[${requestId}] MiniMax task created: ${taskId}`)
660664

661-
// Poll for completion (6-10 minutes typical)
662-
const maxAttempts = 120 // 10 minutes with 5-second intervals
665+
const pollIntervalMs = 5000
666+
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
663667
let attempts = 0
664668

665669
while (attempts < maxAttempts) {
666-
await sleep(5000)
670+
await sleep(pollIntervalMs)
667671

668-
// Query task status
669672
const statusResponse = await fetch(
670673
`https://api.minimax.io/v1/query/video_generation?task_id=${taskId}`,
671674
{
@@ -743,7 +746,7 @@ async function generateWithMiniMax(
743746
attempts++
744747
}
745748

746-
throw new Error('MiniMax generation timed out after 10 minutes')
749+
throw new Error('MiniMax generation timed out')
747750
}
748751

749752
// Helper function to strip subpaths from Fal.ai model IDs for status/result endpoints
@@ -861,11 +864,12 @@ async function generateWithFalAI(
861864
// Get base model ID (without subpath) for status and result endpoints
862865
const baseModelId = getBaseModelId(falModelId)
863866

864-
const maxAttempts = 96 // 8 minutes with 5-second intervals
867+
const pollIntervalMs = 5000
868+
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
865869
let attempts = 0
866870

867871
while (attempts < maxAttempts) {
868-
await sleep(5000)
872+
await sleep(pollIntervalMs)
869873

870874
const statusResponse = await fetch(
871875
`https://queue.fal.run/${baseModelId}/requests/${requestIdFal}/status`,
@@ -938,7 +942,7 @@ async function generateWithFalAI(
938942
attempts++
939943
}
940944

941-
throw new Error('Fal.ai generation timed out after 8 minutes')
945+
throw new Error('Fal.ai generation timed out')
942946
}
943947

944948
function getVideoDimensions(

0 commit comments

Comments
 (0)