mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user