Skip to content

Commit 2479705

Browse files
icecrasher321claude
andcommitted
fix(tools): give internal routes transport headroom past their execution budget
A `timeout` param bounds the work an internal route was asked to do — the code a sandbox runs, the upstream call a proxy route makes. The fetch around it also pays authentication, body parsing, workspace authorization, worker acquisition, and response serialization, none of which that budget was sized for. Arming the client with the bare number made the caller give up at the same instant the route's own deadline fired, so the route could never win the race and report which part actually ran long — the caller saw an unattributable `Request timed out` instead of `Function execution timed out after 5000ms`. Add 30s of headroom, sized above the isolated-vm worker's own 10s startup budget so a cold worker spawn stays inside the transport deadline rather than aborting it. An execution abort signal, when present, still bounds the call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7167ed6 commit 2479705

2 files changed

Lines changed: 70 additions & 3 deletions

File tree

apps/sim/tools/index.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2323,6 +2323,53 @@ describe('executeTool Function', () => {
23232323
tools.function_execute = originalFunctionTool
23242324
})
23252325

2326+
it('gives an internal route transport headroom past its requested execution budget', async () => {
2327+
const originalFunctionTool = { ...tools.function_execute }
2328+
tools.function_execute = {
2329+
...tools.function_execute,
2330+
transformResponse: vi.fn().mockResolvedValue({ success: true, output: {} }),
2331+
}
2332+
2333+
let observedSignal: AbortSignal | undefined
2334+
global.fetch = Object.assign(
2335+
vi.fn().mockImplementation(
2336+
async (_url: string, init: RequestInit) =>
2337+
new Promise((_resolve, reject) => {
2338+
observedSignal = init.signal as AbortSignal
2339+
observedSignal.addEventListener('abort', () => {
2340+
const err = new Error('aborted')
2341+
err.name = 'AbortError'
2342+
reject(err)
2343+
})
2344+
})
2345+
),
2346+
{ preconnect: vi.fn() }
2347+
) as typeof fetch
2348+
2349+
vi.useFakeTimers()
2350+
try {
2351+
const resultPromise = executeTool(
2352+
'function_execute',
2353+
{ code: 'return 1', timeout: 5000 },
2354+
{ skipPostProcess: true }
2355+
)
2356+
2357+
// The route owns the 5s execution budget and needs to outlive it to report
2358+
// its own timeout, so the transport must still be waiting at that instant.
2359+
await vi.advanceTimersByTimeAsync(5000)
2360+
expect(observedSignal?.aborted).toBe(false)
2361+
2362+
await vi.advanceTimersByTimeAsync(30_000)
2363+
const result = await resultPromise
2364+
2365+
expect(result.success).toBe(false)
2366+
expect(result.error).toMatch(/timed out after 35000ms/)
2367+
} finally {
2368+
vi.useRealTimers()
2369+
tools.function_execute = originalFunctionTool
2370+
}
2371+
})
2372+
23262373
it('should add timing information to results', async () => {
23272374
const result = await executeTool(
23282375
'http_request',

apps/sim/tools/index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,24 @@ import { normalizeToolId } from '@/tools/normalize'
953953
const MAX_REQUEST_BODY_SIZE_BYTES = 10 * 1024 * 1024 // 10MB
954954
const MAX_TOOL_RESPONSE_BODY_BYTES = 10 * 1024 * 1024 // 10MB
955955

956+
/**
957+
* Headroom added to an internal route's requested timeout before it becomes the
958+
* transport deadline.
959+
*
960+
* A `timeout` param bounds the work the route was asked to do — the code a
961+
* sandbox runs, the upstream call a proxy route makes. The fetch around it also
962+
* pays authentication, body parsing, workspace authorization, worker
963+
* acquisition, and response serialization, none of which that budget was sized
964+
* for. Arming the client with the bare number makes the caller give up at the
965+
* same instant the route's own deadline fires, so the route can never win the
966+
* race and report which part actually ran long; the caller sees an
967+
* unattributable `Request timed out` instead.
968+
*
969+
* Sized above the isolated-vm worker's own 10s startup budget so a cold worker
970+
* spawn stays inside the transport deadline rather than aborting it.
971+
*/
972+
const INTERNAL_ROUTE_TRANSPORT_OVERHEAD_MS = 30_000
973+
956974
/**
957975
* User-friendly error message for body size limit exceeded
958976
*/
@@ -2540,9 +2558,11 @@ async function executeToolRequest(
25402558
let didTimeout = false
25412559
// With a caller/execution abort signal present, the plan-based timeout bounds the call and
25422560
// this only acts as a ceiling; without one, keep the tighter default as the hang safety net.
2543-
const timeout =
2544-
requestParams.timeout ||
2545-
(signal ? getMaxExecutionTimeout() : DEFAULT_EXECUTION_TIMEOUT_MS)
2561+
const timeout = requestParams.timeout
2562+
? requestParams.timeout + INTERNAL_ROUTE_TRANSPORT_OVERHEAD_MS
2563+
: signal
2564+
? getMaxExecutionTimeout()
2565+
: DEFAULT_EXECUTION_TIMEOUT_MS
25462566
const timeoutId = setTimeout(() => {
25472567
didTimeout = true
25482568
controller.abort(new DOMException('timeout', 'AbortError'))

0 commit comments

Comments
 (0)