58dad6e96d
新增Supervisor最终回复瞬时故障、持久退避与Runner强杀真实验收入口 强化故障代理异步选择器、停止竞态和请求隐私回归 修复并行Agent文件写改删的短等待写锁与绝对路径脱敏 补充终态sidecar等待、并发写锁测试和六轮真实Provider证据 同步Runtime方案、实施计划与项目共享记忆文档
826 lines
25 KiB
TypeScript
826 lines
25 KiB
TypeScript
import http, {
|
|
type IncomingHttpHeaders,
|
|
type IncomingMessage,
|
|
type Server,
|
|
type ServerResponse,
|
|
} from 'node:http';
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
startLlmTransientFaultProxy,
|
|
withLoopbackNoProxy,
|
|
} from '../scripts/llm-transient-fault-proxy.mjs';
|
|
|
|
interface ProxyStats {
|
|
requestCount: number;
|
|
faultInjectedCount: number;
|
|
heldRequestCount: number;
|
|
forwardedRequestCount: number;
|
|
forwardingReleased: boolean;
|
|
stopped: boolean;
|
|
}
|
|
|
|
interface ProxyHandle {
|
|
url: string;
|
|
baseUrl: string;
|
|
port: number;
|
|
stats: ProxyStats;
|
|
getStats(): ProxyStats;
|
|
getRequestLog(): ReadonlyArray<{
|
|
sequence: number;
|
|
acceptedAtMs: number;
|
|
faultInjectedAtMs: number | null;
|
|
heldAtMs: number | null;
|
|
forwardingStartedAtMs: number | null;
|
|
}>;
|
|
waitForFault(timeoutMs?: number): Promise<ProxyStats>;
|
|
waitForHeldRequest(timeoutMs?: number): Promise<ProxyStats>;
|
|
releaseForwarding(): ProxyStats;
|
|
stop(): Promise<void>;
|
|
}
|
|
|
|
interface CapturedRequest {
|
|
method: string | undefined;
|
|
url: string | undefined;
|
|
headers: IncomingHttpHeaders;
|
|
body: string;
|
|
}
|
|
|
|
interface HttpResult {
|
|
statusCode: number | undefined;
|
|
headers: IncomingHttpHeaders;
|
|
body: string;
|
|
}
|
|
|
|
const proxies = new Set<ProxyHandle>();
|
|
const servers = new Set<Server>();
|
|
const TEST_UPSTREAM_FALLBACK_PORTS = Array.from(
|
|
{ length: 128 },
|
|
(_, index) => 62_300 + index,
|
|
);
|
|
|
|
afterEach(async () => {
|
|
await Promise.allSettled([...proxies].map((proxy) => proxy.stop()));
|
|
proxies.clear();
|
|
for (const server of servers) server.closeAllConnections?.();
|
|
await Promise.allSettled([...servers].map((server) => closeServer(server)));
|
|
servers.clear();
|
|
});
|
|
|
|
function deferred<T = void>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function listen(
|
|
server: Server,
|
|
{ host, port }: { host: string; port: number },
|
|
) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const cleanup = () => {
|
|
server.off('error', onError);
|
|
server.off('listening', onListening);
|
|
};
|
|
const onError = (error: Error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
const onListening = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
server.once('error', onError);
|
|
server.once('listening', onListening);
|
|
server.listen({ host, port, exclusive: true });
|
|
});
|
|
}
|
|
|
|
function closeServer(server: Server) {
|
|
if (!server.listening) return Promise.resolve();
|
|
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
function errorCode(error: unknown) {
|
|
return error && typeof error === 'object' && 'code' in error
|
|
? error.code
|
|
: undefined;
|
|
}
|
|
|
|
async function listenTestServer(server: Server) {
|
|
try {
|
|
await listen(server, { host: '127.0.0.1', port: 0 });
|
|
return;
|
|
} catch (error) {
|
|
if (errorCode(error) !== 'EADDRINUSE') throw error;
|
|
}
|
|
|
|
for (const port of TEST_UPSTREAM_FALLBACK_PORTS) {
|
|
try {
|
|
await listen(server, { host: '127.0.0.1', port });
|
|
return;
|
|
} catch (error) {
|
|
if (errorCode(error) !== 'EADDRINUSE') throw error;
|
|
}
|
|
}
|
|
throw new Error('test upstream fallback port pool is exhausted');
|
|
}
|
|
|
|
async function startServer(
|
|
handler: (
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
) => void | Promise<void>,
|
|
) {
|
|
const server = http.createServer((request, response) => {
|
|
void Promise.resolve(handler(request, response)).catch(() => {
|
|
response.destroy();
|
|
});
|
|
});
|
|
await listenTestServer(server);
|
|
servers.add(server);
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('test server did not expose a TCP address');
|
|
}
|
|
return { server, url: `http://127.0.0.1:${address.port}` };
|
|
}
|
|
|
|
async function startProxy(options: Record<string, unknown>) {
|
|
const proxy = (await startLlmTransientFaultProxy(options)) as ProxyHandle;
|
|
proxies.add(proxy);
|
|
return proxy;
|
|
}
|
|
|
|
function readBody(request: IncomingMessage) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
request.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
request.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
request.once('error', reject);
|
|
});
|
|
}
|
|
|
|
function request(
|
|
baseUrl: string,
|
|
{
|
|
method = 'POST',
|
|
path = '/',
|
|
headers = {},
|
|
body = '',
|
|
onChunk,
|
|
}: {
|
|
method?: string;
|
|
path?: string;
|
|
headers?: Record<string, string>;
|
|
body?: string;
|
|
onChunk?: (chunk: string) => void;
|
|
} = {},
|
|
) {
|
|
const base = new URL(baseUrl);
|
|
return new Promise<HttpResult>((resolve, reject) => {
|
|
const outgoing = http.request(
|
|
{
|
|
hostname: base.hostname,
|
|
port: base.port,
|
|
method,
|
|
path,
|
|
headers,
|
|
agent: false,
|
|
},
|
|
(response) => {
|
|
const chunks: Buffer[] = [];
|
|
response.on('data', (chunk) => {
|
|
const buffer = Buffer.from(chunk);
|
|
chunks.push(buffer);
|
|
onChunk?.(buffer.toString('utf8'));
|
|
});
|
|
response.once('end', () => {
|
|
resolve({
|
|
statusCode: response.statusCode,
|
|
headers: response.headers,
|
|
body: Buffer.concat(chunks).toString('utf8'),
|
|
});
|
|
});
|
|
response.once('aborted', () => reject(new Error('response aborted')));
|
|
response.once('error', reject);
|
|
},
|
|
);
|
|
outgoing.once('error', reject);
|
|
outgoing.end(body);
|
|
});
|
|
}
|
|
|
|
describe('LLM transient fault proxy', () => {
|
|
it('pins loopback in both no-proxy variants without dropping existing entries', () => {
|
|
const source = {
|
|
HTTP_PROXY: 'http://proxy.example',
|
|
NO_PROXY: 'internal.example, 127.0.0.1',
|
|
no_proxy: 'legacy.example',
|
|
};
|
|
|
|
const childEnvironment = withLoopbackNoProxy(source);
|
|
|
|
expect(childEnvironment).toEqual({
|
|
HTTP_PROXY: 'http://proxy.example',
|
|
NO_PROXY: 'internal.example,127.0.0.1,legacy.example,localhost,::1',
|
|
no_proxy: 'internal.example,127.0.0.1,legacy.example,localhost,::1',
|
|
});
|
|
expect(source).toEqual({
|
|
HTTP_PROXY: 'http://proxy.example',
|
|
NO_PROXY: 'internal.example, 127.0.0.1',
|
|
no_proxy: 'legacy.example',
|
|
});
|
|
});
|
|
|
|
it('resets the first POST before upstream and then streams an intact request and response', async () => {
|
|
const captured: CapturedRequest[] = [];
|
|
const releaseResponse = deferred();
|
|
const firstResponseChunk = deferred<string>();
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
captured.push({
|
|
method: incoming.method,
|
|
url: incoming.url,
|
|
headers: incoming.headers,
|
|
body: await readBody(incoming),
|
|
});
|
|
response.writeHead(201, {
|
|
'content-type': 'text/plain',
|
|
'x-upstream-stream': 'yes',
|
|
});
|
|
response.write('first-');
|
|
await releaseResponse.promise;
|
|
response.end('second');
|
|
});
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: upstream.url,
|
|
faultCount: 1,
|
|
});
|
|
|
|
const failedRequest = request(proxy.url, {
|
|
path: '/v1/chat/completions?mode=fault',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: '{"attempt":1}',
|
|
});
|
|
await expect(failedRequest).rejects.toBeInstanceOf(Error);
|
|
await expect(proxy.waitForFault()).resolves.toMatchObject({
|
|
faultInjectedCount: 1,
|
|
});
|
|
expect(captured).toHaveLength(0);
|
|
|
|
const forwardedRequest = request(proxy.url, {
|
|
path: '/v1/chat/completions?mode=stream',
|
|
headers: {
|
|
authorization: 'Bearer fixture-secret',
|
|
'content-type': 'application/json',
|
|
'x-request-marker': 'preserved',
|
|
},
|
|
body: '{"prompt":"sensitive-body-marker"}',
|
|
onChunk: (chunk) => firstResponseChunk.resolve(chunk),
|
|
});
|
|
await expect(firstResponseChunk.promise).resolves.toBe('first-');
|
|
expect(captured).toEqual([
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions?mode=stream',
|
|
headers: expect.objectContaining({
|
|
authorization: 'Bearer fixture-secret',
|
|
'content-type': 'application/json',
|
|
'x-request-marker': 'preserved',
|
|
}),
|
|
body: '{"prompt":"sensitive-body-marker"}',
|
|
}),
|
|
]);
|
|
releaseResponse.resolve();
|
|
|
|
await expect(forwardedRequest).resolves.toMatchObject({
|
|
statusCode: 201,
|
|
headers: expect.objectContaining({ 'x-upstream-stream': 'yes' }),
|
|
body: 'first-second',
|
|
});
|
|
expect(proxy.stats).toEqual({
|
|
requestCount: 2,
|
|
faultInjectedCount: 1,
|
|
heldRequestCount: 0,
|
|
forwardedRequestCount: 1,
|
|
forwardingReleased: true,
|
|
stopped: false,
|
|
});
|
|
});
|
|
|
|
it('uses only frozen timing metadata to select a later fault', async () => {
|
|
const capturedBodies: string[] = [];
|
|
const selectorCalls: Array<{
|
|
metadata: { sequence: number; acceptedAtMs: number };
|
|
frozen: boolean;
|
|
}> = [];
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
capturedBodies.push(await readBody(incoming));
|
|
response.end('forwarded');
|
|
});
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: upstream.url,
|
|
faultCount: 1,
|
|
async shouldInjectFault(metadata: {
|
|
sequence: number;
|
|
acceptedAtMs: number;
|
|
}) {
|
|
selectorCalls.push({
|
|
metadata: { ...metadata },
|
|
frozen: Object.isFrozen(metadata),
|
|
});
|
|
await Promise.resolve();
|
|
return metadata.sequence === 2;
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
request(proxy.url, {
|
|
headers: { authorization: 'Bearer a' },
|
|
body: 'first-sensitive-body',
|
|
}),
|
|
).resolves.toMatchObject({ statusCode: 200, body: 'forwarded' });
|
|
await expect(
|
|
request(proxy.url, {
|
|
headers: { authorization: 'Bearer b' },
|
|
body: 'selected-sensitive-body',
|
|
}),
|
|
).rejects.toBeInstanceOf(Error);
|
|
await expect(proxy.waitForFault()).resolves.toMatchObject({
|
|
faultInjectedCount: 1,
|
|
});
|
|
await expect(
|
|
request(proxy.url, { body: 'after-fault-body' }),
|
|
).resolves.toMatchObject({ statusCode: 200, body: 'forwarded' });
|
|
|
|
expect(selectorCalls).toEqual([
|
|
{
|
|
metadata: {
|
|
sequence: 1,
|
|
acceptedAtMs: expect.any(Number),
|
|
},
|
|
frozen: true,
|
|
},
|
|
{
|
|
metadata: {
|
|
sequence: 2,
|
|
acceptedAtMs: expect.any(Number),
|
|
},
|
|
frozen: true,
|
|
},
|
|
]);
|
|
expect(Object.keys(selectorCalls[0].metadata).sort()).toEqual([
|
|
'acceptedAtMs',
|
|
'sequence',
|
|
]);
|
|
expect(JSON.stringify(selectorCalls)).not.toMatch(
|
|
/secret|sensitive|authorization|body/iu,
|
|
);
|
|
expect(capturedBodies).toEqual([
|
|
'first-sensitive-body',
|
|
'after-fault-body',
|
|
]);
|
|
expect(proxy.stats).toEqual({
|
|
requestCount: 3,
|
|
faultInjectedCount: 1,
|
|
heldRequestCount: 0,
|
|
forwardedRequestCount: 2,
|
|
forwardingReleased: true,
|
|
stopped: false,
|
|
});
|
|
});
|
|
|
|
it('does not count a delayed selector decision after the proxy stops', async () => {
|
|
let upstreamRequestCount = 0;
|
|
const selectorStarted = deferred();
|
|
const selectorDecision = deferred<boolean>();
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
upstreamRequestCount += 1;
|
|
await readBody(incoming);
|
|
response.end('unexpected');
|
|
});
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: upstream.url,
|
|
faultCount: 1,
|
|
async shouldInjectFault() {
|
|
selectorStarted.resolve();
|
|
return selectorDecision.promise;
|
|
},
|
|
});
|
|
|
|
const pendingRequest = request(proxy.url, { body: 'delayed-selector' });
|
|
await selectorStarted.promise;
|
|
const stopPromise = proxy.stop();
|
|
selectorDecision.resolve(true);
|
|
|
|
await expect(pendingRequest).rejects.toBeInstanceOf(Error);
|
|
await expect(stopPromise).resolves.toBeUndefined();
|
|
expect(upstreamRequestCount).toBe(0);
|
|
expect(proxy.stats).toMatchObject({
|
|
requestCount: 1,
|
|
faultInjectedCount: 0,
|
|
heldRequestCount: 0,
|
|
forwardedRequestCount: 0,
|
|
stopped: true,
|
|
});
|
|
});
|
|
|
|
it('holds the post-fault request before upstream until forwarding is released', async () => {
|
|
const captured: CapturedRequest[] = [];
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
captured.push({
|
|
method: incoming.method,
|
|
url: incoming.url,
|
|
headers: incoming.headers,
|
|
body: await readBody(incoming),
|
|
});
|
|
response.writeHead(200, { 'x-held-request': 'released' });
|
|
response.end('forwarded-after-release');
|
|
});
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: upstream.url,
|
|
faultCount: 1,
|
|
holdAfterFault: true,
|
|
});
|
|
|
|
await expect(
|
|
request(proxy.url, { path: '/fault', body: 'first-attempt' }),
|
|
).rejects.toBeInstanceOf(Error);
|
|
await proxy.waitForFault();
|
|
|
|
const heldRequest = request(proxy.url, {
|
|
path: '/v1/responses?held=1',
|
|
headers: {
|
|
authorization: 'Bearer held-secret',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: '{"held":"request-body"}',
|
|
});
|
|
await expect(proxy.waitForHeldRequest()).resolves.toMatchObject({
|
|
heldRequestCount: 1,
|
|
forwardedRequestCount: 0,
|
|
});
|
|
const heldLog = proxy.getRequestLog();
|
|
expect(heldLog).toEqual([
|
|
{
|
|
sequence: 1,
|
|
acceptedAtMs: expect.any(Number),
|
|
faultInjectedAtMs: expect.any(Number),
|
|
heldAtMs: null,
|
|
forwardingStartedAtMs: null,
|
|
},
|
|
{
|
|
sequence: 2,
|
|
acceptedAtMs: expect.any(Number),
|
|
faultInjectedAtMs: null,
|
|
heldAtMs: expect.any(Number),
|
|
forwardingStartedAtMs: null,
|
|
},
|
|
]);
|
|
expect(heldLog[1].acceptedAtMs).toBeGreaterThanOrEqual(
|
|
heldLog[0].acceptedAtMs,
|
|
);
|
|
expect(heldLog[1].heldAtMs).toBeGreaterThanOrEqual(heldLog[1].acceptedAtMs);
|
|
expect(JSON.stringify(heldLog)).not.toMatch(
|
|
/held-secret|request-body|responses|authorization/iu,
|
|
);
|
|
expect(captured).toHaveLength(0);
|
|
expect(proxy.releaseForwarding()).toMatchObject({
|
|
forwardingReleased: true,
|
|
heldRequestCount: 1,
|
|
});
|
|
|
|
await expect(heldRequest).resolves.toMatchObject({
|
|
statusCode: 200,
|
|
headers: expect.objectContaining({ 'x-held-request': 'released' }),
|
|
body: 'forwarded-after-release',
|
|
});
|
|
expect(captured).toEqual([
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
url: '/v1/responses?held=1',
|
|
headers: expect.objectContaining({
|
|
authorization: 'Bearer held-secret',
|
|
}),
|
|
body: '{"held":"request-body"}',
|
|
}),
|
|
]);
|
|
expect(proxy.stats.forwardedRequestCount).toBe(1);
|
|
expect(
|
|
proxy.getRequestLog()[1].forwardingStartedAtMs,
|
|
).toBeGreaterThanOrEqual(heldLog[1].heldAtMs);
|
|
});
|
|
|
|
it('holds before injecting a remaining configured fault', async () => {
|
|
let upstreamRequestCount = 0;
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
upstreamRequestCount += 1;
|
|
await readBody(incoming);
|
|
response.end('after-two-faults');
|
|
});
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: upstream.url,
|
|
faultCount: 2,
|
|
holdAfterFault: true,
|
|
});
|
|
|
|
await expect(
|
|
request(proxy.url, { body: 'fault-one' }),
|
|
).rejects.toBeInstanceOf(Error);
|
|
const secondFault = request(proxy.url, { body: 'fault-two' });
|
|
await proxy.waitForHeldRequest();
|
|
expect(proxy.stats).toMatchObject({
|
|
faultInjectedCount: 1,
|
|
heldRequestCount: 1,
|
|
forwardedRequestCount: 0,
|
|
});
|
|
proxy.releaseForwarding();
|
|
await expect(secondFault).rejects.toBeInstanceOf(Error);
|
|
expect(proxy.stats.faultInjectedCount).toBe(2);
|
|
expect(upstreamRequestCount).toBe(0);
|
|
|
|
await expect(
|
|
request(proxy.url, { body: 'forward-third' }),
|
|
).resolves.toMatchObject({ statusCode: 200, body: 'after-two-faults' });
|
|
expect(upstreamRequestCount).toBe(1);
|
|
});
|
|
|
|
it('keeps sensitive upstream, header, and body values out of stats and errors', async () => {
|
|
const sensitiveQuery = 'upstream-sensitive-query-marker';
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
await readBody(incoming);
|
|
response.end('sensitive-response-marker');
|
|
});
|
|
const upstreamBaseUrl = `${upstream.url}/v1///?token=${sensitiveQuery}`;
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl,
|
|
faultCount: 0,
|
|
});
|
|
|
|
await request(proxy.url, {
|
|
path: '/v1/chat/completions',
|
|
headers: { authorization: 'Bearer sensitive-authorization-marker' },
|
|
body: 'sensitive-request-body-marker',
|
|
});
|
|
let timeoutError: unknown;
|
|
try {
|
|
await proxy.waitForHeldRequest(20);
|
|
} catch (error) {
|
|
timeoutError = error;
|
|
}
|
|
|
|
const publicSurface = JSON.stringify({
|
|
proxy,
|
|
stats: proxy.getStats(),
|
|
error: String(timeoutError),
|
|
});
|
|
for (const sensitiveValue of [
|
|
upstream.url,
|
|
upstreamBaseUrl,
|
|
sensitiveQuery,
|
|
'sensitive-authorization-marker',
|
|
'sensitive-request-body-marker',
|
|
'sensitive-response-marker',
|
|
]) {
|
|
expect(publicSurface).not.toContain(sensitiveValue);
|
|
}
|
|
expect(String(timeoutError)).toBe(
|
|
'LlmTransientFaultProxyError: timed out waiting for proxy held',
|
|
);
|
|
});
|
|
|
|
it('returns a normalized local baseUrl and preserves the upstream base path', async () => {
|
|
const captured: CapturedRequest[] = [];
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
captured.push({
|
|
method: incoming.method,
|
|
url: incoming.url,
|
|
headers: incoming.headers,
|
|
body: await readBody(incoming),
|
|
});
|
|
response.end('base-path-preserved');
|
|
});
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: `${upstream.url}/v1///`,
|
|
faultCount: 0,
|
|
});
|
|
|
|
expect(proxy.baseUrl).toBe(`${proxy.url}/v1`);
|
|
await expect(
|
|
request(proxy.url, {
|
|
path: '/v10/chat/completions',
|
|
}),
|
|
).resolves.toMatchObject({ statusCode: 400 });
|
|
await expect(
|
|
request(proxy.url, {
|
|
path: '/v1/../chat/completions',
|
|
}),
|
|
).resolves.toMatchObject({ statusCode: 400 });
|
|
expect(captured).toHaveLength(0);
|
|
|
|
const runtimeUrl = `${proxy.baseUrl}/chat/completions`;
|
|
const runtimePath = new URL(runtimeUrl).pathname;
|
|
await expect(
|
|
request(proxy.url, {
|
|
path: runtimePath,
|
|
headers: { 'content-type': 'application/json' },
|
|
body: '{"basePath":"/v1"}',
|
|
}),
|
|
).resolves.toMatchObject({
|
|
statusCode: 200,
|
|
body: 'base-path-preserved',
|
|
});
|
|
expect(captured).toEqual([
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
url: '/v1/chat/completions',
|
|
body: '{"basePath":"/v1"}',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('stops idempotently, unblocks held requests, and leaves no forwarded request', async () => {
|
|
let upstreamRequestCount = 0;
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
upstreamRequestCount += 1;
|
|
await readBody(incoming);
|
|
response.end('unexpected');
|
|
});
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: upstream.url,
|
|
holdAfterFault: true,
|
|
});
|
|
|
|
await expect(request(proxy.url, { body: 'fault' })).rejects.toBeInstanceOf(
|
|
Error,
|
|
);
|
|
const heldRequest = request(proxy.url, { body: 'held-until-stop' });
|
|
await proxy.waitForHeldRequest();
|
|
|
|
const firstStop = proxy.stop();
|
|
const secondStop = proxy.stop();
|
|
expect(secondStop).toBe(firstStop);
|
|
await Promise.all([firstStop, secondStop]);
|
|
await expect(proxy.stop()).resolves.toBeUndefined();
|
|
await expect(heldRequest).rejects.toBeInstanceOf(Error);
|
|
expect(upstreamRequestCount).toBe(0);
|
|
expect(proxy.stats).toMatchObject({
|
|
faultInjectedCount: 1,
|
|
heldRequestCount: 1,
|
|
forwardedRequestCount: 0,
|
|
stopped: true,
|
|
});
|
|
});
|
|
|
|
it('stops an active upstream request and its downstream connection', async () => {
|
|
const upstreamReceived = deferred();
|
|
const upstreamSocketClosed = deferred();
|
|
const upstream = await startServer(async (incoming) => {
|
|
incoming.socket.once('close', () => upstreamSocketClosed.resolve());
|
|
await readBody(incoming);
|
|
upstreamReceived.resolve();
|
|
});
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: upstream.url,
|
|
faultCount: 0,
|
|
});
|
|
|
|
const activeRequest = request(proxy.url, { body: 'active-forward' });
|
|
await upstreamReceived.promise;
|
|
expect(proxy.stats.forwardedRequestCount).toBe(1);
|
|
|
|
await proxy.stop();
|
|
await expect(activeRequest).rejects.toBeInstanceOf(Error);
|
|
await expect(upstreamSocketClosed.promise).resolves.toBeUndefined();
|
|
expect(proxy.stats.stopped).toBe(true);
|
|
});
|
|
|
|
it('falls back to an injected high loopback port pool after port zero exhaustion', async () => {
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
await readBody(incoming);
|
|
response.end('ok');
|
|
});
|
|
const attempts: Array<{ host: string; port: number }> = [];
|
|
const fallbackPorts = Array.from(
|
|
{ length: 64 },
|
|
(_, index) => 62_128 + index,
|
|
);
|
|
const injectedListen = async (
|
|
server: Server,
|
|
options: { host: string; port: number },
|
|
) => {
|
|
attempts.push(options);
|
|
if (options.port === 0) {
|
|
throw Object.assign(new Error('simulated ephemeral port exhaustion'), {
|
|
code: 'EADDRINUSE',
|
|
});
|
|
}
|
|
await listen(server, options);
|
|
};
|
|
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: upstream.url,
|
|
faultCount: 0,
|
|
fallbackPorts,
|
|
listen: injectedListen,
|
|
});
|
|
|
|
expect(attempts[0]).toEqual({ host: '127.0.0.1', port: 0 });
|
|
expect(attempts.slice(1).every(({ host }) => host === '127.0.0.1')).toBe(
|
|
true,
|
|
);
|
|
expect(fallbackPorts).toContain(proxy.port);
|
|
await expect(
|
|
request(proxy.url, { body: 'fallback-body' }),
|
|
).resolves.toMatchObject({ statusCode: 200, body: 'ok' });
|
|
});
|
|
|
|
it('fails closed for non-POST and absolute-form requests without consuming faults', async () => {
|
|
let upstreamRequestCount = 0;
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
upstreamRequestCount += 1;
|
|
await readBody(incoming);
|
|
response.end('unexpected');
|
|
});
|
|
const proxy = await startProxy({ upstreamBaseUrl: upstream.url });
|
|
|
|
await expect(
|
|
request(proxy.url, { method: 'GET', path: '/models' }),
|
|
).resolves.toMatchObject({ statusCode: 405 });
|
|
await expect(
|
|
request(proxy.url, {
|
|
path: 'http://second-origin.invalid/v1/chat/completions',
|
|
body: 'absolute-form',
|
|
}),
|
|
).resolves.toMatchObject({ statusCode: 400 });
|
|
expect(upstreamRequestCount).toBe(0);
|
|
expect(proxy.stats).toMatchObject({
|
|
requestCount: 2,
|
|
faultInjectedCount: 0,
|
|
forwardedRequestCount: 0,
|
|
});
|
|
|
|
await expect(
|
|
request(proxy.url, { body: 'valid-post' }),
|
|
).rejects.toBeInstanceOf(Error);
|
|
expect(proxy.stats.faultInjectedCount).toBe(1);
|
|
expect(upstreamRequestCount).toBe(0);
|
|
});
|
|
|
|
it('returns upstream redirects without following them to a second origin', async () => {
|
|
let secondOriginRequestCount = 0;
|
|
const secondOrigin = await startServer(async (incoming, response) => {
|
|
secondOriginRequestCount += 1;
|
|
await readBody(incoming);
|
|
response.end('must-not-be-requested');
|
|
});
|
|
const upstream = await startServer(async (incoming, response) => {
|
|
await readBody(incoming);
|
|
response.writeHead(307, {
|
|
location: `${secondOrigin.url}/redirect-target`,
|
|
});
|
|
response.end('redirect-not-followed');
|
|
});
|
|
const proxy = await startProxy({
|
|
upstreamBaseUrl: upstream.url,
|
|
faultCount: 0,
|
|
});
|
|
|
|
await expect(
|
|
request(proxy.url, { body: 'redirect-source' }),
|
|
).resolves.toMatchObject({
|
|
statusCode: 307,
|
|
headers: expect.objectContaining({
|
|
location: `${secondOrigin.url}/redirect-target`,
|
|
}),
|
|
body: 'redirect-not-followed',
|
|
});
|
|
expect(secondOriginRequestCount).toBe(0);
|
|
expect(proxy.stats.forwardedRequestCount).toBe(1);
|
|
});
|
|
|
|
it('rejects unsafe upstream URL forms without echoing them', async () => {
|
|
const unsafeUrls = [
|
|
'ftp://unsafe-url-marker.invalid/v1',
|
|
'https://user:password@unsafe-url-marker.invalid/v1',
|
|
'https://unsafe-url-marker.invalid/v1#fragment',
|
|
];
|
|
|
|
for (const upstreamBaseUrl of unsafeUrls) {
|
|
let startupError: unknown;
|
|
try {
|
|
await startLlmTransientFaultProxy({ upstreamBaseUrl });
|
|
} catch (error) {
|
|
startupError = error;
|
|
}
|
|
expect(startupError).toBeInstanceOf(Error);
|
|
expect(String(startupError)).not.toContain('unsafe-url-marker');
|
|
}
|
|
});
|
|
});
|