fix: Make sure errors are transferred correctly from js task runner (no-changelog) (#11214)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
Tomi Turtiainen 2024-10-10 21:01:38 +03:00 committed by GitHub
parent 4e78c46a74
commit 1078fa662a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 311 additions and 122 deletions

View file

@ -13,6 +13,7 @@ import {
import type { Task } from '@/task-runner';
import { newAllCodeTaskData, newTaskWithSettings, withPairedItem, wrapIntoJson } from './test-data';
import { ExecutionError } from '../errors/execution-error';
jest.mock('ws');
@ -292,7 +293,7 @@ describe('JsTaskRunner', () => {
});
expect(outcome).toEqual({
result: [wrapIntoJson({ error: 'Error message' })],
result: [wrapIntoJson({ error: 'Error message [line 1]' })],
customData: undefined,
});
});
@ -406,8 +407,8 @@ describe('JsTaskRunner', () => {
expect(outcome).toEqual({
result: [
withPairedItem(0, wrapIntoJson({ error: 'Error message' })),
withPairedItem(1, wrapIntoJson({ error: 'Error message' })),
withPairedItem(0, wrapIntoJson({ error: 'Error message [line 1]' })),
withPairedItem(1, wrapIntoJson({ error: 'Error message [line 1]' })),
],
customData: undefined,
});
@ -706,4 +707,56 @@ describe('JsTaskRunner', () => {
);
});
});
describe('errors', () => {
test.each<[CodeExecutionMode]>([['runOnceForAllItems'], ['runOnceForEachItem']])(
'should throw an ExecutionError if the code is invalid in %s mode',
async (nodeMode) => {
await expect(
execTaskWithParams({
task: newTaskWithSettings({
code: 'unknown',
nodeMode,
}),
taskData: newAllCodeTaskData([wrapIntoJson({ a: 1 })]),
}),
).rejects.toThrow(ExecutionError);
},
);
it('sends serializes an error correctly', async () => {
const runner = createRunnerWithOpts({});
const taskId = '1';
const task = newTaskWithSettings({
code: 'unknown; return []',
nodeMode: 'runOnceForAllItems',
continueOnFail: false,
mode: 'manual',
workflowMode: 'manual',
});
runner.runningTasks.set(taskId, task);
const sendSpy = jest.spyOn(runner.ws, 'send').mockImplementation(() => {});
jest.spyOn(runner, 'sendOffers').mockImplementation(() => {});
jest
.spyOn(runner, 'requestData')
.mockResolvedValue(newAllCodeTaskData([wrapIntoJson({ a: 1 })]));
await runner.receivedSettings(taskId, task.settings);
expect(sendSpy).toHaveBeenCalledWith(
JSON.stringify({
type: 'runner:taskerror',
taskId,
error: {
message: 'unknown is not defined [line 1]',
description: 'ReferenceError',
lineNumber: 1,
},
}),
);
console.log('DONE');
}, 1000);
});
});

View file

@ -0,0 +1,53 @@
import { ExecutionError } from '../execution-error';
describe('ExecutionError', () => {
const defaultStack = `TypeError: a.unknown is not a function
at VmCodeWrapper (evalmachine.<anonymous>:2:3)
at evalmachine.<anonymous>:7:2
at Script.runInContext (node:vm:148:12)
at Script.runInNewContext (node:vm:153:17)
at runInNewContext (node:vm:309:38)
at JsTaskRunner.runForAllItems (/n8n/packages/@n8n/task-runner/dist/js-task-runner/js-task-runner.js:90:65)
at JsTaskRunner.executeTask (/n8n/packages/@n8n/task-runner/dist/js-task-runner/js-task-runner.js:71:26)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async JsTaskRunner.receivedSettings (/n8n/packages/@n8n/task-runner/dist/task-runner.js:190:26)`;
it('should parse error details from stack trace without itemIndex', () => {
const error = new Error('a.unknown is not a function');
error.stack = defaultStack;
const executionError = new ExecutionError(error);
expect(executionError.message).toBe('a.unknown is not a function [line 2]');
expect(executionError.lineNumber).toBe(2);
expect(executionError.description).toBe('TypeError');
expect(executionError.context).toBeUndefined();
});
it('should parse error details from stack trace with itemIndex', () => {
const error = new Error('a.unknown is not a function');
error.stack = defaultStack;
const executionError = new ExecutionError(error, 1);
expect(executionError.message).toBe('a.unknown is not a function [line 2, for item 1]');
expect(executionError.lineNumber).toBe(2);
expect(executionError.description).toBe('TypeError');
expect(executionError.context).toEqual({ itemIndex: 1 });
});
it('should serialize correctly', () => {
const error = new Error('a.unknown is not a function');
error.stack = defaultStack;
const executionError = new ExecutionError(error, 1);
expect(JSON.stringify(executionError)).toBe(
JSON.stringify({
message: 'a.unknown is not a function [line 2, for item 1]',
description: 'TypeError',
itemIndex: 1,
context: { itemIndex: 1 },
lineNumber: 2,
}),
);
});
});

View file

@ -0,0 +1,12 @@
export interface ErrorLike {
message: string;
stack?: string;
}
export function isErrorLike(value: unknown): value is ErrorLike {
if (typeof value !== 'object' || value === null) return false;
const errorLike = value as ErrorLike;
return typeof errorLike.message === 'string';
}

View file

@ -1,6 +1,9 @@
import { ApplicationError } from 'n8n-workflow';
import type { ErrorLike } from './error-like';
import { SerializableError } from './serializable-error';
export class ExecutionError extends ApplicationError {
const VM_WRAPPER_FN_NAME = 'VmCodeWrapper';
export class ExecutionError extends SerializableError {
description: string | null = null;
itemIndex: number | undefined = undefined;
@ -11,7 +14,7 @@ export class ExecutionError extends ApplicationError {
lineNumber: number | undefined = undefined;
constructor(error: Error & { stack?: string }, itemIndex?: number) {
constructor(error: ErrorLike, itemIndex?: number) {
super(error.message);
this.itemIndex = itemIndex;
@ -32,10 +35,11 @@ export class ExecutionError extends ApplicationError {
if (stackRows.length === 0) {
this.message = 'Unknown error';
return;
}
const messageRow = stackRows.find((line) => line.includes('Error:'));
const lineNumberRow = stackRows.find((line) => line.includes('Code:'));
const lineNumberRow = stackRows.find((line) => line.includes(`at ${VM_WRAPPER_FN_NAME} `));
const lineNumberDisplay = this.toLineNumberDisplay(lineNumberRow);
if (!messageRow) {
@ -56,16 +60,22 @@ export class ExecutionError extends ApplicationError {
}
private toLineNumberDisplay(lineNumberRow?: string) {
const errorLineNumberMatch = lineNumberRow?.match(/Code:(?<lineNumber>\d+)/);
if (!lineNumberRow) return '';
// TODO: This doesn't work if there is a function definition in the code
// and the error is thrown from that function.
const regex = new RegExp(
`at ${VM_WRAPPER_FN_NAME} \\(evalmachine\\.<anonymous>:(?<lineNumber>\\d+):`,
);
const errorLineNumberMatch = lineNumberRow.match(regex);
if (!errorLineNumberMatch?.groups?.lineNumber) return null;
const lineNumber = errorLineNumberMatch.groups.lineNumber;
if (!lineNumber) return '';
this.lineNumber = Number(lineNumber);
if (!lineNumber) return '';
return this.itemIndex === undefined
? `[line ${lineNumber}]`
: `[line ${lineNumber}, for item ${this.itemIndex}]`;

View file

@ -0,0 +1,21 @@
/**
* Error that has its message property serialized as well. Used to transport
* errors over the wire.
*/
export abstract class SerializableError extends Error {
constructor(message: string) {
super(message);
// So it is serialized as well
this.makeMessageEnumerable();
}
private makeMessageEnumerable() {
Object.defineProperty(this, 'message', {
value: this.message,
enumerable: true, // This makes the message property enumerable
writable: true,
configurable: true,
});
}
}

View file

@ -1,6 +1,6 @@
import { ApplicationError } from 'n8n-workflow';
import { SerializableError } from './serializable-error';
export class ValidationError extends ApplicationError {
export class ValidationError extends SerializableError {
description = '';
itemIndex: number | undefined = undefined;

View file

@ -25,6 +25,8 @@ import { runInNewContext, type Context } from 'node:vm';
import type { TaskResultData } from '@/runner-types';
import { type Task, TaskRunner } from '@/task-runner';
import { isErrorLike } from './errors/error-like';
import { ExecutionError } from './errors/execution-error';
import type { RequireResolver } from './require-resolver';
import { createRequireResolver } from './require-resolver';
import { validateRunForAllItemsOutput, validateRunForEachItemOutput } from './result-validation';
@ -186,7 +188,7 @@ export class JsTaskRunner extends TaskRunner {
try {
const result = (await runInNewContext(
`module.exports = async function() {${settings.code}\n}()`,
`module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
context,
)) as TaskResultData['result'];
@ -195,12 +197,14 @@ export class JsTaskRunner extends TaskRunner {
}
return validateRunForAllItemsOutput(result);
} catch (error) {
} catch (e) {
// Errors thrown by the VM are not instances of Error, so map them to an ExecutionError
const error = this.toExecutionErrorIfNeeded(e);
if (settings.continueOnFail) {
return [{ json: { error: this.getErrorMessageFromVmError(error) } }];
return [{ json: { error: error.message } }];
}
(error as Record<string, unknown>).node = allData.node;
throw error;
}
}
@ -233,7 +237,7 @@ export class JsTaskRunner extends TaskRunner {
try {
let result = (await runInNewContext(
`module.exports = async function() {${settings.code}\n}()`,
`module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
context,
)) as INodeExecutionData | undefined;
@ -257,14 +261,16 @@ export class JsTaskRunner extends TaskRunner {
},
);
}
} catch (error) {
} catch (e) {
// Errors thrown by the VM are not instances of Error, so map them to an ExecutionError
const error = this.toExecutionErrorIfNeeded(e);
if (!settings.continueOnFail) {
(error as Record<string, unknown>).node = allData.node;
throw error;
}
returnData.push({
json: { error: this.getErrorMessageFromVmError(error) },
json: { error: error.message },
pairedItem: {
item: index,
},
@ -304,11 +310,15 @@ export class JsTaskRunner extends TaskRunner {
).getDataProxy();
}
private getErrorMessageFromVmError(error: unknown): string {
if (typeof error === 'object' && !!error && 'message' in error) {
return error.message as string;
private toExecutionErrorIfNeeded(error: unknown): Error {
if (error instanceof Error) {
return error;
}
return JSON.stringify(error);
if (isErrorLike(error)) {
return new ExecutionError(error);
}
return new ExecutionError({ message: JSON.stringify(error) });
}
}

View file

@ -1,4 +1,4 @@
import { ApplicationError, ensureError } from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { URL } from 'node:url';
import { type MessageEvent, WebSocket } from 'ws';
@ -256,8 +256,7 @@ export abstract class TaskRunner {
try {
const data = await this.executeTask(task);
this.taskDone(taskId, data);
} catch (e) {
const error = ensureError(e);
} catch (error) {
this.taskErrored(taskId, error);
}
}

View file

@ -1,22 +1,23 @@
import type {
EnvProviderState,
IExecuteFunctions,
Workflow,
IRunExecutionData,
INodeExecutionData,
ITaskDataConnections,
INode,
WorkflowParameters,
INodeParameters,
WorkflowExecuteMode,
IExecuteData,
IDataObject,
IWorkflowExecuteAdditionalData,
import {
type EnvProviderState,
type IExecuteFunctions,
type Workflow,
type IRunExecutionData,
type INodeExecutionData,
type ITaskDataConnections,
type INode,
type WorkflowParameters,
type INodeParameters,
type WorkflowExecuteMode,
type IExecuteData,
type IDataObject,
type IWorkflowExecuteAdditionalData,
type Result,
createResultOk,
createResultError,
} from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { TaskError } from '@/runners/errors';
import {
RPC_ALLOW_LIST,
type TaskResultData,
@ -125,7 +126,7 @@ export class TaskManager {
tasks: Map<string, Task> = new Map();
async startTask<T>(
async startTask<TData, TError>(
additionalData: IWorkflowExecuteAdditionalData,
taskType: string,
settings: unknown,
@ -145,7 +146,7 @@ export class TaskManager {
defaultReturnRunIndex = -1,
selfData: IDataObject = {},
contextNodeName: string = activeNodeName,
): Promise<T> {
): Promise<Result<TData, TError>> {
const data: TaskData = {
workflow,
runExecutionData,
@ -221,14 +222,10 @@ export class TaskManager {
runExecutionData.resultData.metadata[k] = v;
});
}
return resultData.result as T;
} catch (e) {
if (typeof e === 'string') {
throw new TaskError(e, {
level: 'error',
});
}
throw e;
return createResultOk(resultData.result as TData);
} catch (e: unknown) {
return createResultError(e as TError);
} finally {
this.tasks.delete(taskId);
}

View file

@ -10,6 +10,7 @@ import type {
INodeParameters,
IExecuteData,
IDataObject,
Result,
} from 'n8n-workflow';
import { createEnvProviderState } from 'n8n-workflow';
@ -29,13 +30,13 @@ export const createAgentStartJob = (
selfData?: IDataObject,
contextNodeName?: string,
): IExecuteFunctions['startJob'] => {
return async function startJob<T = unknown>(
return async function startJob<T = unknown, E = unknown>(
this: IExecuteFunctions,
jobType: string,
settings: unknown,
itemIndex: number,
): Promise<T> {
return await additionalData.startAgentJob<T>(
): Promise<Result<T, E>> {
return await additionalData.startAgentJob<T, E>(
additionalData,
jobType,
settings,

View file

@ -1,16 +1,13 @@
import {
ensureError,
ApplicationError,
type CodeExecutionMode,
type IExecuteFunctions,
type INodeExecutionData,
type WorkflowExecuteMode,
} from 'n8n-workflow';
import { ExecutionError } from './ExecutionError';
import {
mapItemsNotDefinedErrorIfNeededForRunForAll,
validateNoDisallowedMethodsInRunForEach,
} from './JsCodeValidator';
import { isWrappableError, WrappedExecutionError } from './errors/WrappedExecutionError';
import { validateNoDisallowedMethodsInRunForEach } from './JsCodeValidator';
/**
* JS Code execution sandbox that executes the JS code using task runner.
@ -23,31 +20,10 @@ export class JsTaskRunnerSandbox {
private readonly executeFunctions: IExecuteFunctions,
) {}
async runCode<T = unknown>(): Promise<T> {
const itemIndex = 0;
try {
const executionResult = (await this.executeFunctions.startJob<T>(
'javascript',
{
code: this.jsCode,
nodeMode: this.nodeMode,
workflowMode: this.workflowMode,
},
itemIndex,
)) as T;
return executionResult;
} catch (e) {
const error = ensureError(e);
throw new ExecutionError(error);
}
}
async runCodeAllItems(): Promise<INodeExecutionData[]> {
const itemIndex = 0;
return await this.executeFunctions
.startJob<INodeExecutionData[]>(
const executionResult = await this.executeFunctions.startJob<INodeExecutionData[]>(
'javascript',
{
code: this.jsCode,
@ -56,22 +32,18 @@ export class JsTaskRunnerSandbox {
continueOnFail: this.executeFunctions.continueOnFail(),
},
itemIndex,
)
.catch((e) => {
const error = ensureError(e);
// anticipate user expecting `items` to pre-exist as in Function Item node
mapItemsNotDefinedErrorIfNeededForRunForAll(this.jsCode, error);
);
throw new ExecutionError(error);
});
return executionResult.ok
? executionResult.result
: this.throwExecutionError(executionResult.error);
}
async runCodeForEachItem(): Promise<INodeExecutionData[]> {
validateNoDisallowedMethodsInRunForEach(this.jsCode, 0);
const itemIndex = 0;
return await this.executeFunctions
.startJob<INodeExecutionData[]>(
const executionResult = await this.executeFunctions.startJob<INodeExecutionData[]>(
'javascript',
{
code: this.jsCode,
@ -80,13 +52,22 @@ export class JsTaskRunnerSandbox {
continueOnFail: this.executeFunctions.continueOnFail(),
},
itemIndex,
)
.catch((e) => {
const error = ensureError(e);
// anticipate user expecting `items` to pre-exist as in Function Item node
mapItemsNotDefinedErrorIfNeededForRunForAll(this.jsCode, error);
);
throw new ExecutionError(error);
return executionResult.ok
? executionResult.result
: this.throwExecutionError(executionResult.error);
}
private throwExecutionError(error: unknown): never {
// The error coming from task runner is not an instance of error,
// so we need to wrap it in an error instance.
if (isWrappableError(error)) {
throw new WrappedExecutionError(error);
}
throw new ApplicationError('Unknown error', {
cause: error,
});
}
}

View file

@ -0,0 +1,35 @@
import { ApplicationError } from 'n8n-workflow';
export type WrappableError = Record<string, unknown>;
/**
* Errors received from the task runner are not instances of Error.
* This class wraps them in an Error instance and makes all their
* properties available.
*/
export class WrappedExecutionError extends ApplicationError {
[key: string]: unknown;
constructor(error: WrappableError) {
const message = typeof error.message === 'string' ? error.message : 'Unknown error';
super(message, {
cause: error,
});
this.copyErrorProperties(error);
}
private copyErrorProperties(error: WrappableError) {
for (const key of Object.getOwnPropertyNames(error)) {
if (key === 'message' || key === 'stack') {
continue;
}
this[key] = error[key];
}
}
}
export function isWrappableError(error: unknown): error is WrappableError {
return typeof error === 'object' && error !== null;
}

View file

@ -21,6 +21,7 @@ import type { NodeOperationError } from './errors/node-operation.error';
import type { WorkflowActivationError } from './errors/workflow-activation.error';
import type { WorkflowOperationError } from './errors/workflow-operation.error';
import type { ExecutionStatus } from './ExecutionStatus';
import type { Result } from './result';
import type { Workflow } from './Workflow';
import type { EnvProviderState } from './WorkflowDataProxyEnvProvider';
import type { WorkflowHooks } from './WorkflowHooks';
@ -997,7 +998,11 @@ export type IExecuteFunctions = ExecuteFunctions.GetNodeParameterFn &
getParentCallbackManager(): CallbackManager | undefined;
startJob<T = unknown>(jobType: string, settings: unknown, itemIndex: number): Promise<T>;
startJob<T = unknown, E = unknown>(
jobType: string,
settings: unknown,
itemIndex: number,
): Promise<Result<T, E>>;
};
export interface IExecuteSingleFunctions extends BaseExecutionFunctions {
@ -2285,7 +2290,7 @@ export interface IWorkflowExecuteAdditionalData {
secretsHelpers: SecretsHelpersBase;
logAiEvent: (eventName: AiEvent, payload: AiEventPayload) => void;
parentCallbackManager?: CallbackManager;
startAgentJob<T>(
startAgentJob<T, E = unknown>(
additionalData: IWorkflowExecuteAdditionalData,
jobType: string,
settings: unknown,
@ -2305,7 +2310,7 @@ export interface IWorkflowExecuteAdditionalData {
defaultReturnRunIndex?: number,
selfData?: IDataObject,
contextNodeName?: string,
): Promise<T>;
): Promise<Result<T, E>>;
}
export type WorkflowExecuteMode =
@ -2752,8 +2757,6 @@ export type BannerName =
export type Functionality = 'regular' | 'configuration-node' | 'pairedItem';
export type Result<T, E> = { ok: true; result: T } | { ok: false; error: E };
export type CallbackManager = CallbackManagerLC;
export type IPersonalizationSurveyAnswersV4 = {

View file

@ -7,10 +7,10 @@ import type {
FilterOptionsValue,
FilterValue,
INodeProperties,
Result,
ValidationResult,
} from '../Interfaces';
import * as LoggerProxy from '../LoggerProxy';
import type { Result } from '../result';
import { validateFieldType } from '../TypeValidation';
type FilterConditionMetadata = {

View file

@ -22,6 +22,7 @@ export * from './WorkflowDataProxyEnvProvider';
export * from './WorkflowHooks';
export * from './VersionedNodeType';
export * from './TypeValidation';
export * from './result';
export { LoggerProxy, NodeHelpers, ObservableObject, TelemetryHelpers };
export {
isObjectEmpty,

View file

@ -0,0 +1,13 @@
export type ResultOk<T> = { ok: true; result: T };
export type ResultError<E> = { ok: false; error: E };
export type Result<T, E> = ResultOk<T> | ResultError<E>;
export const createResultOk = <T>(data: T): ResultOk<T> => ({
ok: true,
result: data,
});
export const createResultError = <E = unknown>(error: E): ResultError<E> => ({
ok: false,
error,
});