From da15b55786d55bc381aafa3debc49f821626ffd5 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 24 Jun 2026 22:31:58 +0300 Subject: [PATCH] =?UTF-8?q?refactor(ai):=20address=20PR=20#176=20review=20?= =?UTF-8?q?=E2=80=94=20finite-timeout=20wording,=20env=20doc,=20tests,=20p?= =?UTF-8?q?ermanent=20provider-http=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .env.example | 6 +++ .../integrations/ai/ai-provider-http.spec.ts | 40 +++++++++++++++++++ ...ttp-diagnostics.ts => ai-provider-http.ts} | 36 ++++++++++------- .../ai/ai-streaming-fetch.spec.ts | 40 +++++++++++++++++-- apps/server/src/integrations/ai/ai.service.ts | 22 +++++----- 5 files changed, 114 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/integrations/ai/ai-provider-http.spec.ts rename apps/server/src/integrations/ai/{ai-http-diagnostics.ts => ai-provider-http.ts} (65%) diff --git a/.env.example b/.env.example index fa886282..4726805b 100644 --- a/.env.example +++ b/.env.example @@ -136,6 +136,12 @@ MCP_DOCMOST_PASSWORD= # A slow/hung embeddings endpoint fails after this and the batch continues. # AI_EMBEDDING_TIMEOUT_MS=120000 +# Silence timeout (ms) for streaming chat/agent AI calls AND external-MCP traffic. +# Bounds time-to-first-byte and the gap BETWEEN chunks (NOT the total turn length), +# so an arbitrarily long turn that keeps streaming is never cut. Finite so a hung +# provider is eventually broken instead of leaking forever. Default 900000 (15 min). +# AI_STREAM_TIMEOUT_MS=900000 + # --- Anonymous public-share AI assistant --- # Opt-in per workspace (AI settings -> "public share assistant"; off by default). # When enabled, anonymous visitors of a published share can ask an AI about that diff --git a/apps/server/src/integrations/ai/ai-provider-http.spec.ts b/apps/server/src/integrations/ai/ai-provider-http.spec.ts new file mode 100644 index 00000000..7ccb744c --- /dev/null +++ b/apps/server/src/integrations/ai/ai-provider-http.spec.ts @@ -0,0 +1,40 @@ +import { createInstrumentedFetch } from './ai-provider-http'; + +/** + * createInstrumentedFetch must be behavior-neutral: it delegates to the supplied + * baseFetch with the SAME input/init, returns the Response object untouched (so + * the streamed SSE body is never read/cloned), and rethrows the same error. The + * baseFetch injection is the seam that carries the streaming fetch (#175) onto + * the chat provider, so it is tested directly. + */ +describe('createInstrumentedFetch', () => { + it('delegates to the injected baseFetch with the same input/init', async () => { + const fakeResponse = new Response('ok', { status: 200 }); + const baseFetch = jest.fn().mockResolvedValue(fakeResponse); + const instrumented = createInstrumentedFetch('test', baseFetch as never); + + const init = { method: 'POST', body: '{"q":1}' }; + const res = await instrumented('https://example.com/v1/chat', init); + + expect(baseFetch).toHaveBeenCalledTimes(1); + expect(baseFetch).toHaveBeenCalledWith('https://example.com/v1/chat', init); + // The Response is returned UNTOUCHED (same reference — never read/cloned). + expect(res).toBe(fakeResponse); + }); + + it('rethrows the base fetch error unchanged (pre-response failure)', async () => { + const err = Object.assign(new TypeError('fetch failed'), { + cause: { code: 'ECONNRESET' }, + }); + const baseFetch = jest.fn().mockRejectedValue(err); + const instrumented = createInstrumentedFetch('test', baseFetch as never); + + await expect(instrumented('https://example.com/')).rejects.toBe(err); + }); + + it('defaults to the global fetch when no baseFetch is given', () => { + // Constructing without a baseFetch must not throw — it simply wraps global + // fetch (the non-chat default). + expect(() => createInstrumentedFetch('test')).not.toThrow(); + }); +}); diff --git a/apps/server/src/integrations/ai/ai-http-diagnostics.ts b/apps/server/src/integrations/ai/ai-provider-http.ts similarity index 65% rename from apps/server/src/integrations/ai/ai-http-diagnostics.ts rename to apps/server/src/integrations/ai/ai-provider-http.ts index 0761d050..22ef2f44 100644 --- a/apps/server/src/integrations/ai/ai-http-diagnostics.ts +++ b/apps/server/src/integrations/ai/ai-provider-http.ts @@ -1,16 +1,22 @@ import { Logger } from '@nestjs/common'; /** - * DIAGNOSTIC (provider ECONNRESET investigation) — temporary. + * The provider HTTP fetch used by the chat path: a thin, behavior-neutral + * instrumentation wrapper around a supplied `fetch`. * - * A PASSIVE, behavior-neutral wrapper around the global `fetch`, injected into - * the OpenAI-compatible provider client (`createOpenAI({ fetch })`, the z.ai - * path). Per provider HTTP call it logs: time-to-response-headers + status + - * request-body size on success; and on a pre-response rejection the failure - * latency + error code/cause + request-body size + the idle gap since the - * previous provider call. It NEVER retries, times out, swaps the dispatcher, or - * reads/clones the response body — the Response is returned untouched (streaming - * unaffected) and any error is rethrown unchanged. + * It defaults to the global `fetch`, but the chat provider passes the streaming + * fetch (which RAISES undici's 300s stream timeouts to a generous-but-finite + * silence timeout so a long agent turn is not severed mid-stream — #175). So this + * wrapper observes the EXACT transport a turn uses. It NEVER retries, times out, + * swaps the dispatcher, or reads/clones the response body — the Response is + * returned untouched (streaming unaffected) and any error is rethrown unchanged. + * + * Per provider HTTP call it logs: time-to-response-headers + status + request + * body size on success; and on a pre-response rejection the failure latency + + * error code/cause + request body size + the idle gap since the previous call. + * This telemetry is intentional and kept (it diagnoses provider connection + * resets / mid-stream cuts), and it is load-bearing: the streaming fetch reaches + * the chat provider THROUGH this wrapper, so the two are one construct. * * How to read the result (a long agentic turn makes one provider call per step): * - a failed turn whose last provider line is "PRE-RESPONSE FAILED ... ECONNRESET" @@ -23,13 +29,13 @@ import { Logger } from '@nestjs/common'; * different failure mode. * * The seq/last-call timestamps are module-level, so under concurrent turns the - * idle-gap figure is approximate (fine for single-user reproduction). + * idle-gap figure is approximate (fine for single-user diagnosis). */ -export function createDiagnosticFetch( +export function createInstrumentedFetch( context: string, // The underlying fetch to instrument. Defaults to the global fetch; the chat - // provider passes a streaming fetch (disabled undici stream timeouts, #175) so - // the telemetry observes the SAME transport the long agent turn actually uses. + // provider passes the streaming fetch (raised, finite undici stream timeouts, + // #175) so the telemetry observes the SAME transport the long agent turn uses. baseFetch: typeof fetch = fetch, ): typeof fetch { const logger = new Logger(context); @@ -56,7 +62,7 @@ export function createDiagnosticFetch( // clone the body) so the streamed SSE response is unaffected. const res = await baseFetch(input, init); logger.log( - `provider HTTP DIAGNOSTIC: call#${callId} OK ` + + `provider HTTP: call#${callId} OK ` + `headersAfter=${Date.now() - startedAt}ms status=${res.status} ` + `reqBytes=${bodyBytes ?? 'n/a'} idleSincePrevCall=${idleSincePrev ?? 'n/a'}ms`, ); @@ -70,7 +76,7 @@ export function createDiagnosticFetch( cause?: { code?: string; message?: string }; }; logger.warn( - `provider HTTP DIAGNOSTIC: call#${callId} PRE-RESPONSE FAILED ` + + `provider HTTP: call#${callId} PRE-RESPONSE FAILED ` + `after=${Date.now() - startedAt}ms code=${e?.cause?.code ?? 'none'} ` + `name=${e?.name ?? 'Error'} cause=${e?.cause?.message ?? e?.message ?? 'unknown'} ` + `reqBytes=${bodyBytes ?? 'n/a'} idleSincePrevCall=${idleSincePrev ?? 'n/a'}ms`, diff --git a/apps/server/src/integrations/ai/ai-streaming-fetch.spec.ts b/apps/server/src/integrations/ai/ai-streaming-fetch.spec.ts index df6a16d5..b28ecf51 100644 --- a/apps/server/src/integrations/ai/ai-streaming-fetch.spec.ts +++ b/apps/server/src/integrations/ai/ai-streaming-fetch.spec.ts @@ -48,10 +48,13 @@ describe('streamTimeoutMs', () => { }); 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). - const DELAY = 400; + // 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) => { @@ -69,10 +72,41 @@ describe('createStreamingFetch — against a delayed server', () => { await new Promise((resolve) => server.close(() => resolve())); }); - it('streams the delayed response instead of timing out', async () => { + 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'); + }); }); diff --git a/apps/server/src/integrations/ai/ai.service.ts b/apps/server/src/integrations/ai/ai.service.ts index 65bdda5c..2a524f2c 100644 --- a/apps/server/src/integrations/ai/ai.service.ts +++ b/apps/server/src/integrations/ai/ai.service.ts @@ -14,8 +14,7 @@ import { AiNotConfiguredException } from './ai-not-configured.exception'; import { AiEmbeddingNotConfiguredException } from './ai-embedding-not-configured.exception'; import { AiSttNotConfiguredException } from './ai-stt-not-configured.exception'; import { describeProviderError } from './ai-error.util'; -// DIAGNOSTIC (provider ECONNRESET investigation) — temporary. -import { createDiagnosticFetch } from './ai-http-diagnostics'; +import { createInstrumentedFetch } from './ai-provider-http'; import { createStreamingFetch } from './ai-streaming-fetch'; import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo'; import { SecretBoxService } from '../crypto/secret-box'; @@ -46,12 +45,12 @@ export interface ChatModelOverride { export class AiService { private readonly logger = new Logger(AiService.name); - // Provider HTTP fetch for the chat path: a streaming fetch that DISABLES - // undici's 300s headers/body timeouts (#175 — long agent turns were severed - // mid-stream), wrapped with passive ECONNRESET-investigation telemetry so the - // logs observe the exact transport the turn uses. Held for the service - // lifetime to reuse the streaming dispatcher's connection pool. - private readonly aiDiagnosticFetch = createDiagnosticFetch( + // Provider HTTP fetch for the chat path: the streaming fetch — which RAISES + // undici's 300s headers/body timeouts to a generous-but-finite silence timeout + // so a long agent turn is not severed mid-stream (#175) — wrapped with the + // provider-HTTP instrumentation so the logs observe that exact transport. Held + // for the service lifetime to reuse the streaming dispatcher's connection pool. + private readonly aiProviderFetch = createInstrumentedFetch( 'AiService:provider-http', createStreamingFetch(), ); @@ -152,13 +151,12 @@ export class AiService { // endpoint. The default callable createOpenAI(...)(model) targets the // Responses API (/responses), which OpenAI-compatible gateways // (OpenRouter, etc.) reject on multi-turn requests (history with - // assistant messages) → 400. - // DIAGNOSTIC (provider ECONNRESET investigation) — temporary: pass the - // passive instrumented fetch (logging only; no behavior change). + // assistant messages) → 400. The provider fetch is the instrumented + // streaming fetch (finite-but-generous stream timeouts, #175). return createOpenAI({ apiKey, baseURL: baseUrl, - fetch: this.aiDiagnosticFetch, + fetch: this.aiProviderFetch, }).chat(chatModel); case 'gemini': return createGoogleGenerativeAI({ apiKey })(chatModel);