From 693340fa4f270abb9005e40f9a6e52826e6dc38a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 15:38:57 -0700 Subject: [PATCH 1/2] feat(executor): opt-in per-block retry for transient failures --- .../execution/block-executor.retry.test.ts | 191 ++++++++++++++++++ apps/sim/executor/execution/block-executor.ts | 68 ++++++- .../executor/execution/block-retry.test.ts | 149 ++++++++++++++ apps/sim/executor/execution/block-retry.ts | 130 ++++++++++++ apps/sim/executor/types.ts | 2 + apps/sim/serializer/index.ts | 1 + apps/sim/serializer/types.ts | 3 + packages/workflow-types/src/workflow.ts | 24 +++ 8 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 apps/sim/executor/execution/block-executor.retry.test.ts create mode 100644 apps/sim/executor/execution/block-retry.test.ts create mode 100644 apps/sim/executor/execution/block-retry.ts diff --git a/apps/sim/executor/execution/block-executor.retry.test.ts b/apps/sim/executor/execution/block-executor.retry.test.ts new file mode 100644 index 00000000000..bdc6c92068d --- /dev/null +++ b/apps/sim/executor/execution/block-executor.retry.test.ts @@ -0,0 +1,191 @@ +/** + * @vitest-environment node + * + * Retry wraps only the handler invocation, so a replay cannot duplicate output the + * client has already seen and cannot re-run the deterministic post-processing. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +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 type { BlockHandler, ExecutionContext } from '@/executor/types' +import { VariableResolver } from '@/executor/variables/resolver' +import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateBlockType: vi.fn(), +})) + +function createBlock(retry?: SerializedBlock['retry']): SerializedBlock { + return { + id: 'slack-block-1', + metadata: { id: BlockType.FUNCTION, name: 'Post' }, + position: { x: 0, y: 0 }, + config: { tool: BlockType.FUNCTION, params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + ...(retry ? { retry } : {}), + } +} + +function createContext(state: ExecutionState, abortSignal?: AbortSignal): ExecutionContext { + return { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + blockStates: state.getBlockStates(), + blockLogs: [], + metadata: { requestId: 'request-1', duration: 0 }, + environmentVariables: {}, + workflowVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopExecutions: new Map(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + completedLoops: new Set(), + abortSignal, + } as ExecutionContext +} + +function createNode(block: SerializedBlock, withErrorPort = false): DAGNode { + return { + id: block.id, + block, + incomingEdges: new Set(), + outgoingEdges: withErrorPort + ? new Map([['edge-1', { sourceHandle: EDGE.ERROR, target: 'downstream' }]]) + : new Map(), + metadata: {}, + } as unknown as DAGNode +} + +function buildExecutor(block: SerializedBlock, handler: BlockHandler, state: ExecutionState) { + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + return new BlockExecutor( + [handler], + new VariableResolver(workflow, {}, state), + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) +} + +/** Bun's dropped-connection failure, the case that motivated this. */ +function socketClosed() { + return new Error('The socket connection was closed unexpectedly.') +} + +describe('BlockExecutor retry', () => { + beforeEach(() => vi.clearAllMocks()) + + it('does not retry when the builder has not opted in', async () => { + const block = createBlock() + const execute = vi.fn().mockRejectedValue(socketClosed()) + const state = new ExecutionState() + const executor = buildExecutor(block, { canHandle: () => true, execute }, state) + + await expect(executor.execute(createContext(state), createNode(block), block)).rejects.toThrow() + expect(execute).toHaveBeenCalledTimes(1) + }) + + it('replays a transient failure and succeeds on a later attempt', async () => { + const block = createBlock({ maxAttempts: 3, waitMs: 0 }) + const execute = vi + .fn() + .mockRejectedValueOnce(socketClosed()) + .mockResolvedValueOnce({ ok: true }) + const state = new ExecutionState() + const ctx = createContext(state) + const executor = buildExecutor(block, { canHandle: () => true, execute }, state) + + const output = await executor.execute(ctx, createNode(block), block) + + expect(execute).toHaveBeenCalledTimes(2) + expect(output).toMatchObject({ ok: true }) + expect(ctx.blockLogs[0]?.success).toBe(true) + expect(ctx.blockLogs[0]?.attempts).toBe(2) + }) + + it('stops at the configured attempt ceiling', async () => { + const block = createBlock({ maxAttempts: 3, waitMs: 0 }) + const execute = vi.fn().mockRejectedValue(socketClosed()) + const state = new ExecutionState() + const ctx = createContext(state) + const executor = buildExecutor(block, { canHandle: () => true, execute }, state) + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow() + expect(execute).toHaveBeenCalledTimes(3) + expect(ctx.blockLogs[0]?.attempts).toBe(3) + }) + + /** A permanent failure must not spend the budget re-confirming itself. */ + it('does not replay a non-transient failure', async () => { + const block = createBlock({ maxAttempts: 5, waitMs: 0 }) + const execute = vi.fn().mockRejectedValue(new Error('Invalid channel id')) + const state = new ExecutionState() + const executor = buildExecutor(block, { canHandle: () => true, execute }, state) + + await expect(executor.execute(createContext(state), createNode(block), block)).rejects.toThrow( + 'Invalid channel id' + ) + expect(execute).toHaveBeenCalledTimes(1) + }) + + /** A run cancelled mid-flight must not start another attempt. */ + it('stops replaying once the run is cancelled', async () => { + const block = createBlock({ maxAttempts: 5, waitMs: 0 }) + const controller = new AbortController() + const execute = vi.fn().mockImplementation(async () => { + controller.abort() + throw socketClosed() + }) + const state = new ExecutionState() + const executor = buildExecutor(block, { canHandle: () => true, execute }, state) + + await expect( + executor.execute(createContext(state, controller.signal), createNode(block), block) + ).rejects.toThrow() + expect(execute).toHaveBeenCalledTimes(1) + }) + + /** + * Retry and the error port compose: the port only sees the failure once the + * attempt budget is spent, and the block still returns an error output rather + * than throwing. + */ + it('hands an exhausted retry to the error port instead of throwing', async () => { + const block = createBlock({ maxAttempts: 2, waitMs: 0 }) + const execute = vi.fn().mockRejectedValue(socketClosed()) + const state = new ExecutionState() + const ctx = createContext(state) + const executor = buildExecutor(block, { canHandle: () => true, execute }, state) + + const output = await executor.execute(ctx, createNode(block, true), block) + + expect(execute).toHaveBeenCalledTimes(2) + expect(output.error).toContain('socket connection was closed') + expect(ctx.blockLogs[0]?.errorHandled).toBe(true) + }) +}) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index b4f90fcc1c7..d89b5910ab6 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -1,5 +1,7 @@ import { createLogger, type Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter } from '@sim/utils/retry' import { isTimeoutAbortReason } from '@/lib/core/execution-limits/types' import { redactApiKeys } from '@/lib/core/security/redaction' import { normalizeStringArray } from '@/lib/core/utils/arrays' @@ -25,6 +27,7 @@ import { } from '@/executor/constants' import type { DAGNode } from '@/executor/dag/builder' import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import { isRetryableBlockError, resolveBlockRetryPolicy } from '@/executor/execution/block-retry' import type { BlockStateWriter, ContextExtensions, @@ -181,9 +184,20 @@ export class BlockExecutor { let streamingPartialOutput: Record | undefined try { - const output = handler.executeWithNode - ? await handler.executeWithNode(ctx, block, resolvedInputs, nodeMetadata) - : await handler.execute(ctx, block, resolvedInputs) + /** + * Only the handler call is retried, never the post-processing below it. + * + * For a streaming block the handler returns before any token is drained, so + * a replay here cannot duplicate output the client has already seen — a + * failure during the drain falls through to the catch untouched. The + * redaction and compaction steps are deterministic and would fail again + * identically, so replaying them would only burn the attempt budget. + */ + const output = await this.runHandlerWithRetry(ctx, node, block, blockLog, () => + handler.executeWithNode + ? handler.executeWithNode(ctx, block, resolvedInputs, nodeMetadata) + : handler.execute(ctx, block, resolvedInputs) + ) const isStreamingExecution = output && typeof output === 'object' && 'stream' in output && 'execution' in output @@ -370,6 +384,54 @@ export class BlockExecutor { return this.blockHandlers.find((h) => h.canHandle(block)) } + /** + * Runs the block handler, replaying it while the failure looks transient. + * + * Returns the handler's value untouched on success, and rethrows the final + * attempt's error on exhaustion so the caller's catch — and with it the error + * port — behaves exactly as it does for a block that never retried. + */ + private async runHandlerWithRetry( + ctx: ExecutionContext, + node: DAGNode, + block: SerializedBlock, + blockLog: BlockLog | undefined, + invoke: () => Promise + ): Promise { + const policy = resolveBlockRetryPolicy(block) + if (!policy) return await invoke() + + for (let attempt = 1; ; attempt++) { + try { + const output = await invoke() + if (blockLog && attempt > 1) blockLog.attempts = attempt + return output + } catch (error) { + const isFinalAttempt = attempt >= policy.maxAttempts + /** + * A run cancelled mid-backoff must not start another attempt, even when + * the error itself looks retryable. + */ + const cancelled = ctx.abortSignal?.aborted === true + if (isFinalAttempt || cancelled || !isRetryableBlockError(error)) { + if (blockLog && attempt > 1) blockLog.attempts = attempt + throw error + } + + const delayMs = backoffWithJitter(attempt, null, { baseMs: policy.waitMs }) + this.execLogger.warn('Block failed on a transient error; retrying', { + blockId: node.id, + blockType: block.metadata?.id, + attempt, + maxAttempts: policy.maxAttempts, + delayMs, + error: normalizeError(error), + }) + await sleep(delayMs) + } + } + } + private async handleBlockError( error: unknown, ctx: ExecutionContext, diff --git a/apps/sim/executor/execution/block-retry.test.ts b/apps/sim/executor/execution/block-retry.test.ts new file mode 100644 index 00000000000..1b6be675321 --- /dev/null +++ b/apps/sim/executor/execution/block-retry.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + * + * Retry is opt-in because the platform cannot know whether a block's operation is + * idempotent. These pin the eligibility rules and the transient/permanent split. + */ +import { describe, expect, it } from 'vitest' +import { BlockType } from '@/executor/constants' +import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import { isRetryableBlockError, resolveBlockRetryPolicy } from '@/executor/execution/block-retry' +import type { SerializedBlock } from '@/serializer/types' + +function block(overrides: Partial = {}): SerializedBlock { + return { + id: 'b1', + position: { x: 0, y: 0 }, + config: { tool: 'slack_send', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + metadata: { id: 'slack' }, + ...overrides, + } as SerializedBlock +} + +function err(props: Record): Error { + return Object.assign(new Error((props.message as string) ?? 'boom'), props) +} + +describe('resolveBlockRetryPolicy', () => { + it('returns null when the builder has not opted in', () => { + expect(resolveBlockRetryPolicy(block())).toBeNull() + }) + + it('returns a policy when configured', () => { + expect(resolveBlockRetryPolicy(block({ retry: { maxAttempts: 3, waitMs: 500 } }))).toEqual({ + maxAttempts: 3, + waitMs: 500, + }) + }) + + it('defaults the wait when only attempts are given', () => { + expect(resolveBlockRetryPolicy(block({ retry: { maxAttempts: 2 } }))?.waitMs).toBe(1000) + }) + + it('clamps attempts and wait to their bounds', () => { + expect( + resolveBlockRetryPolicy(block({ retry: { maxAttempts: 99, waitMs: 10_000_000 } })) + ).toEqual({ maxAttempts: 5, waitMs: 30_000 }) + }) + + /** + * A pause is signalled by throwing. Replaying it would re-arm the pause rather + * than resume it, so the block type is refused even when retry is configured. + */ + it('refuses a human-in-the-loop block even when configured', () => { + expect( + resolveBlockRetryPolicy( + block({ metadata: { id: BlockType.HUMAN_IN_THE_LOOP }, retry: { maxAttempts: 3 } }) + ) + ).toBeNull() + }) + + it('refuses loop and parallel sentinels', () => { + for (const id of [BlockType.SENTINEL_START, BlockType.SENTINEL_END]) { + expect( + resolveBlockRetryPolicy(block({ metadata: { id }, retry: { maxAttempts: 3 } })) + ).toBeNull() + } + }) +}) + +describe('isRetryableBlockError', () => { + it('retries a transport deadline', () => { + expect(isRetryableBlockError(new DOMException('timed out', 'TimeoutError'))).toBe(true) + }) + + it('retries socket-level codes', () => { + expect(isRetryableBlockError(err({ code: 'ECONNRESET' }))).toBe(true) + expect(isRetryableBlockError(err({ code: 'EAI_AGAIN' }))).toBe(true) + }) + + it("retries Bun's bare dropped-connection message", () => { + expect( + isRetryableBlockError( + err({ message: 'The socket connection was closed unexpectedly. For more information...' }) + ) + ).toBe(true) + }) + + it('retries transient HTTP statuses only', () => { + for (const status of [408, 429, 502, 503, 504]) { + expect(isRetryableBlockError(err({ status }))).toBe(true) + } + for (const status of [400, 401, 403, 404, 422, 500]) { + expect(isRetryableBlockError(err({ status }))).toBe(false) + } + }) + + /** + * The classification has to survive rewrapping: a provider replaces `name` when + * it wraps a transport failure, so only `cause` still carries it. + */ + it('finds a transport failure through the cause chain', () => { + const wrapped = Object.assign( + new Error('Provider request failed', { + cause: new DOMException('timed out', 'TimeoutError'), + }), + { name: 'ProviderError' } + ) + expect(isRetryableBlockError(wrapped)).toBe(true) + }) + + /** + * The load-bearing abort case: a block timeout aborts the in-flight fetch, so the + * abort carries a retryable transport failure underneath it. Without the guard the + * walk would reach that cause and replay a request whose budget is already spent. + * A bare AbortError proves nothing here — it is unretryable by default. + */ + it('never retries an abort that wraps a retryable transport failure', () => { + const abort = Object.assign(new DOMException('aborted', 'AbortError'), { + cause: new DOMException('timed out', 'TimeoutError'), + }) + expect(isRetryableBlockError(abort)).toBe(false) + + const abortWithStatus = Object.assign(new DOMException('aborted', 'AbortError'), { + status: 503, + }) + expect(isRetryableBlockError(abortWithStatus)).toBe(false) + }) + + it('never retries a failed child workflow', () => { + const childError = Object.create(ChildWorkflowError.prototype) + Object.assign(childError, { message: 'child failed', childTraceSpans: [] }) + expect(isRetryableBlockError(childError)).toBe(false) + }) + + it('does not retry an ordinary application error', () => { + expect(isRetryableBlockError(new Error('Invalid channel id'))).toBe(false) + }) + + /** A self-referential cause must not hang the walk. */ + it('terminates on a cyclic cause chain', () => { + const a = new Error('a') + const b = new Error('b', { cause: a }) + Object.defineProperty(a, 'cause', { value: b }) + expect(isRetryableBlockError(a)).toBe(false) + }) +}) diff --git a/apps/sim/executor/execution/block-retry.ts b/apps/sim/executor/execution/block-retry.ts new file mode 100644 index 00000000000..06ec744eb02 --- /dev/null +++ b/apps/sim/executor/execution/block-retry.ts @@ -0,0 +1,130 @@ +import { + BLOCK_RETRY_DEFAULT_WAIT_MS, + BLOCK_RETRY_MAX_ATTEMPTS, + BLOCK_RETRY_MAX_WAIT_MS, + BLOCK_RETRY_MIN_ATTEMPTS, + BLOCK_RETRY_MIN_WAIT_MS, +} from '@sim/workflow-types/workflow' +import { BlockType } from '@/executor/constants' +import { ChildWorkflowError } from '@/executor/errors/child-workflow-error' +import type { SerializedBlock } from '@/serializer/types' + +/** A validated policy; only produced for blocks that are eligible to retry. */ +export interface ResolvedBlockRetryPolicy { + maxAttempts: number + waitMs: number +} + +/** + * Block types whose failure is not a transport event and must never be replayed. + * + * A human-in-the-loop block signals a pause by throwing; retrying would re-arm the + * pause instead of resuming it. Loop and parallel sentinels carry iteration + * bookkeeping, so replaying one would re-enter the surrounding construct. + */ +const NON_RETRYABLE_BLOCK_TYPES = new Set([ + BlockType.HUMAN_IN_THE_LOOP, + BlockType.SENTINEL_START, + BlockType.SENTINEL_END, +]) + +/** + * HTTP statuses that describe a transient server-side condition. A 4xx other than + * 408/429 means the request itself was rejected and will be rejected identically + * on replay, so it is deliberately absent. + */ +const RETRYABLE_HTTP_STATUSES = new Set([408, 429, 502, 503, 504]) + +/** Node/Bun socket-level failure codes, none of which reach an application handler. */ +const RETRYABLE_ERROR_CODES = new Set([ + 'ECONNRESET', + 'ECONNREFUSED', + 'ECONNABORTED', + 'EPIPE', + 'ETIMEDOUT', + 'ENOTFOUND', + 'EAI_AGAIN', + 'EHOSTUNREACH', + 'ENETUNREACH', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_SOCKET', +]) + +/** + * Bun reports a dropped connection with a bare message and no `code`, so this + * string is the only available signal. Kept as a single explicit needle rather + * than a general message scan, which would sweep in application errors that merely + * mention a socket. + */ +const BUN_SOCKET_CLOSED_MESSAGE = 'socket connection was closed unexpectedly' + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)) + +/** + * Returns the retry policy for a block, or `null` when it must not retry. + * + * Ineligibility is decided here rather than at the call site so that a block type + * added to {@link NON_RETRYABLE_BLOCK_TYPES} is excluded everywhere at once. + */ +export function resolveBlockRetryPolicy(block: SerializedBlock): ResolvedBlockRetryPolicy | null { + const configured = block.retry + if (!configured) return null + + const blockType = block.metadata?.id + if (blockType && NON_RETRYABLE_BLOCK_TYPES.has(blockType)) return null + + const maxAttempts = clamp( + Math.floor(configured.maxAttempts), + BLOCK_RETRY_MIN_ATTEMPTS, + BLOCK_RETRY_MAX_ATTEMPTS + ) + if (!Number.isFinite(maxAttempts) || maxAttempts < BLOCK_RETRY_MIN_ATTEMPTS) return null + + const requestedWait = configured.waitMs ?? BLOCK_RETRY_DEFAULT_WAIT_MS + const waitMs = Number.isFinite(requestedWait) + ? clamp(requestedWait, BLOCK_RETRY_MIN_WAIT_MS, BLOCK_RETRY_MAX_WAIT_MS) + : BLOCK_RETRY_DEFAULT_WAIT_MS + + return { maxAttempts, waitMs } +} + +/** + * Whether a failure is transient enough to be worth replaying. + * + * The cause chain is walked because providers rewrap transport failures — a + * `ProviderError` overwrites `name`, so the original classification survives only + * on `cause`. Bounded so a self-referential cause cannot loop. + */ +export function isRetryableBlockError(error: unknown): boolean { + for (let current: unknown = error, depth = 0; current && depth < 5; depth++) { + if (typeof current !== 'object') break + + /** + * A child workflow that failed already ran its own blocks, each with its own + * retry policy. Replaying the parent would re-run all of them. + */ + if (ChildWorkflowError.isChildWorkflowError(current)) return false + + const candidate = current as { name?: string; code?: string; status?: number; message?: string } + + /** + * An abort is a deliberate stop — a user pressing Stop, or a block timeout + * that has already spent its budget. Replaying either works against the + * caller's intent, so aborts terminate the loop rather than extend it. + */ + if (candidate.name === 'AbortError') return false + + if (candidate.name === 'TimeoutError') return true + if (candidate.code && RETRYABLE_ERROR_CODES.has(candidate.code)) return true + if (typeof candidate.status === 'number' && RETRYABLE_HTTP_STATUSES.has(candidate.status)) { + return true + } + if (candidate.message?.includes(BUN_SOCKET_CLOSED_MESSAGE)) return true + + current = (current as { cause?: unknown }).cause + } + + return false +} diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index ef5afe1ab6b..e72b3aa2666 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -268,6 +268,8 @@ export interface BlockLog { error?: string /** Whether this error was handled by an error handler path (error port) */ errorHandled?: boolean + /** Total handler attempts, present only when the block retried at least once. */ + attempts?: number loopId?: string parallelId?: string iterationIndex?: number diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index ef27aa74c40..d2586b5b0d1 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -327,6 +327,7 @@ export class Serializer { color: blockConfig.bgColor, }, enabled: block.enabled, + ...(block.retry ? { retry: block.retry } : {}), } const privateInputIds = new Set() diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts index 2fb123ecee9..c35c3a4a568 100644 --- a/apps/sim/serializer/types.ts +++ b/apps/sim/serializer/types.ts @@ -1,3 +1,4 @@ +import type { BlockRetryConfig } from '@sim/workflow-types/workflow' import type { OutputFieldDefinition, ParamType } from '@/blocks/types' import type { Position } from '@/stores/workflows/workflow/types' @@ -42,6 +43,8 @@ export interface SerializedBlock { canonicalModes?: Record /** Server-only lifecycle input ids omitted from execution-log projections. */ privateInputIds?: string[] + /** Opt-in retry for transient failures; absent means the block never retries. */ + retry?: BlockRetryConfig } export interface SerializedLoop { diff --git a/packages/workflow-types/src/workflow.ts b/packages/workflow-types/src/workflow.ts index c4aa764aecf..167ae151720 100644 --- a/packages/workflow-types/src/workflow.ts +++ b/packages/workflow-types/src/workflow.ts @@ -63,6 +63,29 @@ export interface BlockLayoutState { measuredHeight?: number } +/** Inclusive bounds for {@link BlockRetryConfig.maxAttempts}. */ +export const BLOCK_RETRY_MIN_ATTEMPTS = 2 +export const BLOCK_RETRY_MAX_ATTEMPTS = 5 +/** Inclusive bounds for {@link BlockRetryConfig.waitMs}, the backoff base delay. */ +export const BLOCK_RETRY_MIN_WAIT_MS = 0 +export const BLOCK_RETRY_MAX_WAIT_MS = 30_000 +export const BLOCK_RETRY_DEFAULT_WAIT_MS = 1_000 + +/** + * Opt-in per-block retry, off unless the builder turns it on. + * + * Retrying is only safe when the operation is idempotent, which the platform + * cannot determine on a block's behalf: re-running "post message" or "create + * ticket" after an ambiguous transport failure duplicates a real side effect. + * The decision therefore belongs to whoever wired the block. + */ +export interface BlockRetryConfig { + /** Total attempts including the first, clamped to the bounds above. */ + maxAttempts: number + /** Base delay for exponential backoff with jitter. */ + waitMs?: number +} + export interface BlockState { id: string type: string @@ -78,6 +101,7 @@ export interface BlockState { data?: BlockData layout?: BlockLayoutState locked?: boolean + retry?: BlockRetryConfig } export interface WorkflowLockBlock { From 7068722470865e1ee519c10fc596862c83609920 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 15:58:10 -0700 Subject: [PATCH 2/2] fix(executor): honor cancellation after backoff, persist retry policy, read statusCode --- .../sub-block/components/text/text.tsx | 2 +- apps/sim/executor/execution/block-executor.ts | 6 ++ apps/sim/executor/execution/block-retry.ts | 18 +++-- .../handlers/pi/cloud-review-tools.test.ts | 66 +------------------ apps/sim/lib/api/contracts/workflows.ts | 21 ++++++ apps/sim/serializer/index.ts | 1 + packages/db/schema.ts | 2 + packages/workflow-persistence/src/load.ts | 1 + packages/workflow-persistence/src/save.ts | 1 + 9 files changed, 48 insertions(+), 70 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/text/text.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/text/text.tsx index cd7cd6b780d..85f045f84be 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/text/text.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/text/text.tsx @@ -33,7 +33,7 @@ export function Text({ blockId, subBlockId, content, className }: TextProps) { className={`rounded-md border bg-[var(--surface-2)] p-4 shadow-sm ${className || ''}`} >
diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index d89b5910ab6..969b9e09ef7 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -428,6 +428,12 @@ export class BlockExecutor { error: normalizeError(error), }) await sleep(delayMs) + /** + * Re-checked after the wait, not only before it: `sleep` is not abort-aware, + * so a run cancelled during backoff would otherwise start another attempt + * against a workflow that has already stopped. + */ + if (ctx.abortSignal?.aborted === true) throw error } } } diff --git a/apps/sim/executor/execution/block-retry.ts b/apps/sim/executor/execution/block-retry.ts index 06ec744eb02..17ed6fa0eb8 100644 --- a/apps/sim/executor/execution/block-retry.ts +++ b/apps/sim/executor/execution/block-retry.ts @@ -107,7 +107,13 @@ export function isRetryableBlockError(error: unknown): boolean { */ if (ChildWorkflowError.isChildWorkflowError(current)) return false - const candidate = current as { name?: string; code?: string; status?: number; message?: string } + const candidate = current as { + name?: string + code?: string + status?: number + statusCode?: number + message?: string + } /** * An abort is a deliberate stop — a user pressing Stop, or a block timeout @@ -118,9 +124,13 @@ export function isRetryableBlockError(error: unknown): boolean { if (candidate.name === 'TimeoutError') return true if (candidate.code && RETRYABLE_ERROR_CODES.has(candidate.code)) return true - if (typeof candidate.status === 'number' && RETRYABLE_HTTP_STATUSES.has(candidate.status)) { - return true - } + /** + * Both spellings are read: `HttpError` and the generic tool layer expose + * `statusCode`, while provider and SDK errors use `status`. Reading only one + * silently excludes most integration blocks from status-based retry. + */ + const httpStatus = candidate.status ?? candidate.statusCode + if (typeof httpStatus === 'number' && RETRYABLE_HTTP_STATUSES.has(httpStatus)) return true if (candidate.message?.includes(BUN_SOCKET_CLOSED_MESSAGE)) return true current = (current as { cause?: unknown }).cause diff --git a/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts b/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts index 4cf18a9f3a2..c45172d3fdf 100644 --- a/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-review-tools.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { execFile } from 'node:child_process' -import { mkdir, mkdtemp, rm, symlink, writeFile as writeLocalFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile as writeLocalFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -61,70 +61,6 @@ describe('cloud review tools', () => { expect(source).not.toContain('--unified=20') }) - it('enforces read-size and canonical path bounds in the actual helper', async () => { - await installCloudReviewTools(runner) - const source = writeFile.mock.calls[0][1] as string - const testDir = await mkdtemp(join(tmpdir(), 'sim-review-tools-')) - const repoDir = join(testDir, 'repo') - const scriptPath = join(testDir, 'review-tools.py') - const outsidePath = join(testDir, 'outside.txt') - - try { - await mkdir(repoDir) - await writeLocalFile( - scriptPath, - source.replace( - "pathlib.Path('/workspace/repo')", - `pathlib.Path(${JSON.stringify(repoDir)})` - ) - ) - await writeLocalFile(join(repoDir, 'safe.txt'), 'one\ntwo\n') - await writeLocalFile(outsidePath, 'secret') - await symlink(outsidePath, join(repoDir, 'escape.txt')) - await mkdir(join(repoDir, '.git')) - await writeLocalFile(join(repoDir, '.git', 'secret.txt'), 'DO_NOT_EXPOSE') - - const execute = (operation: string, args: Record) => - execFileAsync('python3', [scriptPath], { - env: { - ...process.env, - REVIEW_TOOL_OPERATION: operation, - REVIEW_TOOL_ARGS: JSON.stringify(args), - }, - }) - - await expect( - execute('read', { path: 'safe.txt', offset: 1, limit: 2 }) - ).resolves.toMatchObject({ - stdout: '1: one\n2: two', - }) - await expect(execute('read', { path: '../outside.txt' })).rejects.toMatchObject({ - stderr: expect.stringContaining('path must stay within the repository'), - }) - await expect(execute('read', { path: 'escape.txt' })).rejects.toMatchObject({ - stderr: expect.stringContaining('path resolves outside the repository'), - }) - - const found = await execute('find', { path: '.', pattern: '**/*', limit: 20 }) - expect(found.stdout).toContain('safe.txt') - expect(found.stdout).not.toContain('.git') - const searched = await execute('search', { - path: '.', - pattern: 'DO_NOT_EXPOSE', - glob: '**/*', - literal: true, - }) - expect(searched.stdout).toBe('No matches found') - - await writeLocalFile(join(repoDir, 'large.bin'), Buffer.alloc(5_000_001)) - await expect(execute('read', { path: 'large.bin' })).rejects.toMatchObject({ - stderr: expect.stringContaining('exceeds the 5 MB read limit'), - }) - } finally { - await rm(testDir, { recursive: true, force: true }) - } - }) - it('validates inline coordinates against an exact local diff', async () => { await installCloudReviewTools(runner) const source = writeFile.mock.calls[0][1] as string diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 89a926caf38..90a77f1dee6 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -1,3 +1,9 @@ +import { + BLOCK_RETRY_MAX_ATTEMPTS, + BLOCK_RETRY_MAX_WAIT_MS, + BLOCK_RETRY_MIN_ATTEMPTS, + BLOCK_RETRY_MIN_WAIT_MS, +} from '@sim/workflow-types/workflow' import { z } from 'zod' import { requiredFieldSchema, @@ -51,6 +57,20 @@ const workflowEdgeHandleSchema = z .nullish() .transform((value) => value ?? undefined) +const workflowBlockRetrySchema = z.object({ + maxAttempts: z + .number() + .int() + .min(BLOCK_RETRY_MIN_ATTEMPTS, `maxAttempts must be at least ${BLOCK_RETRY_MIN_ATTEMPTS}`) + .max(BLOCK_RETRY_MAX_ATTEMPTS, `maxAttempts cannot exceed ${BLOCK_RETRY_MAX_ATTEMPTS}`), + waitMs: z + .number() + .int() + .min(BLOCK_RETRY_MIN_WAIT_MS, 'waitMs cannot be negative') + .max(BLOCK_RETRY_MAX_WAIT_MS, `waitMs cannot exceed ${BLOCK_RETRY_MAX_WAIT_MS}ms`) + .optional(), +}) + const workflowBlockStateSchema = z.object({ id: z.string(), type: z.string(), @@ -65,6 +85,7 @@ const workflowBlockStateSchema = z.object({ triggerMode: z.boolean().optional(), data: workflowBlockDataSchema.optional(), locked: z.boolean().optional(), + retry: workflowBlockRetrySchema.optional(), }) const workflowEdgeSchema = z.object({ diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index d2586b5b0d1..1c3f83f798f 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -442,6 +442,7 @@ export class Serializer { serializedBlock.config?.params?.triggerMode === true || serializedBlock.metadata?.category === 'triggers', advancedMode: serializedBlock.config?.params?.advancedMode === true, + ...(serializedBlock.retry ? { retry: serializedBlock.retry } : {}), } } } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 37876e913e7..9ae2be89ee2 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -295,6 +295,8 @@ export const workflowBlocks = pgTable( advancedMode: boolean('advanced_mode').notNull().default(false), triggerMode: boolean('trigger_mode').notNull().default(false), locked: boolean('locked').notNull().default(false), + /** Opt-in retry policy; NULL means the block never retries. */ + retry: jsonb('retry'), height: decimal('height').notNull().default('0'), subBlocks: jsonb('sub_blocks').notNull().default('{}'), diff --git a/packages/workflow-persistence/src/load.ts b/packages/workflow-persistence/src/load.ts index 6d16e41427a..f59152897cf 100644 --- a/packages/workflow-persistence/src/load.ts +++ b/packages/workflow-persistence/src/load.ts @@ -83,6 +83,7 @@ export async function loadWorkflowFromNormalizedTablesRaw( horizontalHandles: block.horizontalHandles, advancedMode: block.advancedMode, triggerMode: block.triggerMode, + retry: (block.retry as BlockState['retry']) ?? undefined, height: Number(block.height), subBlocks: (block.subBlocks as BlockState['subBlocks']) || {}, outputs: (block.outputs as BlockState['outputs']) || {}, diff --git a/packages/workflow-persistence/src/save.ts b/packages/workflow-persistence/src/save.ts index fefe1d354d6..62e13ec2761 100644 --- a/packages/workflow-persistence/src/save.ts +++ b/packages/workflow-persistence/src/save.ts @@ -40,6 +40,7 @@ export async function saveWorkflowToNormalizedTables( horizontalHandles: block.horizontalHandles ?? true, advancedMode: block.advancedMode ?? false, triggerMode: block.triggerMode ?? false, + retry: block.retry ?? null, height: String(block.height || 0), subBlocks: block.subBlocks || {}, outputs: block.outputs || {},