fix(vm): categorize user or server side errors (#4283)

* fix(vm): categorize user or server side errors

* recategorize function syntax errors as 4xx
This commit is contained in:
Theodore Li
2026-04-27 19:27:50 -07:00
committed by GitHub
parent c95ac3bc23
commit 154b9d0883
9 changed files with 192 additions and 27 deletions

View File

@@ -191,7 +191,7 @@ describe('Function Execute API Route', () => {
const response = await POST(req)
const data = await response.json()
if (response.status === 500) {
if (response.status === 422 || response.status === 500) {
expect(data.success).toBe(false)
} else {
const result = data.output?.result
@@ -504,7 +504,7 @@ describe('Function Execute API Route', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(response.status).toBe(422)
expect(data.success).toBe(false)
expect(data.error).toBeTruthy()
})
@@ -518,7 +518,7 @@ describe('Function Execute API Route', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(response.status).toBe(422)
expect(data.success).toBe(false)
expect(data.error).toContain('Type Error')
expect(data.error).toContain('Cannot read properties of null')
@@ -533,7 +533,7 @@ describe('Function Execute API Route', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(response.status).toBe(422)
expect(data.success).toBe(false)
expect(data.error).toContain('Reference Error')
expect(data.error).toContain('undefinedVariable is not defined')
@@ -548,7 +548,7 @@ describe('Function Execute API Route', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(response.status).toBe(422)
expect(data.success).toBe(false)
expect(data.error).toContain('Custom error message')
})
@@ -562,7 +562,7 @@ describe('Function Execute API Route', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(500)
expect(response.status).toBe(422)
expect(data.success).toBe(false)
expect(data.error).toBeTruthy()
})

View File

@@ -1088,9 +1088,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const executionTime = Date.now() - startTime
if (isolatedResult.error) {
logger.error(`[${requestId}] Function execution failed in isolated-vm`, {
const isSystemError = isolatedResult.error.isSystemError === true
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
logFn(`[${requestId}] Function execution failed in isolated-vm`, {
error: isolatedResult.error,
executionTime,
isSystemError,
})
const ivmError = isolatedResult.error
@@ -1119,7 +1122,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
resolvedCode
)
logger.error(`[${requestId}] Enhanced error details`, {
const detailLogFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
detailLogFn(`[${requestId}] Enhanced error details`, {
originalMessage: ivmError.message,
enhancedMessage: userFriendlyErrorMessage,
line: enhancedError.line,
@@ -1145,7 +1149,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
stack: enhancedError.stack,
},
},
{ status: 500 }
{ status: isSystemError ? 500 : 422 }
)
}

View File

@@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
@@ -83,6 +83,14 @@ export function createDocumentPreviewRoute(config: DocumentPreviewRouteConfig) {
})
} catch (err) {
const message = toError(err).message
if (err instanceof SandboxUserCodeError) {
logger.warn(`${config.label} preview user code failed`, {
error: message,
errorName: err.name,
workspaceId,
})
return NextResponse.json({ error: message, errorName: err.name }, { status: 422 })
}
logger.error(`${config.label} preview generation failed`, { error: message, workspaceId })
return NextResponse.json({ error: message }, { status: 500 })
}

View File

@@ -6,9 +6,15 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
const { mockRunSandboxTask } = vi.hoisted(() => ({
mockRunSandboxTask: vi.fn(),
}))
const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => {
class SandboxUserCodeError extends Error {
constructor(message: string, name: string) {
super(message)
this.name = name
}
}
return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError }
})
const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership
@@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/lib/execution/sandbox/run-task', () => ({
runSandboxTask: mockRunSandboxTask,
SandboxUserCodeError,
}))
import { POST } from '@/app/api/workspaces/[id]/docx/preview/route'
@@ -189,4 +196,31 @@ describe('DOCX preview API route', () => {
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
})
it('returns 422 when user code throws inside the sandbox', async () => {
mockRunSandboxTask.mockRejectedValue(
new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError')
)
const request = new NextRequest(
'http://localhost:3000/api/workspaces/workspace-1/docx/preview',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: 'const x = ' }),
}
)
const response = await POST(request, {
params: Promise.resolve({ id: 'workspace-1' }),
})
expect(response.status).toBe(422)
await expect(response.json()).resolves.toEqual({
error: 'Invalid or unexpected token',
errorName: 'SyntaxError',
})
})
})

View File

@@ -6,9 +6,15 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
const { mockRunSandboxTask } = vi.hoisted(() => ({
mockRunSandboxTask: vi.fn(),
}))
const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => {
class SandboxUserCodeError extends Error {
constructor(message: string, name: string) {
super(message)
this.name = name
}
}
return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError }
})
const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership
@@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/lib/execution/sandbox/run-task', () => ({
runSandboxTask: mockRunSandboxTask,
SandboxUserCodeError,
}))
import { POST } from '@/app/api/workspaces/[id]/pdf/preview/route'
@@ -187,4 +194,31 @@ describe('PDF preview API route', () => {
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
})
it('returns 422 when user code throws inside the sandbox', async () => {
mockRunSandboxTask.mockRejectedValue(
new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError')
)
const request = new NextRequest(
'http://localhost:3000/api/workspaces/workspace-1/pdf/preview',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: 'const x = ' }),
}
)
const response = await POST(request, {
params: Promise.resolve({ id: 'workspace-1' }),
})
expect(response.status).toBe(422)
await expect(response.json()).resolves.toEqual({
error: 'Invalid or unexpected token',
errorName: 'SyntaxError',
})
})
})

View File

@@ -6,9 +6,15 @@ import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
const { mockRunSandboxTask } = vi.hoisted(() => ({
mockRunSandboxTask: vi.fn(),
}))
const { mockRunSandboxTask, SandboxUserCodeError } = vi.hoisted(() => {
class SandboxUserCodeError extends Error {
constructor(message: string, name: string) {
super(message)
this.name = name
}
}
return { mockRunSandboxTask: vi.fn(), SandboxUserCodeError }
})
const mockVerifyWorkspaceMembership = workflowsApiUtilsMockFns.mockVerifyWorkspaceMembership
@@ -16,6 +22,7 @@ vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
vi.mock('@/lib/execution/sandbox/run-task', () => ({
runSandboxTask: mockRunSandboxTask,
SandboxUserCodeError,
}))
import { POST } from '@/app/api/workspaces/[id]/pptx/preview/route'
@@ -189,4 +196,31 @@ describe('PPTX preview API route', () => {
expect(response.status).toBe(500)
await expect(response.json()).resolves.toEqual({ error: 'boom: sandbox failed' })
})
it('returns 422 when user code throws inside the sandbox', async () => {
mockRunSandboxTask.mockRejectedValue(
new SandboxUserCodeError('Invalid or unexpected token', 'SyntaxError')
)
const request = new NextRequest(
'http://localhost:3000/api/workspaces/workspace-1/pptx/preview',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code: 'const x = ' }),
}
)
const response = await POST(request, {
params: Promise.resolve({ id: 'workspace-1' }),
})
expect(response.status).toBe(422)
await expect(response.json()).resolves.toEqual({
error: 'Invalid or unexpected token',
errorName: 'SyntaxError',
})
})
})

View File

@@ -717,10 +717,13 @@ export class LoopOrchestrator {
})
if (vmResult.error) {
logger.error('Failed to evaluate loop condition', {
const isSystemError = vmResult.error.isSystemError === true
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
logFn('Failed to evaluate loop condition', {
condition,
evaluatedCondition,
error: vmResult.error,
isSystemError,
})
return false
}

View File

@@ -99,6 +99,15 @@ export interface IsolatedVMError {
line?: number
column?: number
lineContent?: string
/**
* True when the failure is host-infrastructure caused (worker crash, IPC
* failure, pool saturation, task misconfig) rather than anything the user's
* code did. Callers use this to keep genuine server failures as 5xx while
* translating user-caused failures (code errors, timeouts, aborts, per-owner
* rate limits) into 4xx. Defaults to undefined/false — new error sites
* default to user-caused unless explicitly marked.
*/
isSystemError?: boolean
}
const POOL_SIZE = Number.parseInt(env.IVM_POOL_SIZE) || 4
@@ -838,7 +847,11 @@ function cleanupWorker(workerId: number) {
pending.resolve({
result: null,
stdout: '',
error: { message: 'Code execution failed unexpectedly. Please try again.', name: 'Error' },
error: {
message: 'Code execution failed unexpectedly. Please try again.',
name: 'Error',
isSystemError: true,
},
})
workerInfo.pendingExecutions.delete(id)
}
@@ -1125,7 +1138,11 @@ function dispatchToWorker(
resolve({
result: null,
stdout: '',
error: { message: 'Code execution failed to start. Please try again.', name: 'Error' },
error: {
message: 'Code execution failed to start. Please try again.',
name: 'Error',
isSystemError: true,
},
})
if (workerInfo.retiring && workerInfo.activeExecutions === 0) {
cleanupWorker(workerInfo.id)
@@ -1159,6 +1176,7 @@ function enqueueExecution(
error: {
message: 'Code execution is at capacity. Please try again in a moment.',
name: 'Error',
isSystemError: true,
},
})
return
@@ -1198,6 +1216,7 @@ function enqueueExecution(
error: {
message: 'Code execution timed out waiting for an available worker. Please try again.',
name: 'Error',
isSystemError: true,
},
})
}, QUEUE_TIMEOUT_MS)
@@ -1294,6 +1313,7 @@ export async function executeInIsolatedVM(
error: {
message: `Task "${req.task.id}" requires broker "${brokerName}" but none was provided`,
name: 'Error',
isSystemError: true,
},
}
}

View File

@@ -20,6 +20,24 @@ export interface RunSandboxTaskOptions {
signal?: AbortSignal
}
/**
* Thrown when the sandbox failure is attributable to the caller — user code
* errors (SyntaxError, ReferenceError, user-thrown exceptions), timeouts from
* user code, client aborts, or per-owner rate limits. Callers should translate
* this into a 4xx response so genuine 5xx remains a signal of server health.
*
* System-origin failures (worker crash, IPC failure, pool saturation, task
* misconfig) are tagged with `isSystemError` at the isolated-vm layer and
* surface as a plain `Error` → 500.
*/
export class SandboxUserCodeError extends Error {
constructor(message: string, name: string, stack?: string) {
super(message)
this.name = name || 'SandboxUserCodeError'
if (stack) this.stack = stack
}
}
/**
* Executes a sandbox task inside the shared isolated-vm pool and returns the
* binary result buffer. Throws with a human-readable message if the task fails
@@ -70,7 +88,9 @@ export async function runSandboxTask<TInput extends SandboxTaskInput>(
const queueMs = result.timings ? Math.max(0, elapsedMs - result.timings.total) : undefined
if (result.error) {
logger.warn('Sandbox task failed', {
const isSystemError = result.error.isSystemError === true
const logFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
logFn('Sandbox task failed', {
taskId,
requestId,
workspaceId: input.workspaceId,
@@ -79,11 +99,19 @@ export async function runSandboxTask<TInput extends SandboxTaskInput>(
timings: result.timings,
error: result.error.message,
errorName: result.error.name,
isSystemError,
})
const err = new Error(result.error.message)
err.name = result.error.name || 'SandboxTaskError'
if (result.error.stack) err.stack = result.error.stack
throw err
if (isSystemError) {
const err = new Error(result.error.message)
err.name = result.error.name || 'SandboxSystemError'
if (result.error.stack) err.stack = result.error.stack
throw err
}
throw new SandboxUserCodeError(
result.error.message,
result.error.name || 'SandboxTaskError',
result.error.stack
)
}
if (typeof result.bytesBase64 !== 'string' || result.bytesBase64.length === 0) {