- Wording: every comment now says the stream timeouts are RAISED to a
generous-but-finite ~15-min silence timeout, not "disabled (0)" (the stale
comments contradicted the code, which uses AI_STREAM_TIMEOUT_MS, default
900000ms).
- Architecture (the load-bearing-temporary trap): the streaming fetch reached
the chat provider only by riding the "temporary DIAGNOSTIC" telemetry, so
deleting the telemetry by its own label would silently revert the timeout fix.
Legitimize it: rename ai-http-diagnostics.ts -> ai-provider-http.ts,
createDiagnosticFetch -> createInstrumentedFetch, field aiDiagnosticFetch ->
aiProviderFetch, drop the "temporary" labels, and document the chat transport
(streaming fetch + instrumentation) as one intentional construct.
- Docs: AI_STREAM_TIMEOUT_MS added to .env.example next to AI_EMBEDDING_TIMEOUT_MS.
- Tests:
- ai-provider-http.spec: createInstrumentedFetch delegates to the injected
baseFetch with the same input/init, returns the Response untouched, rethrows
the error, and defaults to global fetch — covering the baseFetch seam.
- ai-streaming-fetch.spec: the delayed-server test is now LOAD-BEARING — with
AI_STREAM_TIMEOUT_MS set below the 1.5s server delay the call actually rejects
(a lost dispatcher -> global 300s default would NOT), proving the configured
dispatcher is wired; plus the default-timeout happy path.
server tsc clean; ai-streaming-fetch / ai-provider-http / ai.service / mcp-servers
/ ai-error specs green (41).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
113 lines
4.3 KiB
TypeScript
113 lines
4.3 KiB
TypeScript
import * as http from 'node:http';
|
|
import {
|
|
createStreamingFetch,
|
|
streamTimeoutMs,
|
|
streamingDispatcherOptions,
|
|
} from './ai-streaming-fetch';
|
|
|
|
/**
|
|
* #175: undici's default 300s headers/body timeouts severed long agent turns.
|
|
* The streaming fetch raises them to a generous-but-FINITE silence timeout (not
|
|
* 0 — a true hang must still break). We pin: the configured value + env override,
|
|
* that both dispatcher timeouts use it, and that a delayed response streams.
|
|
*/
|
|
describe('streamTimeoutMs', () => {
|
|
const ORIG = process.env.AI_STREAM_TIMEOUT_MS;
|
|
afterEach(() => {
|
|
if (ORIG === undefined) delete process.env.AI_STREAM_TIMEOUT_MS;
|
|
else process.env.AI_STREAM_TIMEOUT_MS = ORIG;
|
|
});
|
|
|
|
it('defaults to a generous-but-finite 15 minutes', () => {
|
|
delete process.env.AI_STREAM_TIMEOUT_MS;
|
|
expect(streamTimeoutMs()).toBe(900_000);
|
|
// Finite — NOT disabled (0 would let a hung provider leak forever).
|
|
expect(streamTimeoutMs()).toBeGreaterThan(0);
|
|
expect(Number.isFinite(streamTimeoutMs())).toBe(true);
|
|
});
|
|
|
|
it('honours a positive AI_STREAM_TIMEOUT_MS override', () => {
|
|
process.env.AI_STREAM_TIMEOUT_MS = '120000';
|
|
expect(streamTimeoutMs()).toBe(120000);
|
|
});
|
|
|
|
it('ignores an invalid / non-positive override (falls back to default)', () => {
|
|
for (const bad of ['0', '-5', 'abc', '']) {
|
|
process.env.AI_STREAM_TIMEOUT_MS = bad;
|
|
expect(streamTimeoutMs()).toBe(900_000);
|
|
}
|
|
});
|
|
|
|
it('applies the timeout to BOTH undici stream timeouts', () => {
|
|
delete process.env.AI_STREAM_TIMEOUT_MS;
|
|
expect(streamingDispatcherOptions()).toEqual({
|
|
headersTimeout: 900_000,
|
|
bodyTimeout: 900_000,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('createStreamingFetch — against a delayed server', () => {
|
|
const ORIG = process.env.AI_STREAM_TIMEOUT_MS;
|
|
let server: http.Server;
|
|
let url: string;
|
|
// The server waits before sending ANY byte (a long time-to-first-token). It is
|
|
// > undici's ~1s timeout-timer granularity so a sub-second configured timeout
|
|
// fires deterministically in the load-bearing test below.
|
|
const DELAY = 1500;
|
|
|
|
beforeAll(async () => {
|
|
server = http.createServer((_req, res) => {
|
|
setTimeout(() => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('ok');
|
|
}, DELAY);
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const addr = server.address() as import('node:net').AddressInfo;
|
|
url = `http://127.0.0.1:${addr.port}/`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (ORIG === undefined) delete process.env.AI_STREAM_TIMEOUT_MS;
|
|
else process.env.AI_STREAM_TIMEOUT_MS = ORIG;
|
|
});
|
|
|
|
it('streams the delayed response at the default (generous) timeout', async () => {
|
|
delete process.env.AI_STREAM_TIMEOUT_MS; // default 15 min >> DELAY
|
|
const streamingFetch = createStreamingFetch();
|
|
const res = await streamingFetch(url);
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe('ok');
|
|
});
|
|
|
|
it('LOAD-BEARING: a sub-DELAY AI_STREAM_TIMEOUT_MS actually severs the response', async () => {
|
|
// Proves the configured dispatcher is wired into the fetch: with the timeout
|
|
// set below DELAY the call must reject with undici's headers-timeout. If the
|
|
// dispatcher were lost (fallback to global fetch's 300s default), the 1.5s
|
|
// response would slip through and this would NOT throw.
|
|
process.env.AI_STREAM_TIMEOUT_MS = '500';
|
|
const streamingFetch = createStreamingFetch();
|
|
let caught: unknown;
|
|
const startedAt = Date.now();
|
|
try {
|
|
await streamingFetch(url).then((r) => r.text());
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
// It rejected (a lost dispatcher -> global 300s default would NOT reject on a
|
|
// 1.5s response) and it did so BEFORE the response would have arrived (DELAY).
|
|
// Use `.name` (realm-safe) — undici's TypeError fails cross-realm instanceof.
|
|
expect(caught).toBeDefined();
|
|
expect((caught as Error)?.name).toBe('TypeError');
|
|
expect(Date.now() - startedAt).toBeLessThan(DELAY);
|
|
// When present, the undici cause is the headers timeout.
|
|
const code = (caught as { cause?: { code?: string } })?.cause?.code;
|
|
if (code) expect(code).toBe('UND_ERR_HEADERS_TIMEOUT');
|
|
});
|
|
});
|