refactor(core): Switch plain errors in workflow to ApplicationError (no-changelog) (#7877)

Ensure all errors in `workflow` are `ApplicationError` or children of it
and contain no variables in the message, to continue normalizing all the
errors we report to Sentry

Follow-up to: https://github.com/n8n-io/n8n/pull/7873
This commit is contained in:
Iván Ovejero 2023-11-30 12:46:45 +01:00 committed by GitHub
parent ce2d388f05
commit 67702c2485
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 110 additions and 61 deletions

View file

@ -24,7 +24,7 @@ export function init(errorReporter: ErrorReporter) {
const wrap = (e: unknown) => { const wrap = (e: unknown) => {
if (e instanceof Error) return e; if (e instanceof Error) return e;
if (typeof e === 'string') return new Error(e); if (typeof e === 'string') return new ApplicationError(e);
return; return;
}; };

View file

@ -25,6 +25,7 @@ import { extendedFunctions } from './Extensions/ExtendedFunctions';
import { extendSyntax } from './Extensions/ExpressionExtension'; import { extendSyntax } from './Extensions/ExpressionExtension';
import { evaluateExpression, setErrorHandler } from './ExpressionEvaluatorProxy'; import { evaluateExpression, setErrorHandler } from './ExpressionEvaluatorProxy';
import { getGlobalState } from './GlobalState'; import { getGlobalState } from './GlobalState';
import { ApplicationError } from './errors/application.error';
const IS_FRONTEND_IN_DEV_MODE = const IS_FRONTEND_IN_DEV_MODE =
typeof process === 'object' && typeof process === 'object' &&
@ -75,7 +76,7 @@ export class Expression {
const typeName = Array.isArray(value) ? 'Array' : 'Object'; const typeName = Array.isArray(value) ? 'Array' : 'Object';
if (value instanceof DateTime && value.invalidReason !== null) { if (value instanceof DateTime && value.invalidReason !== null) {
throw new Error('invalid DateTime'); throw new ApplicationError('invalid DateTime');
} }
let result = ''; let result = '';
@ -310,12 +311,12 @@ export class Expression {
const extendedExpression = extendSyntax(parameterValue); const extendedExpression = extendSyntax(parameterValue);
const returnValue = this.renderExpression(extendedExpression, data); const returnValue = this.renderExpression(extendedExpression, data);
if (typeof returnValue === 'function') { if (typeof returnValue === 'function') {
if (returnValue.name === '$') throw new Error('invalid syntax'); if (returnValue.name === '$') throw new ApplicationError('invalid syntax');
if (returnValue.name === 'DateTime') if (returnValue.name === 'DateTime')
throw new Error('this is a DateTime, please access its methods'); throw new ApplicationError('this is a DateTime, please access its methods');
throw new Error('this is a function, please add ()'); throw new ApplicationError('this is a function, please add ()');
} else if (typeof returnValue === 'string') { } else if (typeof returnValue === 'string') {
return returnValue; return returnValue;
} else if (returnValue !== null && typeof returnValue === 'object') { } else if (returnValue !== null && typeof returnValue === 'object') {
@ -339,14 +340,14 @@ export class Expression {
} catch (error) { } catch (error) {
if (isExpressionError(error)) throw error; if (isExpressionError(error)) throw error;
if (isSyntaxError(error)) throw new Error('invalid syntax'); if (isSyntaxError(error)) throw new ApplicationError('invalid syntax');
if (isTypeError(error) && IS_FRONTEND && error.message.endsWith('is not a function')) { if (isTypeError(error) && IS_FRONTEND && error.message.endsWith('is not a function')) {
const match = error.message.match(/(?<msg>[^.]+is not a function)/); const match = error.message.match(/(?<msg>[^.]+is not a function)/);
if (!match?.groups?.msg) return null; if (!match?.groups?.msg) return null;
throw new Error(match.groups.msg); throw new ApplicationError(match.groups.msg);
} }
} finally { } finally {
Object.defineProperty(Function.prototype, 'constructor', { value: fnConstructors.sync }); Object.defineProperty(Function.prototype, 'constructor', { value: fnConstructors.sync });

View file

@ -48,6 +48,7 @@ import { deepCopy } from './utils';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import type { Workflow } from './Workflow'; import type { Workflow } from './Workflow';
import { ApplicationError } from './errors/application.error';
export const cronNodeOptions: INodePropertyCollection[] = [ export const cronNodeOptions: INodePropertyCollection[] = [
{ {
@ -416,7 +417,7 @@ export function getContext(
): IContextObject { ): IContextObject {
if (runExecutionData.executionData === undefined) { if (runExecutionData.executionData === undefined) {
// TODO: Should not happen leave it for test now // TODO: Should not happen leave it for test now
throw new Error('The "executionData" is not initialized!'); throw new ApplicationError('`executionData` is not initialized');
} }
let key: string; let key: string;
@ -424,11 +425,16 @@ export function getContext(
key = 'flow'; key = 'flow';
} else if (type === 'node') { } else if (type === 'node') {
if (node === undefined) { if (node === undefined) {
throw new Error('The request data of context type "node" the node parameter has to be set!'); // @TODO: What does this mean?
throw new ApplicationError(
'The request data of context type "node" the node parameter has to be set!',
);
} }
key = `node:${node.name}`; key = `node:${node.name}`;
} else { } else {
throw new Error(`The context type "${type}" is not know. Only "flow" and node" are supported!`); throw new ApplicationError('Unknown context type. Only `flow` and `node` are supported.', {
extra: { contextType: type },
});
} }
if (runExecutionData.executionData.contextData[key] === undefined) { if (runExecutionData.executionData.contextData[key] === undefined) {
@ -530,7 +536,7 @@ export function getParameterResolveOrder(
} }
if (iterations > lastIndexReduction + nodePropertiesArray.length) { if (iterations > lastIndexReduction + nodePropertiesArray.length) {
throw new Error( throw new ApplicationError(
'Could not resolve parameter dependencies. Max iterations reached! Hint: If `displayOptions` are specified in any child parameter of a parent `collection` or `fixedCollection`, remove the `displayOptions` from the child parameter.', 'Could not resolve parameter dependencies. Max iterations reached! Hint: If `displayOptions` are specified in any child parameter of a parent `collection` or `fixedCollection`, remove the `displayOptions` from the child parameter.',
); );
} }
@ -773,9 +779,9 @@ export function getNodeParameters(
) as INodePropertyCollection; ) as INodePropertyCollection;
if (nodePropertyOptions === undefined) { if (nodePropertyOptions === undefined) {
throw new Error( throw new ApplicationError('Could not find property option', {
`Could not find property option "${itemName}" for "${nodeProperties.name}"`, extra: { propertyOption: itemName, property: nodeProperties.name },
); });
} }
tempNodePropertiesArray = nodePropertyOptions.values!; tempNodePropertiesArray = nodePropertyOptions.values!;
@ -1054,7 +1060,9 @@ export function getNodeInputs(
{}, {},
) || []) as ConnectionTypes[]; ) || []) as ConnectionTypes[];
} catch (e) { } catch (e) {
throw new Error(`Could not calculate inputs dynamically for node "${node.name}"`); throw new ApplicationError('Could not calculate inputs dynamically for node', {
extra: { nodeName: node.name },
});
} }
} }
@ -1077,7 +1085,9 @@ export function getNodeOutputs(
{}, {},
) || []) as ConnectionTypes[]; ) || []) as ConnectionTypes[];
} catch (e) { } catch (e) {
throw new Error(`Could not calculate outputs dynamically for node "${node.name}"`); throw new ApplicationError('Could not calculate outputs dynamically for node', {
extra: { nodeName: node.name },
});
} }
} }
@ -1258,7 +1268,7 @@ export const tryToParseNumber = (value: unknown): number => {
const isValidNumber = !isNaN(Number(value)); const isValidNumber = !isNaN(Number(value));
if (!isValidNumber) { if (!isValidNumber) {
throw new Error(`Could not parse '${String(value)}' to number.`); throw new ApplicationError('Failed to parse value to number', { extra: { value } });
} }
return Number(value); return Number(value);
}; };
@ -1282,7 +1292,9 @@ export const tryToParseBoolean = (value: unknown): value is boolean => {
} }
} }
throw new Error(`Could not parse '${String(value)}' to boolean.`); throw new ApplicationError('Failed to parse value as boolean', {
extra: { value },
});
}; };
export const tryToParseDateTime = (value: unknown): DateTime => { export const tryToParseDateTime = (value: unknown): DateTime => {
@ -1306,7 +1318,7 @@ export const tryToParseDateTime = (value: unknown): DateTime => {
return sqlDate; return sqlDate;
} }
throw new Error(`The value "${dateString}" is not a valid date.`); throw new ApplicationError('Value is not a valid date', { extra: { dateString } });
}; };
export const tryToParseTime = (value: unknown): string => { export const tryToParseTime = (value: unknown): string => {
@ -1314,7 +1326,7 @@ export const tryToParseTime = (value: unknown): string => {
String(value), String(value),
); );
if (!isTimeInput) { if (!isTimeInput) {
throw new Error(`The value "${String(value)}" is not a valid time.`); throw new ApplicationError('Value is not a valid time', { extra: { value } });
} }
return String(value); return String(value);
}; };
@ -1333,11 +1345,11 @@ export const tryToParseArray = (value: unknown): unknown[] => {
} }
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
throw new Error(`The value "${String(value)}" is not a valid array.`); throw new ApplicationError('Value is not a valid array', { extra: { value } });
} }
return parsed; return parsed;
} catch (e) { } catch (e) {
throw new Error(`The value "${String(value)}" is not a valid array.`); throw new ApplicationError('Value is not a valid array', { extra: { value } });
} }
}; };
@ -1348,11 +1360,11 @@ export const tryToParseObject = (value: unknown): object => {
try { try {
const o = JSON.parse(String(value)); const o = JSON.parse(String(value));
if (typeof o !== 'object' || Array.isArray(o)) { if (typeof o !== 'object' || Array.isArray(o)) {
throw new Error(`The value "${String(value)}" is not a valid object.`); throw new ApplicationError('Value is not a valid object', { extra: { value } });
} }
return o; return o;
} catch (e) { } catch (e) {
throw new Error(`The value "${String(value)}" is not a valid object.`); throw new ApplicationError('Value is not a valid object', { extra: { value } });
} }
}; };

View file

@ -9,6 +9,7 @@ import type {
INodeTypes, INodeTypes,
INodeType, INodeType,
} from './Interfaces'; } from './Interfaces';
import { ApplicationError } from './errors/application.error';
const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote'; const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote';
@ -95,7 +96,7 @@ export function getDomainPath(raw: string, urlParts = URL_PARTS_REGEX): string {
try { try {
const url = new URL(raw); const url = new URL(raw);
if (!url.hostname) throw new Error('Malformed URL'); if (!url.hostname) throw new ApplicationError('Malformed URL');
return sanitizeRoute(url.pathname); return sanitizeRoute(url.pathname);
} catch { } catch {

View file

@ -51,6 +51,7 @@ import * as ObservableObject from './ObservableObject';
import { RoutingNode } from './RoutingNode'; import { RoutingNode } from './RoutingNode';
import { Expression } from './Expression'; import { Expression } from './Expression';
import { NODES_WITH_RENAMABLE_CONTENT } from './Constants'; import { NODES_WITH_RENAMABLE_CONTENT } from './Constants';
import { ApplicationError } from './errors/application.error';
function dedupe<T>(arr: T[]): T[] { function dedupe<T>(arr: T[]): T[] {
return [...new Set(arr)]; return [...new Set(arr)];
@ -113,7 +114,10 @@ export class Workflow {
// expression resolution also then when the unknown node // expression resolution also then when the unknown node
// does not get used. // does not get used.
continue; continue;
// throw new Error(`The node type "${node.type}" of node "${node.name}" is not known.`); // throw new ApplicationError(`Node with unknown node type`, {
// tags: { nodeType: node.type },
// extra: { node },
// });
} }
// Add default values // Add default values
@ -312,15 +316,15 @@ export class Workflow {
key = 'global'; key = 'global';
} else if (type === 'node') { } else if (type === 'node') {
if (node === undefined) { if (node === undefined) {
throw new Error( throw new ApplicationError(
'The request data of context type "node" the node parameter has to be set!', 'The request data of context type "node" the node parameter has to be set!',
); );
} }
key = `node:${node.name}`; key = `node:${node.name}`;
} else { } else {
throw new Error( throw new ApplicationError('Unknown context type. Only `global` and `node` are supported.', {
`The context type "${type}" is not know. Only "global" and node" are supported!`, extra: { contextType: type },
); });
} }
if (this.staticData[key] === undefined) { if (this.staticData[key] === undefined) {
@ -1092,13 +1096,17 @@ export class Workflow {
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) { if (nodeType === undefined) {
throw new Error(`The node type "${node.type}" of node "${node.name}" is not known.`); throw new ApplicationError('Node with unknown node type', {
extra: { nodeName: node.name },
tags: { nodeType: node.type },
});
} }
if (!nodeType.trigger) { if (!nodeType.trigger) {
throw new Error( throw new ApplicationError('Node type does not have a trigger function defined', {
`The node type "${node.type}" of node "${node.name}" does not have a trigger function defined.`, extra: { nodeName: node.name },
); tags: { nodeType: node.type },
});
} }
if (mode === 'manual') { if (mode === 'manual') {
@ -1169,13 +1177,17 @@ export class Workflow {
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) { if (nodeType === undefined) {
throw new Error(`The node type "${node.type}" of node "${node.name}" is not known.`); throw new ApplicationError('Node with unknown node type', {
extra: { nodeName: node.name },
tags: { nodeType: node.type },
});
} }
if (!nodeType.poll) { if (!nodeType.poll) {
throw new Error( throw new ApplicationError('Node type does not have a poll function defined', {
`The node type "${node.type}" of node "${node.name}" does not have a poll function defined.`, extra: { nodeName: node.name },
); tags: { nodeType: node.type },
});
} }
return nodeType.poll.call(pollFunctions); return nodeType.poll.call(pollFunctions);
@ -1195,9 +1207,13 @@ export class Workflow {
): Promise<IWebhookResponseData> { ): Promise<IWebhookResponseData> {
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) { if (nodeType === undefined) {
throw new Error(`The type of the webhook node "${node.name}" is not known.`); throw new ApplicationError('Unknown node type of webhook node', {
extra: { nodeName: node.name },
});
} else if (nodeType.webhook === undefined) { } else if (nodeType.webhook === undefined) {
throw new Error(`The node "${node.name}" does not have any webhooks defined.`); throw new ApplicationError('Node does not have any webhooks defined', {
extra: { nodeName: node.name },
});
} }
const context = nodeExecuteFunctions.getExecuteWebhookFunctions( const context = nodeExecuteFunctions.getExecuteWebhookFunctions(
@ -1241,7 +1257,9 @@ export class Workflow {
const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) { if (nodeType === undefined) {
throw new Error(`Node type "${node.type}" is not known so can not run it!`); throw new ApplicationError('Node type is unknown so cannot run it', {
tags: { nodeType: node.type },
});
} }
let connectionInputData: INodeExecutionData[] = []; let connectionInputData: INodeExecutionData[] = [];

View file

@ -29,6 +29,7 @@ import type { Workflow } from './Workflow';
import { augmentArray, augmentObject } from './AugmentObject'; import { augmentArray, augmentObject } from './AugmentObject';
import { deepCopy } from './utils'; import { deepCopy } from './utils';
import { getGlobalState } from './GlobalState'; import { getGlobalState } from './GlobalState';
import { ApplicationError } from './errors/application.error';
export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator { export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator {
return Boolean( return Boolean(
@ -190,7 +191,9 @@ export class WorkflowDataProxy {
if (name[0] === '&') { if (name[0] === '&') {
const key = name.slice(1); const key = name.slice(1);
if (!that.siblingParameters.hasOwnProperty(key)) { if (!that.siblingParameters.hasOwnProperty(key)) {
throw new Error(`Could not find sibling parameter "${key}" on node "${nodeName}"`); throw new ApplicationError('Could not find sibling parameter on node', {
extra: { nodeName, parameter: key },
});
} }
returnValue = that.siblingParameters[key]; returnValue = that.siblingParameters[key];
} else { } else {
@ -300,8 +303,8 @@ export class WorkflowDataProxy {
const taskData = that.runExecutionData.resultData.runData[nodeName][runIndex].data!; const taskData = that.runExecutionData.resultData.runData[nodeName][runIndex].data!;
if (!taskData.main?.length || taskData.main[0] === null) { if (!taskData.main?.length || taskData.main[0] === null) {
// throw new Error(`No data found for item-index: "${itemIndex}"`); // throw new ApplicationError('No data found for item-index', { extra: { itemIndex } });
throw new ExpressionError('No data found from "main" input.', { throw new ExpressionError('No data found from `main` input', {
runIndex: that.runIndex, runIndex: that.runIndex,
itemIndex: that.itemIndex, itemIndex: that.itemIndex,
}); });
@ -765,7 +768,7 @@ export class WorkflowDataProxy {
if (itemInput >= taskData.source.length) { if (itemInput >= taskData.source.length) {
// `Could not resolve pairedItem as the defined node input '${itemInput}' does not exist on node '${sourceData!.previousNode}'.` // `Could not resolve pairedItem as the defined node input '${itemInput}' does not exist on node '${sourceData!.previousNode}'.`
// Actual error does not matter as it gets caught below and `null` will be returned // Actual error does not matter as it gets caught below and `null` will be returned
throw new Error('Not found'); throw new ApplicationError('Not found');
} }
return getPairedItem(destinationNodeName, taskData.source[itemInput], item); return getPairedItem(destinationNodeName, taskData.source[itemInput], item);

View file

@ -1,5 +1,6 @@
import FormData from 'form-data'; import FormData from 'form-data';
import type { BinaryFileType, JsonObject } from './Interfaces'; import type { BinaryFileType, JsonObject } from './Interfaces';
import { ApplicationError } from './errors/application.error';
const readStreamClasses = new Set(['ReadStream', 'Readable', 'ReadableStream']); const readStreamClasses = new Set(['ReadStream', 'Readable', 'ReadableStream']);
@ -77,7 +78,7 @@ export const jsonParse = <T>(jsonString: string, options?: JSONParseOptions<T>):
if (options?.fallbackValue !== undefined) { if (options?.fallbackValue !== undefined) {
return options.fallbackValue; return options.fallbackValue;
} else if (options?.errorMessage) { } else if (options?.errorMessage) {
throw new Error(options.errorMessage); throw new ApplicationError(options.errorMessage);
} }
throw error; throw error;

View file

@ -39,6 +39,7 @@ import { WorkflowHooks } from '@/WorkflowHooks';
import * as NodeHelpers from '@/NodeHelpers'; import * as NodeHelpers from '@/NodeHelpers';
import { deepCopy } from '@/utils'; import { deepCopy } from '@/utils';
import { getGlobalState } from '@/GlobalState'; import { getGlobalState } from '@/GlobalState';
import { ApplicationError } from '@/errors/application.error';
export interface INodeTypesObject { export interface INodeTypesObject {
[key: string]: INodeType; [key: string]: INodeType;
@ -55,14 +56,14 @@ export class Credentials extends ICredentials {
getData(): ICredentialDataDecryptedObject { getData(): ICredentialDataDecryptedObject {
if (this.data === undefined) { if (this.data === undefined) {
throw new Error('No data is set so nothing can be returned.'); throw new ApplicationError('No data is set so nothing can be returned');
} }
return JSON.parse(this.data); return JSON.parse(this.data);
} }
getDataToSave(): ICredentialsEncrypted { getDataToSave(): ICredentialsEncrypted {
if (this.data === undefined) { if (this.data === undefined) {
throw new Error('No credentials were set to save.'); throw new ApplicationError('No credentials were set to save');
} }
return { return {
@ -135,13 +136,15 @@ export function getNodeParameter(
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object { ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object {
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) { if (nodeType === undefined) {
throw new Error(`Node type "${node.type}" is not known so can not return parameter value!`); throw new ApplicationError('Node type is unknown so cannot return parameter value', {
tags: { nodeType: node.type },
});
} }
const value = get(node.parameters, parameterName, fallbackValue); const value = get(node.parameters, parameterName, fallbackValue);
if (value === undefined) { if (value === undefined) {
throw new Error(`Could not get parameter "${parameterName}"!`); throw new ApplicationError('Could not get parameter', { extra: { parameterName } });
} }
let returnData; let returnData;
@ -211,12 +214,15 @@ export function getExecuteFunctions(
} }
if (inputData[inputName].length < inputIndex) { if (inputData[inputName].length < inputIndex) {
throw new Error(`Could not get input index "${inputIndex}" of input "${inputName}"!`); throw new ApplicationError('Could not get input index', {
extra: { inputIndex, inputName },
});
} }
if (inputData[inputName][inputIndex] === null) { if (inputData[inputName][inputIndex] === null) {
// return []; throw new ApplicationError('Value of input did not get set', {
throw new Error(`Value "${inputIndex}" of input "${inputName}" did not get set!`); extra: { inputIndex, inputName },
});
} }
return inputData[inputName][inputIndex] as INodeExecutionData[]; return inputData[inputName][inputIndex] as INodeExecutionData[];
@ -387,21 +393,23 @@ export function getExecuteSingleFunctions(
} }
if (inputData[inputName].length < inputIndex) { if (inputData[inputName].length < inputIndex) {
throw new Error(`Could not get input index "${inputIndex}" of input "${inputName}"!`); throw new ApplicationError('Could not get input index', {
extra: { inputIndex, inputName },
});
} }
const allItems = inputData[inputName][inputIndex]; const allItems = inputData[inputName][inputIndex];
if (allItems === null) { if (allItems === null) {
// return []; throw new ApplicationError('Value of input did not get set', {
throw new Error(`Value "${inputIndex}" of input "${inputName}" did not get set!`); extra: { inputIndex, inputName },
});
} }
if (allItems[itemIndex] === null) { if (allItems[itemIndex] === null) {
// return []; throw new ApplicationError('Value of input with item index did not get set', {
throw new Error( extra: { inputIndex, inputName, itemIndex },
`Value "${inputIndex}" of input "${inputName}" with itemIndex "${itemIndex}" did not get set!`, });
);
} }
return allItems[itemIndex]; return allItems[itemIndex];

View file

@ -1,3 +1,4 @@
import { ApplicationError } from '@/errors/application.error';
import { jsonParse, jsonStringify, deepCopy, isObjectEmpty, fileTypeFromMimeType } from '@/utils'; import { jsonParse, jsonStringify, deepCopy, isObjectEmpty, fileTypeFromMimeType } from '@/utils';
describe('isObjectEmpty', () => { describe('isObjectEmpty', () => {
@ -58,7 +59,11 @@ describe('isObjectEmpty', () => {
const { calls } = keySpy.mock; const { calls } = keySpy.mock;
const assertCalls = (count: number) => { const assertCalls = (count: number) => {
if (calls.length !== count) throw new Error(`Object.keys was called ${calls.length} times`); if (calls.length !== count) {
throw new ApplicationError('`Object.keys()` was called an unexpected number of times', {
extra: { times: calls.length },
});
}
}; };
assertCalls(0); assertCalls(0);