/* eslint-disable @typescript-eslint/naming-convention */ import { parseString } from 'xml2js'; import { removeCircularRefs, isTraversableObject } from './utils'; import type { IDataObject, INode, IStatusCodeMessages, JsonObject } from './Interfaces'; /** * Top-level properties where an error message can be found in an API response. */ const ERROR_MESSAGE_PROPERTIES = [ 'cause', 'error', 'message', 'Message', 'msg', 'messages', 'description', 'reason', 'detail', 'details', 'errors', 'errorMessage', 'errorMessages', 'ErrorMessage', 'error_message', '_error_message', 'errorDescription', 'error_description', 'error_summary', 'title', 'text', 'field', 'err', 'type', ]; /** * Top-level properties where an HTTP error code can be found in an API response. */ const ERROR_STATUS_PROPERTIES = [ 'statusCode', 'status', 'code', 'status_code', 'errorCode', 'error_code', ]; /** * Properties where a nested object can be found in an API response. */ const ERROR_NESTING_PROPERTIES = ['error', 'err', 'response', 'body', 'data']; /** * Descriptive messages for common errors. */ const COMMON_ERRORS: IDataObject = { // nodeJS errors ECONNREFUSED: 'The service refused the connection - perhaps it is offline', ECONNRESET: 'The connection to the server wes closed unexpectedly, perhaps it is offline. You can retry request immidiately or wait and retry later.', ENOTFOUND: 'The connection cannot be established, this usually occurs due to an incorrect host(domain) value', ETIMEDOUT: "The connection timed out, consider setting 'Retry on Fail' option in the node settings", ERRADDRINUSE: 'The port is already occupied by some other application, if possible change the port or kill the application that is using it', EADDRNOTAVAIL: 'The address is not available, ensure that you have the right IP address', ECONNABORTED: 'The connection was aborted, perhaps the server is offline', EHOSTUNREACH: 'The host is unreachable, perhaps the server is offline', EAI_AGAIN: 'The DNS server returned an error, perhaps the server is offline', ENOENT: 'The file or directory does not exist', EISDIR: 'The file path expected but a given path is a directory', ENOTDIR: 'The directory path expected but a given path is a file', EACCES: 'Forbidden by access permissions, make sure you have the right permissions', EEXIST: 'The file or directory already exists', EPERM: 'Operation not permitted, make sure you have the right permissions', // other errors GETADDRINFO: 'The server closed the connection unexpectedly', }; /** * Descriptive messages for common HTTP status codes * this is used by NodeApiError class */ const STATUS_CODE_MESSAGES: IStatusCodeMessages = { '4XX': 'Your request is invalid or could not be processed by the service', '400': 'Bad request - please check your parameters', '401': 'Authorization failed - please check your credentials', '402': 'Payment required - perhaps check your payment details?', '403': 'Forbidden - perhaps check your credentials?', '404': 'The resource you are requesting could not be found', '405': 'Method not allowed - please check you are using the right HTTP method', '429': 'The service is receiving too many requests from you', '5XX': 'The service failed to process your request', '500': 'The service was not able to process your request', '502': 'Bad gateway - the service failed to handle your request', '503': 'Service unavailable - try again later or consider setting this node to retry automatically (in the node settings)', '504': 'Gateway timed out - perhaps try again later?', }; const UNKNOWN_ERROR_MESSAGE = 'UNKNOWN ERROR - check the detailed error for more information'; const UNKNOWN_ERROR_MESSAGE_CRED = 'UNKNOWN ERROR'; export type Severity = 'warning' | 'error'; interface ExecutionBaseErrorOptions { cause?: Error | JsonObject; } interface NodeOperationErrorOptions { message?: string; description?: string; runIndex?: number; itemIndex?: number; severity?: Severity; messageMapping?: { [key: string]: string }; // allows to pass custom mapping for error messages scoped to a node } interface NodeApiErrorOptions extends NodeOperationErrorOptions { message?: string; httpCode?: string; parseXml?: boolean; } export abstract class ExecutionBaseError extends Error { description: string | null | undefined; cause: Error | JsonObject | undefined; timestamp: number; context: IDataObject = {}; lineNumber: number | undefined; severity: Severity = 'error'; constructor(message: string, { cause }: ExecutionBaseErrorOptions) { const options = cause instanceof Error ? { cause } : {}; super(message, options); this.name = this.constructor.name; this.timestamp = Date.now(); if (cause instanceof ExecutionBaseError) { this.context = cause.context; } else if (cause && !(cause instanceof Error)) { this.cause = cause; } } // eslint-disable-next-line @typescript-eslint/no-explicit-any toJSON?(): any { return { message: this.message, lineNumber: this.lineNumber, timestamp: this.timestamp, name: this.name, description: this.description, context: this.context, cause: this.cause, }; } } /** * Base class for specific NodeError-types, with functionality for finding * a value recursively inside an error object. */ export abstract class NodeError extends ExecutionBaseError { node: INode; constructor(node: INode, error: Error | JsonObject) { const message = error instanceof Error ? error.message : ''; super(message, { cause: error }); this.node = node; } /** * Finds property through exploration based on potential keys and traversal keys. * Depth-first approach. * * This method iterates over `potentialKeys` and, if the value at the key is a * truthy value, the type of the value is checked: * (1) if a string or number, the value is returned as a string; or * (2) if an array, * its string or number elements are collected as a long string, * its object elements are traversed recursively (restart this function * with each object as a starting point), or * (3) if it is an object, it traverses the object and nested ones recursively * based on the `potentialKeys` and returns a string if found. * * If nothing found via `potentialKeys` this method iterates over `traversalKeys` and * if the value at the key is a traversable object, it restarts with the object as the * new starting point (recursion). * If nothing found for any of the `traversalKeys`, exploration continues with remaining * `traversalKeys`. * * Otherwise, if all the paths have been exhausted and no value is eligible, `null` is * returned. * */ protected findProperty( jsonError: JsonObject, potentialKeys: string[], traversalKeys: string[] = [], ): string | null { for (const key of potentialKeys) { const value = jsonError[key]; if (value) { if (typeof value === 'string') return value; if (typeof value === 'number') return value.toString(); if (Array.isArray(value)) { const resolvedErrors: string[] = value // eslint-disable-next-line @typescript-eslint/no-shadow .map((jsonError) => { if (typeof jsonError === 'string') return jsonError; if (typeof jsonError === 'number') return jsonError.toString(); if (isTraversableObject(jsonError)) { return this.findProperty(jsonError, potentialKeys); } return null; }) .filter((errorValue): errorValue is string => errorValue !== null); if (resolvedErrors.length === 0) { return null; } return resolvedErrors.join(' | '); } if (isTraversableObject(value)) { const property = this.findProperty(value, potentialKeys); if (property) { return property; } } } } for (const key of traversalKeys) { const value = jsonError[key]; if (isTraversableObject(value)) { const property = this.findProperty(value, potentialKeys, traversalKeys); if (property) { return property; } } } return null; } /** * Set descriptive error message if code is provided or if message contains any of the common errors, * update description to include original message plus the description */ protected setDescriptiveErrorMessage( message: string, description: string | undefined | null, code?: string | null, messageMapping?: { [key: string]: string }, ) { let newMessage = message; let newDescription = description as string; if (messageMapping) { for (const [mapKey, mapMessage] of Object.entries(messageMapping)) { if ((message || '').toUpperCase().includes(mapKey.toUpperCase())) { newMessage = mapMessage; newDescription = this.updateDescription(message, description); break; } } if (newMessage !== message) { return [newMessage, newDescription]; } } // if code is provided and it is in the list of common errors set the message and return early if (code && COMMON_ERRORS[code.toUpperCase()]) { newMessage = COMMON_ERRORS[code] as string; newDescription = this.updateDescription(message, description); return [newMessage, newDescription]; } // check if message contains any of the common errors and set the message and description for (const [errorCode, errorDescriptiveMessage] of Object.entries(COMMON_ERRORS)) { if ((message || '').toUpperCase().includes(errorCode.toUpperCase())) { newMessage = errorDescriptiveMessage as string; newDescription = this.updateDescription(message, description); break; } } return [newMessage, newDescription]; } protected updateDescription(message: string, description: string | undefined | null) { return `${message}${description ? ` - ${description}` : ''}`; } } /** * Class for instantiating an operational error, e.g. an invalid credentials error. */ export class NodeOperationError extends NodeError { lineNumber: number | undefined; constructor(node: INode, error: Error | string, options: NodeOperationErrorOptions = {}) { if (typeof error === 'string') { error = new Error(error); } super(node, error); if (options.message) this.message = options.message; if (options.severity) this.severity = options.severity; this.description = options.description; this.context.runIndex = options.runIndex; this.context.itemIndex = options.itemIndex; if (this.message === this.description) { this.description = undefined; } [this.message, this.description] = this.setDescriptiveErrorMessage( this.message, this.description, undefined, options.messageMapping, ); } } /** * Class for instantiating an error in an API response, e.g. a 404 Not Found response, * with an HTTP error code, an error message and a description. */ export class NodeApiError extends NodeError { httpCode: string | null; constructor( node: INode, error: JsonObject, { message, description, httpCode, parseXml, runIndex, itemIndex, severity, messageMapping, }: NodeApiErrorOptions = {}, ) { super(node, error); if (severity) this.severity = severity; else if (httpCode?.charAt(0) !== '5') this.severity = 'warning'; // only for request library error if (error.error) { removeCircularRefs(error.error as JsonObject); } // if not description provided, try to find it in the error object // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (!description && (error.description || (error?.reason as IDataObject)?.description)) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing this.description = (error.description || (error?.reason as IDataObject)?.description) as string; } // if not message provided, try to find it in the error object or set description as message // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (!message && (error.message || (error?.reason as IDataObject)?.message || description)) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing this.message = (error.message || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (error?.reason as IDataObject)?.message || description) as string; } // if it's an error generated by axios // look for descriptions in the response object if (error.reason) { const reason: IDataObject = error.reason as unknown as IDataObject; if (reason.isAxiosError && reason.response) { error = reason.response as JsonObject; } } // set http code of this error if (httpCode) { this.httpCode = httpCode; } else { this.httpCode = this.findProperty(error, ERROR_STATUS_PROPERTIES, ERROR_NESTING_PROPERTIES) ?? null; } // set description of this error if (description) { this.description = description; } if (!this.description) { if (parseXml) { this.setDescriptionFromXml(error.error as string); } else { this.description = this.findProperty( error, ERROR_MESSAGE_PROPERTIES, ERROR_NESTING_PROPERTIES, ); } } // set message if provided or set default message based on http code if (message) { this.message = message; } else { this.setDefaultStatusCodeMessage(); } // if message and description are the same, unset redundant description if (this.message === this.description) { this.description = undefined; } // if message contain common error code set descriptive message and update description [this.message, this.description] = this.setDescriptiveErrorMessage( this.message, this.description, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing this.httpCode || (error?.code as string) || ((error?.reason as JsonObject)?.code as string) || undefined, messageMapping, ); if (runIndex !== undefined) this.context.runIndex = runIndex; if (itemIndex !== undefined) this.context.itemIndex = itemIndex; } private setDescriptionFromXml(xml: string) { parseString(xml, { explicitArray: false }, (_, result) => { if (!result) return; // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const topLevelKey = Object.keys(result)[0]; this.description = this.findProperty( // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access result[topLevelKey], ERROR_MESSAGE_PROPERTIES, ['Error'].concat(ERROR_NESTING_PROPERTIES), ); }); } /** * Set the error's message based on the HTTP status code. */ private setDefaultStatusCodeMessage() { // Set generic error message for 502 Bad Gateway if (!this.httpCode && this.message && this.message.toLowerCase().includes('bad gateway')) { this.httpCode = '502'; } if (!this.httpCode) { this.httpCode = null; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing this.message = this.message || this.description || UNKNOWN_ERROR_MESSAGE; return; } if (STATUS_CODE_MESSAGES[this.httpCode]) { this.description = this.updateDescription(this.message, this.description); this.message = STATUS_CODE_MESSAGES[this.httpCode]; return; } switch (this.httpCode.charAt(0)) { case '4': this.description = this.updateDescription(this.message, this.description); this.message = STATUS_CODE_MESSAGES['4XX']; break; case '5': this.description = this.updateDescription(this.message, this.description); this.message = STATUS_CODE_MESSAGES['5XX']; break; default: // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing this.message = this.message || this.description || UNKNOWN_ERROR_MESSAGE; } if (this.node.type === 'n8n-nodes-base.noOp' && this.message === UNKNOWN_ERROR_MESSAGE) { this.message = `${UNKNOWN_ERROR_MESSAGE_CRED} - ${this.httpCode}`; } } } export class NodeSSLError extends ExecutionBaseError { constructor(cause: Error) { super("SSL Issue: consider using the 'Ignore SSL issues' option", { cause }); } }