fix(core): Apply AI request timeout even without a proxy (#34992)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rodrigo Santos da Silva
2026-07-28 10:54:12 +02:00
committed by GitHub
parent 298a941af6
commit ab3ed79fa5
4 changed files with 155 additions and 5 deletions
@@ -244,6 +244,38 @@ describe('getProxyAgent', () => {
// Since we can't easily re-import, we verify the mock was called with defaults
expect(Agent).toHaveBeenCalled();
});
it('should return an Agent instead of undefined when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => {
process.env.N8N_AI_TIMEOUT_MAX = '120000';
const agent = getProxyAgent('https://api.openai.com/v1');
// DEFAULT_TIMEOUT was captured from the env at module load time (before this test set it),
// so the value here reflects that capture, not '120000' — the module-reset test below
// covers the env value actually being picked up end to end.
expect(Agent).toHaveBeenCalled();
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' }));
expect(ProxyAgent).not.toHaveBeenCalled();
});
it('should honor N8N_AI_TIMEOUT_MAX when there is no proxy and the caller passes no timeout options at all', async () => {
vi.resetModules();
process.env.N8N_AI_TIMEOUT_MAX = '120000';
const undici = await import('undici');
const { getProxyAgent: freshGetProxyAgent } = await import('../../utils/http-proxy-agent.js');
const agent = freshGetProxyAgent('https://api.openai.com/v1');
expect(undici.Agent).toHaveBeenCalledWith({
headersTimeout: 120000,
bodyTimeout: 120000,
});
expect(agent).toEqual({
type: 'Agent',
options: { headersTimeout: 120000, bodyTimeout: 120000 },
});
});
});
describe('secure lookup', () => {
@@ -52,13 +52,16 @@ const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/';
* always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied.
* @param lookup - Optional DNS lookup to pin the resolved address at connect time (e.g. an egress
* filter's secure lookup). When provided (without a proxy) an Agent is always returned.
* @returns An Agent (no proxy with timeout options or a lookup) or ProxyAgent (with proxy) configured with timeouts,
* or undefined if no proxy, timeout options, nor lookup are provided (backward compatible behavior).
* @returns An Agent (no proxy with timeout options, a lookup, or `N8N_AI_TIMEOUT_MAX` set) or ProxyAgent
* (with proxy) configured with timeouts, or undefined if no proxy, timeout options, lookup, nor
* `N8N_AI_TIMEOUT_MAX` are provided/set (backward compatible behavior).
*
* @remarks
* When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured.
* The default undici timeouts (5 minutes) are too short for many AI operations.
* When timeoutOptions are NOT provided, returns undefined if no proxy is configured (backward compatible).
* When timeoutOptions are NOT provided, this still returns an agent if `N8N_AI_TIMEOUT_MAX` is set,
* so the env override isn't silently ignored just because no proxy is configured. Otherwise, returns
* undefined if no proxy is configured (backward compatible).
*/
export function getProxyAgent(
targetUrl?: string,
@@ -82,6 +85,9 @@ export function getProxyAgent(
if (timeoutOptions) {
return new Agent(agentOptions);
}
if (process.env.N8N_AI_TIMEOUT_MAX) {
return new Agent(agentOptions);
}
return undefined;
}
@@ -1,6 +1,6 @@
import { ChatGroq } from '@langchain/groq';
import {
getProxyAgent,
getNodeProxyAgent,
makeN8nLlmFailedAttemptHandler,
N8nLlmTracing,
getConnectionHintNoticeField,
@@ -150,7 +150,7 @@ export class LmChatGroq implements INodeType {
maxTokens: options.maxTokensToSample,
temperature: options.temperature,
callbacks: [new N8nLlmTracing(this)],
httpAgent: getProxyAgent('https://api.groq.com/openai/v1'),
httpAgent: getNodeProxyAgent('https://api.groq.com/openai/v1'),
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
});
@@ -0,0 +1,112 @@
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/unbound-method */
import { ChatGroq } from '@langchain/groq';
import {
getNodeProxyAgent,
getProxyAgent,
makeN8nLlmFailedAttemptHandler,
} from '@n8n/ai-utilities';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import type { Mocked } from 'vitest';
import { LmChatGroq } from '../LmChatGroq.node';
vi.mock('@langchain/groq');
vi.mock('@n8n/ai-utilities');
const MockedChatGroq = vi.mocked(ChatGroq);
const mockedMakeN8nLlmFailedAttemptHandler = vi.mocked(makeN8nLlmFailedAttemptHandler);
const mockedGetNodeProxyAgent = vi.mocked(getNodeProxyAgent);
const mockedGetProxyAgent = vi.mocked(getProxyAgent);
describe('LmChatGroq', () => {
let node: LmChatGroq;
const mockNodeDef: INode = {
id: '1',
name: 'Groq Chat Model',
typeVersion: 1,
type: 'n8n-nodes-langchain.lmChatGroq',
position: [0, 0],
parameters: {},
};
const setupMockContext = () => {
const ctx = createMockExecuteFunction<ISupplyDataFunctions>(
{},
mockNodeDef,
) as Mocked<ISupplyDataFunctions>;
ctx.getCredentials = vi.fn().mockResolvedValue({ apiKey: 'test-groq-key' });
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'llama3-8b-8192';
if (paramName === 'options') return {};
return undefined;
});
mockedMakeN8nLlmFailedAttemptHandler.mockReturnValue(vi.fn());
mockedGetNodeProxyAgent.mockReturnValue(undefined);
return ctx;
};
beforeEach(() => {
node = new LmChatGroq();
vi.clearAllMocks();
});
describe('supplyData', () => {
// groq-sdk hands `httpAgent` straight to node-fetch's `agent` option, which requires a Node
// http(s).Agent — an undici Agent/ProxyAgent (from getProxyAgent) throws a TypeError there.
it('should build the http agent with getNodeProxyAgent, not getProxyAgent', async () => {
const ctx = setupMockContext();
const nodeAgent = { fake: 'node-agent' };
mockedGetNodeProxyAgent.mockReturnValue(nodeAgent as never);
await node.supplyData.call(ctx, 0);
expect(mockedGetNodeProxyAgent).toHaveBeenCalledWith('https://api.groq.com/openai/v1');
expect(mockedGetProxyAgent).not.toHaveBeenCalled();
expect(MockedChatGroq).toHaveBeenCalledWith(
expect.objectContaining({ httpAgent: nodeAgent }),
);
});
it('should create ChatGroq with credentials and node parameters', async () => {
const ctx = setupMockContext();
const result = await node.supplyData.call(ctx, 0);
expect(ctx.getCredentials).toHaveBeenCalledWith('groqApi');
expect(MockedChatGroq).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'test-groq-key',
model: 'llama3-8b-8192',
callbacks: expect.arrayContaining([expect.any(Object)]),
onFailedAttempt: expect.any(Function),
}),
);
expect(result).toEqual({ response: expect.any(Object) });
});
it('should pass options to ChatGroq', async () => {
const ctx = setupMockContext();
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
if (paramName === 'model') return 'llama-3.3-70b-versatile';
if (paramName === 'options') return { maxTokensToSample: 2048, temperature: 0.3 };
return undefined;
});
await node.supplyData.call(ctx, 0);
expect(MockedChatGroq).toHaveBeenCalledWith(
expect.objectContaining({
model: 'llama-3.3-70b-versatile',
maxTokens: 2048,
temperature: 0.3,
}),
);
});
});
});