diff --git a/apps/sim/executor/execution/block-executor.retry.test.ts b/apps/sim/executor/execution/block-executor.retry.test.ts index e22c93104eb..617a70b2f20 100644 --- a/apps/sim/executor/execution/block-executor.retry.test.ts +++ b/apps/sim/executor/execution/block-executor.retry.test.ts @@ -10,10 +10,17 @@ import { BlockType, EDGE } from '@/executor/constants' import type { DAGNode } from '@/executor/dag/builder' import { BlockExecutor } from '@/executor/execution/block-executor' import { ExecutionState } from '@/executor/execution/state' +import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { attachTrustedExecutionCost } from '@/executor/utils/errors' import { VariableResolver } from '@/executor/variables/resolver' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' +import { executeTool } from '@/tools' +import { getTool } from '@/tools/utils' + +vi.mock('@/blocks/index', () => ({ getBlock: vi.fn() })) +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) +vi.mock('@/tools/utils', () => ({ getTool: vi.fn() })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ validateBlockType: vi.fn(), @@ -139,6 +146,40 @@ describe('BlockExecutor retry', () => { expect(ctx.blockLogs[0]?.tries).toBe(2) }) + it.each([ + { retryable: false, attempts: 1 }, + { retryable: true, attempts: 3 }, + { retryable: undefined, attempts: 3 }, + ])( + 'executes a generic tool $attempts times when retryable is $retryable', + async ({ retryable, attempts }) => { + const block = createBlock(enabled) + block.config.tool = 'synthetic_write' + vi.mocked(getTool).mockReturnValue({ + id: 'synthetic_write', + name: 'Synthetic Write', + description: 'A synthetic write for retry testing', + version: '1.0', + params: {}, + request: { url: 'https://example.com/write', method: 'POST' }, + }) + vi.mocked(executeTool).mockResolvedValue({ + success: false, + error: 'Write outcome is unknown', + output: { detail: 'Response was lost' }, + ...(retryable !== undefined ? { retryable } : {}), + }) + const state = new ExecutionState() + const ctx = createContext(state) + const executor = buildExecutor(block, new GenericBlockHandler(), state) + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow( + 'Write outcome is unknown' + ) + expect(executeTool).toHaveBeenCalledTimes(attempts) + } + ) + it('adds the trusted cost of failed Function tries to the successful result', async () => { const block = createBlock(enabled) const firstFailure = new Error('first attempt failed') diff --git a/apps/sim/executor/handlers/generic/generic-handler.test.ts b/apps/sim/executor/handlers/generic/generic-handler.test.ts index 2c3dd174f78..722e58113c0 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.test.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.test.ts @@ -1,6 +1,7 @@ import '@sim/testing/mocks/executor' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { NonRetryableExecutionError } from '@/lib/execution/non-retryable-error' import { HarmonicBlock } from '@/blocks/blocks/harmonic' import { KnowledgeBlock } from '@/blocks/blocks/knowledge' import { getBlock } from '@/blocks/index' @@ -593,30 +594,42 @@ describe('GenericBlockHandler', () => { expect(mockExecuteTool).not.toHaveBeenCalled() }) - it('should handle tool execution errors correctly', async () => { - const inputs = { param1: 'value' } - const errorResult = { - success: false, - error: 'Custom tool failed', - output: { detail: 'error detail' }, - } - mockExecuteTool.mockResolvedValue(errorResult) + it.each([undefined, true, false])( + 'preserves failure details when retryable is %s', + async (retryable) => { + const inputs = { param1: 'value' } + const errorResult = { + success: false, + error: 'Custom tool failed', + output: { detail: 'error detail' }, + statusCode: 503, + ...(retryable !== undefined ? { retryable } : {}), + } + mockExecuteTool.mockResolvedValue(errorResult) - await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( - 'Custom tool failed' - ) + let thrown: unknown + try { + await handler.execute(mockContext, mockBlock, inputs) + } catch (error) { + thrown = error + } - // Re-execute to check error properties after catching - try { - await handler.execute(mockContext, mockBlock, inputs) - } catch (e: any) { - expect(e.toolId).toBe('some_custom_tool') - expect(e.blockName).toBe('Test Generic Block') - expect(e.output).toEqual({ detail: 'error detail' }) + expect(thrown).toBeInstanceOf(Error) + expect(thrown instanceof NonRetryableExecutionError).toBe(retryable === false) + expect(thrown).toMatchObject({ + message: 'Custom tool failed', + toolId: 'some_custom_tool', + toolName: 'Some Custom Tool', + blockId: 'generic-block-1', + blockName: 'Test Generic Block', + output: { detail: 'error detail' }, + statusCode: 503, + timestamp: expect.any(String), + ...(retryable === false ? { retryable: false } : {}), + }) + expect(mockExecuteTool).toHaveBeenCalledTimes(1) } - - expect(mockExecuteTool).toHaveBeenCalledTimes(2) // Called twice now - }) + ) it.concurrent('should handle tool execution errors with no specific message', async () => { const inputs = { param1: 'value' } diff --git a/apps/sim/executor/handlers/generic/generic-handler.ts b/apps/sim/executor/handlers/generic/generic-handler.ts index dec5e6787e5..ad482db203f 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.ts @@ -2,6 +2,7 @@ import { isDeepStrictEqual } from 'node:util' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isPlainRecord } from '@sim/utils/object' +import { NonRetryableExecutionError } from '@/lib/execution/non-retryable-error' import { getBlock } from '@/blocks/index' import { isMcpTool } from '@/executor/constants' import type { BlockHandler, BlockNodeMetadata, ExecutionContext } from '@/executor/types' @@ -344,7 +345,10 @@ export class GenericBlockHandler implements BlockHandler { ? errorDetails.join(' - ') : `Block execution of ${tool?.name || block.config.tool} failed with no error message` - const error = new Error(errorMessage) + const error = + result.retryable === false + ? new NonRetryableExecutionError(errorMessage) + : new Error(errorMessage) Object.assign(error, { toolId: block.config.tool,