test(ai-http): cover header-stall fail-fast + retry (#140)

Extend ai-http.spec with two loopback-server tests: a provider that stalls
without sending headers triggers the (lowered) headersTimeout and is retried on a
fresh connection, recovering; a healthy fast response passes through in one
attempt. No external network calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-23 04:11:50 +03:00
parent a958935dce
commit bd3e307b2e

View File

@@ -1,5 +1,10 @@
import * as http from 'node:http';
import { RetryAgent } from 'undici'; 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'; 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<void> }> {
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<void>((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);
});