fix(core): Show the root cause of model errors in AI Agent and Basic LLM Chain (#41388)

This commit is contained in:
Benjamin Schroth 2026-10-21 13:51:13 +00:00 committed by GitHub
parent 0b349760f8
commit 9ceb3945e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 89 additions and 4 deletions

View File

@ -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');

View File

@ -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<Error>();
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 } : {}),
};