fix(proxy): keep undici timeouts above the upstream request timeout (#1524)

Sets undici headersTimeout/bodyTimeout to request_timeout+30s so the AbortController is the single authority on upstream request lifetime, preventing premature socket closes on slow self-hosted upstreams. Verified undici v5 ProxyAgent object-signature compat.
This commit is contained in:
kcfang authored and GitHub committed 2026-06-15 21:57:23 -04:00
1 parent 0d856fc0ab
commit 8f9795bce2
5 files changed
+116 -13

No files matched your search

+38 -6
View File
@@ -10,9 +10,16 @@ import {
import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer';
import { isAnthropicPassthroughProfile, resolveOpenAIChatCompletionsUrl } from '../upstream-url';
import { createLogger } from '../../services/logging';
import {
createGlobalFetchProxyDispatcher,
type UpstreamAgentTimeoutOptions,
} from '../../utils/fetch-proxy-setup';
import { pipeWebResponseToNode, readRawBody, writeJson } from './http-helpers';
const REQUEST_TIMEOUT_MS = 600_000;
// Keep undici's per-phase timeouts above the explicit request timeout so the
// AbortController is the single authority on when an upstream request dies.
const UPSTREAM_TIMEOUT_GRACE_MS = 30_000;
const DIRECT_OPENAI_REASONING_CHAT_MODEL = /^(?:gpt-5|o[134])(?:[-.]|$)/;
const logger = createLogger('proxy:openai-compat:messages');
@@ -375,7 +382,7 @@ function buildFetchInit(
signal: AbortSignal,
incomingHeaders: http.IncomingHttpHeaders,
preserveUserAgent: boolean,
insecureDispatcher?: Dispatcher
dispatcher?: Dispatcher
): RequestInit {
const init: RequestInit = {
method: 'POST',
@@ -384,8 +391,8 @@ function buildFetchInit(
signal,
};
if (insecureDispatcher) {
(init as Record<string, unknown>).dispatcher = insecureDispatcher;
if (dispatcher) {
(init as Record<string, unknown>).dispatcher = dispatcher;
}
return init;
@@ -401,6 +408,30 @@ function getRequestTimeoutMs(): number {
return Number.isFinite(parsed) && parsed > 0 ? parsed : REQUEST_TIMEOUT_MS;
}
/**
* undici defaults `headersTimeout`/`bodyTimeout` to 300s, which silently
* undercuts {@link REQUEST_TIMEOUT_MS} (600s) and the
* `CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS` override: slow upstreams (self-hosted
* LLMs with long queue + prefill phases) get their socket closed at 300s with
* a generic connection error instead of the proxy's timeout response. Every
* dispatcher used for upstream fetches must carry these options.
*/
export function buildUpstreamAgentTimeouts(): UpstreamAgentTimeoutOptions {
const ceiling = getRequestTimeoutMs() + UPSTREAM_TIMEOUT_GRACE_MS;
return { headersTimeout: ceiling, bodyTimeout: ceiling };
}
let defaultUpstreamDispatcher: Dispatcher | null = null;
function getDefaultUpstreamDispatcher(): Dispatcher {
if (!defaultUpstreamDispatcher) {
const timeouts = buildUpstreamAgentTimeouts();
// Honor HTTP(S)_PROXY routing when configured; otherwise a plain Agent.
defaultUpstreamDispatcher = createGlobalFetchProxyDispatcher(timeouts) ?? new Agent(timeouts);
}
return defaultUpstreamDispatcher;
}
function formatTimeoutDuration(timeoutMs: number): string {
return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000} seconds` : `${timeoutMs}ms`;
}
@@ -538,19 +569,20 @@ export async function handleProxyMessagesRequest(
const useProfileInsecureTls = upstream.route.profile.insecure === true;
const ephemeralInsecureDispatcher =
useProfileInsecureTls && !useSharedInsecureDispatcher
? new Agent({ connect: { rejectUnauthorized: false } })
? new Agent({ connect: { rejectUnauthorized: false }, ...buildUpstreamAgentTimeouts() })
: undefined;
const insecureTls = useSharedInsecureDispatcher || useProfileInsecureTls;
const dispatcher = useSharedInsecureDispatcher
? insecureDispatcher
: useProfileInsecureTls
? ephemeralInsecureDispatcher
: undefined;
: getDefaultUpstreamDispatcher();
try {
logger.stage('dispatch', 'upstream.dispatch', 'Dispatching upstream fetch', {
profileName: profile.profileName,
routedProfileName: upstream.route.profile.profileName,
insecureTls: dispatcher !== undefined,
insecureTls,
passthrough,
});
const upstreamUrl = resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl, {
+2 -1
View File
@@ -5,6 +5,7 @@ import type { OpenAICompatProfileConfig } from '../profile-router';
import { OPENAI_COMPAT_PROXY_SERVICE_NAME } from '../proxy-daemon-paths';
import { createLogger, withRequestContext } from '../../services/logging';
import {
buildUpstreamAgentTimeouts,
handleProxyMessagesRequest,
handleProxyModelsRequest,
validateIncomingProxyAuth,
@@ -39,7 +40,7 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt
port: options.port,
});
const insecureDispatcher = options.insecure
? new Agent({ connect: { rejectUnauthorized: false } })
? new Agent({ connect: { rejectUnauthorized: false }, ...buildUpstreamAgentTimeouts() })
: undefined;
const server = http.createServer((req, res) => {
const requestId = resolveInboundRequestId(req.headers);