mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 06:18:55 +00:00
fix: time out stalled CLIProxy response streams
This commit is contained in:
@@ -76,6 +76,53 @@ function postJson(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function postJsonText(
|
||||||
|
url: string,
|
||||||
|
body: JsonRecord,
|
||||||
|
timeoutMs = 2_000
|
||||||
|
): Promise<{ statusCode: number; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port,
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let responseBody = '';
|
||||||
|
res.setEncoding('utf8');
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
responseBody += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({ statusCode: res.statusCode ?? 0, body: responseBody });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
req.destroy(new Error(`Timed out waiting for proxy response after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('CodexReasoningProxy extended-context compatibility', () => {
|
describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||||
const cleanupServers: http.Server[] = [];
|
const cleanupServers: http.Server[] = [];
|
||||||
|
|
||||||
@@ -147,6 +194,84 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
|||||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ends streaming responses when upstream stalls after headers', async () => {
|
||||||
|
const upstream = http.createServer((req, res) => {
|
||||||
|
req.resume();
|
||||||
|
req.on('end', () => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
res.write(
|
||||||
|
'event: message_start\n' +
|
||||||
|
'data: {"type":"message_start","message":{"id":"msg_stall","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cleanupServers.push(upstream);
|
||||||
|
|
||||||
|
const upstreamPort = await listenOnRandomPort(upstream);
|
||||||
|
const proxy = new CodexReasoningProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
modelMap: { defaultModel: 'gpt-5.3-codex' },
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyPort = await proxy.start();
|
||||||
|
try {
|
||||||
|
const response = await postJsonText(
|
||||||
|
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||||
|
{
|
||||||
|
model: 'gpt-5.3-codex',
|
||||||
|
messages: [],
|
||||||
|
stream: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toContain('message_start');
|
||||||
|
expect(response.body).toContain('timeout_error');
|
||||||
|
expect(response.body).toContain('Upstream response timed out');
|
||||||
|
} finally {
|
||||||
|
proxy.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 504 when non-stream upstream stalls after headers', async () => {
|
||||||
|
const upstream = http.createServer((req, res) => {
|
||||||
|
req.resume();
|
||||||
|
req.on('end', () => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.flushHeaders();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cleanupServers.push(upstream);
|
||||||
|
|
||||||
|
const upstreamPort = await listenOnRandomPort(upstream);
|
||||||
|
const proxy = new CodexReasoningProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
modelMap: { defaultModel: 'gpt-5.3-codex' },
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyPort = await proxy.start();
|
||||||
|
try {
|
||||||
|
const response = await postJsonText(
|
||||||
|
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||||
|
{
|
||||||
|
model: 'gpt-5.3-codex',
|
||||||
|
messages: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(504);
|
||||||
|
expect(response.body).toContain('Upstream response timed out');
|
||||||
|
} finally {
|
||||||
|
proxy.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('translates codex fast model suffixes into service_tier', async () => {
|
it('translates codex fast model suffixes into service_tier', async () => {
|
||||||
const capturedBodies: JsonRecord[] = [];
|
const capturedBodies: JsonRecord[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
resolveRuntimeCodexFallbackModel,
|
resolveRuntimeCodexFallbackModel,
|
||||||
} from './codex-plan-compatibility';
|
} from './codex-plan-compatibility';
|
||||||
import { getModelMaxLevel } from '../model-catalog';
|
import { getModelMaxLevel } from '../model-catalog';
|
||||||
|
import {
|
||||||
|
attachUpstreamResponseTimeout,
|
||||||
|
writeForwardResponseHead,
|
||||||
|
} from '../proxy/upstream-response-timeout';
|
||||||
|
|
||||||
export type CodexReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
export type CodexReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
export type CodexServiceTier = 'fast';
|
export type CodexServiceTier = 'fast';
|
||||||
@@ -301,7 +305,7 @@ export class CodexReasoningProxy {
|
|||||||
headers: http.IncomingHttpHeaders,
|
headers: http.IncomingHttpHeaders,
|
||||||
responseBody: string
|
responseBody: string
|
||||||
): void {
|
): void {
|
||||||
clientRes.writeHead(statusCode, headers);
|
writeForwardResponseHead(clientRes, statusCode, headers);
|
||||||
clientRes.end(responseBody);
|
clientRes.end(responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,10 +639,38 @@ export class CodexReasoningProxy {
|
|||||||
),
|
),
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
clearResponseTimeout();
|
clearResponseTimeout();
|
||||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
const statusCode = upstreamRes.statusCode || 200;
|
||||||
upstreamRes.pipe(clientRes);
|
let responseStarted = false;
|
||||||
upstreamRes.on('end', () => resolve());
|
const writeResponseHead = () => {
|
||||||
upstreamRes.on('error', reject);
|
if (responseStarted) return;
|
||||||
|
responseStarted = true;
|
||||||
|
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
|
||||||
|
};
|
||||||
|
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||||
|
upstreamReq,
|
||||||
|
upstreamRes,
|
||||||
|
clientRes,
|
||||||
|
timeoutMs: this.config.timeoutMs,
|
||||||
|
onTimeout: () => resolve(),
|
||||||
|
});
|
||||||
|
upstreamRes.on('data', (chunk: Buffer) => {
|
||||||
|
writeResponseHead();
|
||||||
|
const canContinue = clientRes.write(chunk);
|
||||||
|
if (!canContinue) {
|
||||||
|
upstreamRes.pause();
|
||||||
|
clientRes.once('drain', () => upstreamRes.resume());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstreamRes.on('end', () => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
writeResponseHead();
|
||||||
|
clientRes.end();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
upstreamRes.on('error', (error) => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -672,11 +704,38 @@ export class CodexReasoningProxy {
|
|||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
clearResponseTimeout();
|
clearResponseTimeout();
|
||||||
const statusCode = upstreamRes.statusCode || 200;
|
const statusCode = upstreamRes.statusCode || 200;
|
||||||
|
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||||
|
upstreamReq,
|
||||||
|
upstreamRes,
|
||||||
|
clientRes,
|
||||||
|
timeoutMs: this.config.timeoutMs,
|
||||||
|
onTimeout: () => resolve(504),
|
||||||
|
});
|
||||||
if (statusCode >= 200 && statusCode < 300) {
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
clientRes.writeHead(statusCode, upstreamRes.headers);
|
let responseStarted = false;
|
||||||
upstreamRes.pipe(clientRes);
|
const writeResponseHead = () => {
|
||||||
upstreamRes.on('end', () => resolve(statusCode));
|
if (responseStarted) return;
|
||||||
upstreamRes.on('error', reject);
|
responseStarted = true;
|
||||||
|
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
|
||||||
|
};
|
||||||
|
upstreamRes.on('data', (chunk: Buffer) => {
|
||||||
|
writeResponseHead();
|
||||||
|
const canContinue = clientRes.write(chunk);
|
||||||
|
if (!canContinue) {
|
||||||
|
upstreamRes.pause();
|
||||||
|
clientRes.once('drain', () => upstreamRes.resume());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstreamRes.on('end', () => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
writeResponseHead();
|
||||||
|
clientRes.end();
|
||||||
|
resolve(statusCode);
|
||||||
|
});
|
||||||
|
upstreamRes.on('error', (error) => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,6 +753,7 @@ export class CodexReasoningProxy {
|
|||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
});
|
});
|
||||||
upstreamRes.on('end', async () => {
|
upstreamRes.on('end', async () => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
if (responseTooLarge) {
|
if (responseTooLarge) {
|
||||||
reject(new Error('Upstream error response exceeded 10MB limit'));
|
reject(new Error('Upstream error response exceeded 10MB limit'));
|
||||||
return;
|
return;
|
||||||
@@ -756,7 +816,10 @@ export class CodexReasoningProxy {
|
|||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
upstreamRes.on('error', reject);
|
upstreamRes.on('error', (error) => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -825,6 +825,49 @@ describe('ToolSanitizationProxy Integration', () => {
|
|||||||
proxy.stop();
|
proxy.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 504 when raw upstream passthrough stalls after headers', async () => {
|
||||||
|
const upstream = http.createServer((req, res) => {
|
||||||
|
req.resume();
|
||||||
|
req.on('end', () => {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.flushHeaders();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const upstreamPort = await new Promise<number>((resolve, reject) => {
|
||||||
|
upstream.once('error', reject);
|
||||||
|
upstream.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = upstream.address();
|
||||||
|
if (typeof address !== 'object' || !address) {
|
||||||
|
reject(new Error('Failed to resolve upstream port'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxy = new ToolSanitizationProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
const port = await proxy.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
||||||
|
const text = await Promise.race([
|
||||||
|
response.text(),
|
||||||
|
new Promise<string>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 2_000)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(response.status).toBe(504);
|
||||||
|
expect(text).toContain('Upstream response timed out');
|
||||||
|
} finally {
|
||||||
|
proxy.stop();
|
||||||
|
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Multiple Tools Sanitization', () => {
|
describe('Multiple Tools Sanitization', () => {
|
||||||
@@ -1086,5 +1129,65 @@ describe('ToolSanitizationProxy Integration', () => {
|
|||||||
proxy.stop();
|
proxy.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ends stalled streaming responses after upstream headers', async () => {
|
||||||
|
const upstream = http.createServer((req, res) => {
|
||||||
|
req.resume();
|
||||||
|
req.on('end', () => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
res.write(
|
||||||
|
'event: message_start\n' +
|
||||||
|
'data: {"type":"message_start","message":{"id":"msg_stall","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const upstreamPort = await new Promise<number>((resolve, reject) => {
|
||||||
|
upstream.once('error', reject);
|
||||||
|
upstream.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = upstream.address();
|
||||||
|
if (typeof address !== 'object' || !address) {
|
||||||
|
reject(new Error('Failed to resolve upstream port'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxy = new ToolSanitizationProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
const port = await proxy.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
stream: true,
|
||||||
|
tools: [{ name: 'valid_tool' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await Promise.race([
|
||||||
|
response.text(),
|
||||||
|
new Promise<string>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('Timed out waiting for proxy response')), 2_000)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(text).toContain('message_start');
|
||||||
|
expect(text).toContain('timeout_error');
|
||||||
|
expect(text).toContain('Upstream response timed out');
|
||||||
|
} finally {
|
||||||
|
proxy.stop();
|
||||||
|
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ import { getModelMaxLevel } from '../model-catalog';
|
|||||||
|
|
||||||
import { createLogger } from '../../services/logging';
|
import { createLogger } from '../../services/logging';
|
||||||
import { getCcsDir } from '../../config/config-loader-facade';
|
import { getCcsDir } from '../../config/config-loader-facade';
|
||||||
|
import {
|
||||||
|
attachUpstreamResponseTimeout,
|
||||||
|
writeForwardResponseHead,
|
||||||
|
} from './upstream-response-timeout';
|
||||||
|
|
||||||
export interface ToolSanitizationProxyConfig {
|
export interface ToolSanitizationProxyConfig {
|
||||||
/** Upstream CLIProxy URL */
|
/** Upstream CLIProxy URL */
|
||||||
@@ -521,10 +525,38 @@ export class ToolSanitizationProxy {
|
|||||||
),
|
),
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
clearResponseTimeout();
|
clearResponseTimeout();
|
||||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
const statusCode = upstreamRes.statusCode || 200;
|
||||||
upstreamRes.pipe(clientRes);
|
let responseStarted = false;
|
||||||
upstreamRes.on('end', () => resolve());
|
const writeResponseHead = () => {
|
||||||
upstreamRes.on('error', reject);
|
if (responseStarted) return;
|
||||||
|
responseStarted = true;
|
||||||
|
writeForwardResponseHead(clientRes, statusCode, upstreamRes.headers);
|
||||||
|
};
|
||||||
|
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||||
|
upstreamReq,
|
||||||
|
upstreamRes,
|
||||||
|
clientRes,
|
||||||
|
timeoutMs: this.config.timeoutMs,
|
||||||
|
onTimeout: () => resolve(),
|
||||||
|
});
|
||||||
|
upstreamRes.on('data', (chunk: Buffer) => {
|
||||||
|
writeResponseHead();
|
||||||
|
const canContinue = clientRes.write(chunk);
|
||||||
|
if (!canContinue) {
|
||||||
|
upstreamRes.pause();
|
||||||
|
clientRes.once('drain', () => upstreamRes.resume());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstreamRes.on('end', () => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
writeResponseHead();
|
||||||
|
clientRes.end();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
upstreamRes.on('error', (error) => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -560,10 +592,18 @@ export class ToolSanitizationProxy {
|
|||||||
),
|
),
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
clearResponseTimeout();
|
clearResponseTimeout();
|
||||||
|
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||||
|
upstreamReq,
|
||||||
|
upstreamRes,
|
||||||
|
clientRes,
|
||||||
|
timeoutMs: this.config.timeoutMs,
|
||||||
|
onTimeout: () => resolve(),
|
||||||
|
});
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
upstreamRes.on('end', () => {
|
upstreamRes.on('end', () => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
try {
|
try {
|
||||||
const responseBody = Buffer.concat(chunks).toString('utf8');
|
const responseBody = Buffer.concat(chunks).toString('utf8');
|
||||||
const contentType = upstreamRes.headers['content-type'] || '';
|
const contentType = upstreamRes.headers['content-type'] || '';
|
||||||
@@ -598,7 +638,10 @@ export class ToolSanitizationProxy {
|
|||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
upstreamRes.on('error', reject);
|
upstreamRes.on('error', (error) => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -636,7 +679,14 @@ export class ToolSanitizationProxy {
|
|||||||
),
|
),
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
clearResponseTimeout();
|
clearResponseTimeout();
|
||||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
const clearUpstreamResponseTimeout = attachUpstreamResponseTimeout({
|
||||||
|
upstreamReq,
|
||||||
|
upstreamRes,
|
||||||
|
clientRes,
|
||||||
|
timeoutMs: this.config.timeoutMs,
|
||||||
|
onTimeout: () => resolve(),
|
||||||
|
});
|
||||||
|
writeForwardResponseHead(clientRes, upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||||
|
|
||||||
// Track upstream SSE lifecycle events (guards against empty proxy responses)
|
// Track upstream SSE lifecycle events (guards against empty proxy responses)
|
||||||
const lifecycle = {
|
const lifecycle = {
|
||||||
@@ -673,6 +723,7 @@ export class ToolSanitizationProxy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
upstreamRes.on('end', () => {
|
upstreamRes.on('end', () => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
try {
|
try {
|
||||||
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
|
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
|
||||||
this.writeLog(
|
this.writeLog(
|
||||||
@@ -693,7 +744,10 @@ export class ToolSanitizationProxy {
|
|||||||
}
|
}
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
upstreamRes.on('error', reject);
|
upstreamRes.on('error', (error) => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +773,7 @@ export class ToolSanitizationProxy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
upstreamRes.on('end', () => {
|
upstreamRes.on('end', () => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
try {
|
try {
|
||||||
// Process any remaining buffer
|
// Process any remaining buffer
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
@@ -749,7 +804,10 @@ export class ToolSanitizationProxy {
|
|||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
upstreamRes.on('error', reject);
|
upstreamRes.on('error', (error) => {
|
||||||
|
clearUpstreamResponseTimeout();
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import * as http from 'http';
|
||||||
|
|
||||||
|
export const UPSTREAM_RESPONSE_TIMEOUT_MESSAGE =
|
||||||
|
'Upstream response timed out while streaming response body';
|
||||||
|
|
||||||
|
export function buildTimeoutSafeResponseHeaders(
|
||||||
|
headers: http.IncomingHttpHeaders
|
||||||
|
): http.OutgoingHttpHeaders {
|
||||||
|
const safeHeaders: http.OutgoingHttpHeaders = {};
|
||||||
|
for (const [name, value] of Object.entries(headers)) {
|
||||||
|
const normalized = name.toLowerCase();
|
||||||
|
if (normalized === 'content-length' || normalized === 'transfer-encoding') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
safeHeaders[name] = value;
|
||||||
|
}
|
||||||
|
return safeHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeForwardResponseHead(
|
||||||
|
clientRes: http.ServerResponse,
|
||||||
|
statusCode: number,
|
||||||
|
headers: http.IncomingHttpHeaders
|
||||||
|
): void {
|
||||||
|
if (clientRes.headersSent) return;
|
||||||
|
clientRes.writeHead(statusCode, buildTimeoutSafeResponseHeaders(headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTimeoutResponse(
|
||||||
|
clientRes: http.ServerResponse,
|
||||||
|
headers: http.IncomingHttpHeaders,
|
||||||
|
message = UPSTREAM_RESPONSE_TIMEOUT_MESSAGE
|
||||||
|
): void {
|
||||||
|
if (clientRes.destroyed || clientRes.writableEnded) return;
|
||||||
|
|
||||||
|
const contentType = String(headers['content-type'] ?? '').toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (contentType.includes('text/event-stream')) {
|
||||||
|
const payload = {
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'timeout_error',
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
clientRes.write(`event: error\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||||
|
} else if (!clientRes.headersSent) {
|
||||||
|
clientRes.writeHead(504, { 'Content-Type': 'application/json' });
|
||||||
|
clientRes.write(JSON.stringify({ error: message }));
|
||||||
|
} else {
|
||||||
|
clientRes.write(`\n${JSON.stringify({ error: message })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clientRes.end();
|
||||||
|
} catch {
|
||||||
|
// Client may have disconnected while the upstream response was stalled.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachUpstreamResponseTimeout(options: {
|
||||||
|
upstreamReq: http.ClientRequest;
|
||||||
|
upstreamRes: http.IncomingMessage;
|
||||||
|
clientRes: http.ServerResponse;
|
||||||
|
timeoutMs: number;
|
||||||
|
onTimeout?: (error: Error) => void;
|
||||||
|
}): () => void {
|
||||||
|
const { upstreamReq, upstreamRes, clientRes, timeoutMs, onTimeout } = options;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
settled = true;
|
||||||
|
upstreamRes.setTimeout(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
upstreamRes.setTimeout(timeoutMs, () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
const error = new Error(UPSTREAM_RESPONSE_TIMEOUT_MESSAGE);
|
||||||
|
writeTimeoutResponse(clientRes, upstreamRes.headers, error.message);
|
||||||
|
onTimeout?.(error);
|
||||||
|
upstreamRes.destroy(error);
|
||||||
|
upstreamReq.destroy(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return clear;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user