Skip to content

Commit e3e3ea9

Browse files
fix(vm): categorize user or server side errors
1 parent 65972f2 commit e3e3ea9

6 files changed

Lines changed: 175 additions & 17 deletions

File tree

apps/sim/app/api/workspaces/[id]/_preview/create-preview-route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { getSession } from '@/lib/auth'
55
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
6-
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
6+
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
77
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
88
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
99

@@ -83,6 +83,14 @@ export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) {
8383
})
8484
} catch (err) {
8585
const message = toError(err).message
86+
if (err instanceof SandboxUserCodeError) {
87+
logger.warn(`${config.label} preview user code failed`, {
88+
error: message,
89+
errorName: err.name,
90+
workspaceId,
91+
})
92+
return NextResponse.json({ error: message, errorName: err.name }, { status: 422 })
93+
}
8694
logger.error(`${config.label} preview generation failed`, { error: message, workspaceId })
8795
return NextResponse.json({ error: message }, { status: 500 })
8896
}

apps/sim/app/api/workspaces/[id]/docx/preview/route.test.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@ import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
88

9-
const { mockRunSandboxTask } = vi.hoisted(() => ({
10-
mockRunSandboxTask: vi.fn(),
11-
}))
9+
const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => {
10+
class SandboxUserCodeError extends Error {
11+
constructor(message: string, name: string) {
12+
super(message)
13+
this.name = name
14+
}
15+
}
16+
return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError }
17+
})
1218

1319
const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership
1420

1521
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
1622

1723
vi.mock('@/lib/execution/sandbox/run-task', () => ({
1824
runSandboxTask: mockRunSandboxTask,
25+
SandboxUserCodeError,
1926
}))
2027

2128
import { POST } from '@/app/api/workspaces/[id]/docx/preview/route'
@@ -189,4 +196,31 @@ describe('DOCX preview API route', () => {
189196
expect(response.status).toBe(500)
190197
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
191198
})
199+
200+
it('returns 422 when user code throws inside the sandbox', async () => {
201+
mockRunSandboxTask.mockRejectedValue(
202+
new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError')
203+
)
204+
205+
const request = new NextRequest(
206+
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
207+
{
208+
method: 'POST',
209+
headers: {
210+
'Content-Type': 'application/json',
211+
},
212+
body: JSON.stringify({ code: 'const x = ' }),
213+
}
214+
)
215+
216+
const response = await POST(request, {
217+
params: Promise.resolve({ id: 'workspace-1' }),
218+
})
219+
220+
expect(response.status).toBe(422)
221+
await expect(response.json()).resolves.toEqual({
222+
error: 'Invalid or unexpected token',
223+
errorName: 'SyntaxError',
224+
})
225+
})
192226
})

apps/sim/app/api/workspaces/[id]/pdf/preview/route.test.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@ import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
88

9-
const { mockRunSandboxTask } = vi.hoisted(() => ({
10-
mockRunSandboxTask: vi.fn(),
11-
}))
9+
const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => {
10+
class SandboxUserCodeError extends Error {
11+
constructor(message: string, name: string) {
12+
super(message)
13+
this.name = name
14+
}
15+
}
16+
return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError }
17+
})
1218

1319
const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership
1420

1521
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
1622

1723
vi.mock('@/lib/execution/sandbox/run-task', () => ({
1824
runSandboxTask: mockRunSandboxTask,
25+
SandboxUserCodeError,
1926
}))
2027

2128
import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route'
@@ -187,4 +194,31 @@ describe('PDF preview API route', () => {
187194
expect(response.status).toBe(500)
188195
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
189196
})
197+
198+
it('returns 422 when user code throws inside the sandbox', async () => {
199+
mockRunSandboxTask.mockRejectedValue(
200+
new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError')
201+
)
202+
203+
const request = new NextRequest(
204+
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
205+
{
206+
method: 'POST',
207+
headers: {
208+
'Content-Type': 'application/json',
209+
},
210+
body: JSON.stringify({ code: 'const x = ' }),
211+
}
212+
)
213+
214+
const response = await POST(request, {
215+
params: Promise.resolve({ id: 'workspace-1' }),
216+
})
217+
218+
expect(response.status).toBe(422)
219+
await expect(response.json()).resolves.toEqual({
220+
error: 'Invalid or unexpected token',
221+
errorName: 'SyntaxError',
222+
})
223+
})
190224
})

apps/sim/app/api/workspaces/[id]/pptx/preview/route.test.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@ import { NextRequest } from 'next/server'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
88

9-
const { mockRunSandboxTask } = vi.hoisted(() => ({
10-
mockRunSandboxTask: vi.fn(),
11-
}))
9+
const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => {
10+
class SandboxUserCodeError extends Error {
11+
constructor(message: string, name: string) {
12+
super(message)
13+
this.name = name
14+
}
15+
}
16+
return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError }
17+
})
1218

1319
const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership
1420

1521
vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
1622

1723
vi.mock('@/lib/execution/sandbox/run-task', () => ({
1824
runSandboxTask: mockRunSandboxTask,
25+
SandboxUserCodeError,
1926
}))
2027

2128
import { POST } from '@/app/api/workspaces/[id]/pptx/preview/route'
@@ -189,4 +196,31 @@ describe('PPTX preview API route', () => {
189196
expect(response.status).toBe(500)
190197
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
191198
})
199+
200+
it('returns 422 when user code throws inside the sandbox', async () => {
201+
mockRunSandboxTask.mockRejectedValue(
202+
new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError')
203+
)
204+
205+
const request = new NextRequest(
206+
'http://localhost:3000/api/workspaces/workspace-1/pptx/preview',
207+
{
208+
method: 'POST',
209+
headers: {
210+
'Content-Type': 'application/json',
211+
},
212+
body: JSON.stringify({ code: 'const x = ' }),
213+
}
214+
)
215+
216+
const response = await POST(request, {
217+
params: Promise.resolve({ id: 'workspace-1' }),
218+
})
219+
220+
expect(response.status).toBe(422)
221+
await expect(response.json()).resolves.toEqual({
222+
error: 'Invalid or unexpected token',
223+
errorName: 'SyntaxError',
224+
})
225+
})
192226
})

apps/sim/lib/execution/isolated-vm.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ export interface IsolatedVMError {
9999
line?: number
100100
column?: number
101101
lineContent?: string
102+
/**
103+
* True when the failure is host-infrastructure caused (worker crash, IPC
104+
* failure, pool saturation, task misconfig) rather than anything the user's
105+
* code did. Callers use this to keep genuine server failures as 5xx while
106+
* translating user-caused failures (code errors, timeouts, aborts, per-owner
107+
* rate limits) into 4xx. Defaults to undefined/false — new error sites
108+
* default to user-caused unless explicitly marked.
109+
*/
110+
isSystemError?: boolean
102111
}
103112

104113
const POOL_SIZE = Number.parseInt(env.IVM_POOL_SIZE) || 4
@@ -838,7 +847,11 @@ function cleanupWorker(workerId: number) {
838847
pending.resolve({
839848
result: null,
840849
stdout: '',
841-
error: { message: 'Code execution failed unexpectedly. Please try again.', name: 'Error' },
850+
error: {
851+
message: 'Code execution failed unexpectedly. Please try again.',
852+
name: 'Error',
853+
isSystemError: true,
854+
},
842855
})
843856
workerInfo.pendingExecutions.delete(id)
844857
}
@@ -1125,7 +1138,11 @@ function dispatchToWorker(
11251138
resolve({
11261139
result: null,
11271140
stdout: '',
1128-
error: { message: 'Code execution failed to start. Please try again.', name: 'Error' },
1141+
error: {
1142+
message: 'Code execution failed to start. Please try again.',
1143+
name: 'Error',
1144+
isSystemError: true,
1145+
},
11291146
})
11301147
if (workerInfo.retiring && workerInfo.activeExecutions === 0) {
11311148
cleanupWorker(workerInfo.id)
@@ -1159,6 +1176,7 @@ function enqueueExecution(
11591176
error: {
11601177
message: 'Code execution is at capacity. Please try again in a moment.',
11611178
name: 'Error',
1179+
isSystemError: true,
11621180
},
11631181
})
11641182
return
@@ -1198,6 +1216,7 @@ function enqueueExecution(
11981216
error: {
11991217
message: 'Code execution timed out waiting for an available worker. Please try again.',
12001218
name: 'Error',
1219+
isSystemError: true,
12011220
},
12021221
})
12031222
}, QUEUE_TIMEOUT_MS)
@@ -1294,6 +1313,7 @@ export async function executeInIsolatedVM(
12941313
error: {
12951314
message: `Task "${req.task.id}" requires broker "${brokerName}" but none was provided`,
12961315
name: 'Error',
1316+
isSystemError: true,
12971317
},
12981318
}
12991319
}

apps/sim/lib/execution/sandbox/run-task.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ export interface RunSandboxTaskOptions {
2020
signal?: AbortSignal
2121
}
2222

23+
/**
24+
* Thrown when the sandbox failure is attributable to the caller — user code
25+
* errors (SyntaxError, ReferenceError, user-thrown exceptions), timeouts from
26+
* user code, client aborts, or per-owner rate limits. Callers should translate
27+
* this into a 4xx response so genuine 5xx remains a signal of server health.
28+
*
29+
* System-origin failures (worker crash, IPC failure, pool saturation, task
30+
* misconfig) are tagged with `isSystemError` at the isolated-vm layer and
31+
* surface as a plain `Error` → 500.
32+
*/
33+
export class SandboxUserCodeError extends Error {
34+
constructor(message: string, name: string, stack?: string) {
35+
super(message)
36+
this.name = name || 'SandboxUserCodeError'
37+
if (stack) this.stack = stack
38+
}
39+
}
40+
2341
/**
2442
* Executes a sandbox task inside the shared isolated-vm pool and returns the
2543
* binary result buffer. Throws with a human-readable message if the task fails
@@ -70,7 +88,9 @@ export async function runSandboxTask<TInput extends SandboxTaskInput>(
7088
const queueMs = result.timings ? Math.max(0, elapsedMs - result.timings.total) : undefined
7189

7290
if (result.error) {
73-
logger.warn('Sandbox task failed', {
91+
const isSystemError = result.error.isSystemError === true
92+
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
93+
logFn('Sandbox task failed', {
7494
taskId,
7595
requestId,
7696
workspaceId: input.workspaceId,
@@ -79,11 +99,19 @@ export async function runSandboxTask<TInput extends SandboxTaskInput>(
7999
timings: result.timings,
80100
error: result.error.message,
81101
errorName: result.error.name,
102+
isSystemError,
82103
})
83-
const err = new Error(result.error.message)
84-
err.name = result.error.name || 'SandboxTaskError'
85-
if (result.error.stack) err.stack = result.error.stack
86-
throw err
104+
if (isSystemError) {
105+
const err = new Error(result.error.message)
106+
err.name = result.error.name || 'SandboxSystemError'
107+
if (result.error.stack) err.stack = result.error.stack
108+
throw err
109+
}
110+
throw new SandboxUserCodeError(
111+
result.error.message,
112+
result.error.name || 'SandboxTaskError',
113+
result.error.stack
114+
)
87115
}
88116

89117
if (typeof result.bytesBase64 !== 'string' || result.bytesBase64.length === 0) {

0 commit comments

Comments
 (0)