fix(core): Add retry mechanism to tools (#16667)

This commit is contained in:
Benjamin Schroth
2025-06-26 13:11:41 +02:00
committed by GitHub
parent f690ed5e97
commit 9e61d0b9c0
7 changed files with 1056 additions and 88 deletions

View File

@@ -25,6 +25,7 @@ import {
NodeConnectionTypes,
NodeOperationError,
parseErrorMetadata,
sleepWithAbort,
traverseNodeParameters,
} from 'n8n-workflow';
import { z } from 'zod';
@@ -75,69 +76,116 @@ export class WorkflowToolService {
// This function will execute the sub-workflow and return the response
// We get the runIndex from the context to handle multiple executions
// of the same tool when the tool is used in a loop or in a parallel execution.
const node = ctx.getNode();
let runIndex: number = ctx.getNextRunIndex();
const toolHandler = async (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
): Promise<IDataObject | IDataObject[] | string> => {
const localRunIndex = runIndex++;
// We need to clone the context here to handle runIndex correctly
// Otherwise the runIndex will be shared between different executions
// Causing incorrect data to be passed to the sub-workflow and via $fromAI
const context = this.baseContext.cloneWith({
runIndex: localRunIndex,
inputData: [[{ json: { query } }]],
});
try {
const response = await this.runFunction(context, query, itemIndex, runManager);
const processedResponse = this.handleToolResponse(response);
let responseData: INodeExecutionData[];
if (isNodeExecutionData(response)) {
responseData = response;
} else {
const reParsedData = jsonParse<IDataObject>(processedResponse, {
fallbackValue: { response: processedResponse },
});
responseData = [{ json: reParsedData }];
}
// Once the sub-workflow is executed, add the output data to the context
// This will be used to link the sub-workflow execution in the parent workflow
let metadata: ITaskMetadata | undefined;
if (this.subExecutionId && this.subWorkflowId) {
metadata = {
subExecution: {
executionId: this.subExecutionId,
workflowId: this.subWorkflowId,
},
};
}
void context.addOutputData(
NodeConnectionTypes.AiTool,
localRunIndex,
[responseData],
metadata,
);
return processedResponse;
} catch (error) {
const executionError = error as ExecutionError;
const errorResponse = `There was an error: "${executionError.message}"`;
const metadata = parseErrorMetadata(error);
void context.addOutputData(
NodeConnectionTypes.AiTool,
localRunIndex,
executionError,
metadata,
);
return errorResponse;
let maxTries = 1;
if (node.retryOnFail === true) {
maxTries = Math.min(5, Math.max(2, node.maxTries ?? 3));
}
let waitBetweenTries = 0;
if (node.retryOnFail === true) {
waitBetweenTries = Math.min(5000, Math.max(0, node.waitBetweenTries ?? 1000));
}
let lastError: ExecutionError | undefined;
for (let tryIndex = 0; tryIndex < maxTries; tryIndex++) {
const localRunIndex = runIndex++;
// We need to clone the context here to handle runIndex correctly
// Otherwise the runIndex will be shared between different executions
// Causing incorrect data to be passed to the sub-workflow and via $fromAI
const context = this.baseContext.cloneWith({
runIndex: localRunIndex,
inputData: [[{ json: { query } }]],
});
// Get abort signal from context for cancellation support
const abortSignal = context.getExecutionCancelSignal?.();
// Check if execution was cancelled before retry
if (abortSignal?.aborted) {
return 'There was an error: "Execution was cancelled"';
}
if (tryIndex !== 0) {
// Reset error from previous attempt
lastError = undefined;
if (waitBetweenTries !== 0) {
try {
await sleepWithAbort(waitBetweenTries, abortSignal);
} catch (abortError) {
return 'There was an error: "Execution was cancelled"';
}
}
}
try {
const response = await this.runFunction(context, query, itemIndex, runManager);
const processedResponse = this.handleToolResponse(response);
let responseData: INodeExecutionData[];
if (isNodeExecutionData(response)) {
responseData = response;
} else {
const reParsedData = jsonParse<IDataObject>(processedResponse, {
fallbackValue: { response: processedResponse },
});
responseData = [{ json: reParsedData }];
}
// Once the sub-workflow is executed, add the output data to the context
// This will be used to link the sub-workflow execution in the parent workflow
let metadata: ITaskMetadata | undefined;
if (this.subExecutionId && this.subWorkflowId) {
metadata = {
subExecution: {
executionId: this.subExecutionId,
workflowId: this.subWorkflowId,
},
};
}
void context.addOutputData(
NodeConnectionTypes.AiTool,
localRunIndex,
[responseData],
metadata,
);
return processedResponse;
} catch (error) {
// Check if error is due to cancellation
if (abortSignal?.aborted) {
return 'There was an error: "Execution was cancelled"';
}
const executionError = error as ExecutionError;
lastError = executionError;
const errorResponse = `There was an error: "${executionError.message}"`;
const metadata = parseErrorMetadata(error);
void context.addOutputData(
NodeConnectionTypes.AiTool,
localRunIndex,
executionError,
metadata,
);
if (tryIndex === maxTries - 1) {
return errorResponse;
}
}
}
return `There was an error: ${lastError?.message ?? 'Unknown error'}`;
};
// Create structured tool if input schema is provided