diff --git a/apps/server/src/integrations/ai/ai-http.spec.ts b/apps/server/src/integrations/ai/ai-http.spec.ts index 044c18f9..4fbfab04 100644 --- a/apps/server/src/integrations/ai/ai-http.spec.ts +++ b/apps/server/src/integrations/ai/ai-http.spec.ts @@ -1,5 +1,10 @@ +import * as http from 'node:http'; import { RetryAgent } from 'undici'; +// A short header timeout makes the #140 "header stall" deterministic and fast. +// Must be set BEFORE importing ai-http (the undici agents read it at module load). +process.env.AI_HTTP_HEADERS_TIMEOUT_MS = '800'; + import { aiFetch } from './ai-http'; /** @@ -45,3 +50,63 @@ describe('ai-http', () => { } }); }); + +/** + * #140 regression: a provider that accepts the request but stalls without ever + * sending response headers must FAIL FAST (at headersTimeout — set to 800ms + * above, not undici's 300s default) and be RETRIED on a fresh connection. + * headersTimeout only bounds time-to-headers, so a healthy fast response is + * unaffected. Uses a real loopback server; makes no external network calls. + */ +describe('aiFetch header-stall resilience (#140)', () => { + function makeServer( + handler: http.RequestListener, + ): Promise<{ url: string; close: () => Promise }> { + return new Promise((resolve) => { + const server = http.createServer(handler); + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as { port: number }).port; + resolve({ + url: `http://127.0.0.1:${port}/health`, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); + } + + it('retries a header stall on a fresh connection and recovers', async () => { + let attempts = 0; + const { url, close } = await makeServer((_req, res) => { + attempts++; + // First attempt: never send headers -> UND_ERR_HEADERS_TIMEOUT -> retry. + if (attempts === 1) return; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, servedOnAttempt: attempts })); + }); + try { + const res = await aiFetch(url, { method: 'GET' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { servedOnAttempt: number }; + expect(attempts).toBeGreaterThanOrEqual(2); // the stalled attempt was retried + expect(body.servedOnAttempt).toBeGreaterThanOrEqual(2); + } finally { + await close(); + } + }, 15000); + + it('passes a healthy fast response straight through (one attempt)', async () => { + let attempts = 0; + const { url, close } = await makeServer((_req, res) => { + attempts++; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + try { + const res = await aiFetch(url, { method: 'GET' }); + expect(res.status).toBe(200); + expect(attempts).toBe(1); + } finally { + await close(); + } + }, 15000); +});