diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/langchainParserError.test.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/langchainParserError.test.ts index d2e24528a3d..ccd70ed9b60 100644 --- a/packages/@n8n/nodes-langchain/utils/output_parsers/langchainParserError.test.ts +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/langchainParserError.test.ts @@ -116,6 +116,65 @@ describe('wrapLangChainParserError', () => { expect((wrapped as NodeOperationError).cause).toBe(original); }); + it('shows the root cause of a nested connection error in the description', () => { + const socketError = Object.assign(new Error('write EPROTO bad signature'), { + code: 'EPROTO', + }); + const fetchError = new TypeError('fetch failed', { cause: socketError }); + class APIConnectionError extends Error {} + const original = new APIConnectionError('Connection error.', { cause: fetchError }); + + const wrapped = wrapLangChainParserError(original, node, undefined, { + enrichNonParserErrors: true, + }); + + expect(wrapped.message).toBe('Connection error.'); + expect((wrapped as NodeOperationError).description).toBe( + 'Original error: APIConnectionError. Cause: write EPROTO bad signature', + ); + expect((wrapped as NodeOperationError).cause).toBe(original); + }); + + it('adds the error code to the root cause when the message does not contain it', () => { + const socketError = Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }); + const original = new Error('Connection error.', { cause: socketError }); + + const wrapped = wrapLangChainParserError(original, node, undefined, { + enrichNonParserErrors: true, + }); + + expect((wrapped as NodeOperationError).description).toBe( + 'Original error: Error. Cause: ECONNRESET: socket hang up', + ); + }); + + it('shows the error code when the root cause has no message', () => { + // Node 20+ reports a failed connection attempt as an AggregateError without a message + const socketError = Object.assign(new AggregateError([], ''), { code: 'ECONNREFUSED' }); + const original = new Error('Connection error.', { cause: socketError }); + + const wrapped = wrapLangChainParserError(original, node, undefined, { + enrichNonParserErrors: true, + }); + + expect((wrapped as NodeOperationError).description).toBe( + 'Original error: Error. Cause: ECONNREFUSED', + ); + }); + + it('removes URL query strings from the root cause', () => { + const socketError = new Error('request to https://api.example.com/v1?key=secret123 failed'); + const original = new Error('Connection error.', { cause: socketError }); + + const wrapped = wrapLangChainParserError(original, node, undefined, { + enrichNonParserErrors: true, + }); + + const { description } = wrapped as NodeOperationError; + expect(description).toContain('https://api.example.com/v1 failed'); + expect(description).not.toContain('secret123'); + }); + it('uses constructor.name in the description for a custom subclass that inherits name "Error"', () => { class CustomAgentError extends Error {} const original = new CustomAgentError('boom'); diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/langchainParserError.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/langchainParserError.ts index b7f2b552c39..945e915f613 100644 --- a/packages/@n8n/nodes-langchain/utils/output_parsers/langchainParserError.ts +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/langchainParserError.ts @@ -1,4 +1,5 @@ import { OutputParserException } from '@langchain/core/output_parsers'; +import { sanitizeErrorDetail } from '@n8n/utils/redaction/sanitize-error-detail'; import type { INode } from 'n8n-workflow'; import { BaseError, NodeOperationError } from 'n8n-workflow'; @@ -10,6 +11,8 @@ const LANGCHAIN_PARSER_ERROR_MESSAGES = ['Failed to parse. Text:', 'Unable to pa const AGENT_FAILURE_FALLBACK_MESSAGE = 'Agent execution failed'; +const MAX_CAUSE_DETAIL_LENGTH = 500; + function getErrorProperty(error: unknown, property: 'message' | 'name' | 'description') { if (typeof error !== 'object' || error === null || !(property in error)) return undefined; @@ -46,6 +49,11 @@ export function isLangChainParserError(error: unknown) { * underlying error class. */ export function getFailureType(error: unknown): string { + const root = getRootCause(error); + return resolveErrorName(root) ?? (root instanceof Error ? 'Error' : typeof root); +} + +function getRootCause(error: unknown): unknown { let current: unknown = error; const seen = new Set(); while (current instanceof Error && current.cause instanceof Error) { @@ -53,7 +61,7 @@ export function getFailureType(error: unknown): string { if (seen.has(current.cause)) break; current = current.cause; } - return resolveErrorName(current) ?? (current instanceof Error ? 'Error' : typeof current); + return current; } function isUselessMessage(message: string | undefined, className: string | undefined): boolean { @@ -62,6 +70,26 @@ function isUselessMessage(message: string | undefined, className: string | undef return message === 'Error'; } +/** + * Names the original error class and, when there is one, the deepest error in the + * `cause` chain, for example the socket or TLS error under an SDK "Connection error.". + */ +function describeOriginalError(error: unknown, className: string | undefined) { + const original = className ? `Original error: ${className}` : undefined; + const root = getRootCause(error); + if (root === error || !(root instanceof Error)) return original; + + const code = 'code' in root && typeof root.code === 'string' ? root.code : undefined; + const detail = + code && !root.message.includes(code) + ? [code, root.message].filter(Boolean).join(': ') + : root.message; + if (!detail) return original; + // The root message can come from any library, so remove secrets and URL queries. + const cause = `Cause: ${sanitizeErrorDetail(detail, MAX_CAUSE_DETAIL_LENGTH)}`; + return original ? `${original}. ${cause}` : cause; +} + function wrapAsNodeOperationError( error: unknown, node: INode, @@ -77,9 +105,7 @@ function wrapAsNodeOperationError( const options = { message, - description: - getErrorProperty(error, 'description') ?? - (className ? `Original error: ${className}` : undefined), + description: getErrorProperty(error, 'description') ?? describeOriginalError(error, className), level: 'error' as const, ...(itemIndex !== undefined ? { itemIndex } : {}), };