211 lines
6.2 KiB
TypeScript
211 lines
6.2 KiB
TypeScript
/**
|
|
* Task 2.5 Stage 1.5 §7.1 — fetch-retry on TypeError tests.
|
|
*
|
|
* Exercises the retry branch in LiteLlmClient.call. Uses vi.stubGlobal to
|
|
* inject a fake fetch that returns a programmable sequence of responses or
|
|
* throws controllable error classes. 1-second wait between retries is
|
|
* accepted as per-test wall-clock cost; only 2-3 retry-path tests pay it.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createLlmClient } from '../src/llm.js';
|
|
import type { ModelSpec } from '../src/types.js';
|
|
|
|
const MODEL: ModelSpec = {
|
|
id: 'test-model',
|
|
displayName: 'Test',
|
|
provider: 'alibaba',
|
|
litellmModel: 'test/model',
|
|
pricePerMillionInput: 0.1,
|
|
pricePerMillionOutput: 0.5,
|
|
contextWindow: 16_000,
|
|
};
|
|
|
|
function buildInput() {
|
|
return {
|
|
model: MODEL,
|
|
systemPrompt: 'sys',
|
|
userPrompt: 'hello',
|
|
};
|
|
}
|
|
|
|
/** Mock that returns a JSON-body successful response. */
|
|
function mockSuccess(content = 'ok', usage?: { prompt_tokens: number; completion_tokens: number }): Response {
|
|
return new Response(
|
|
JSON.stringify({
|
|
choices: [{ message: { content } }],
|
|
usage: usage ?? { prompt_tokens: 10, completion_tokens: 2 },
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
/** Mock an HTTP error response. */
|
|
function mockHttpError(status: number): Response {
|
|
return new Response('', { status });
|
|
}
|
|
|
|
function throwTypeError(): never {
|
|
const e = new Error('fetch failed');
|
|
e.name = 'TypeError';
|
|
throw e;
|
|
}
|
|
|
|
function throwAbortError(): never {
|
|
const e = new Error('aborted');
|
|
e.name = 'AbortError';
|
|
throw e;
|
|
}
|
|
|
|
function throwRangeError(): never {
|
|
const e = new Error('range issue');
|
|
e.name = 'RangeError';
|
|
throw e;
|
|
}
|
|
|
|
let fetchCallCount = 0;
|
|
|
|
beforeEach(() => {
|
|
fetchCallCount = 0;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('LiteLlmClient — §7.1 fetch-retry on TypeError', () => {
|
|
it('succeeds on first attempt with no retries (fast path)', async () => {
|
|
vi.stubGlobal('fetch', async () => {
|
|
fetchCallCount++;
|
|
return mockSuccess('hello-response');
|
|
});
|
|
const client = createLlmClient({
|
|
dryRun: false,
|
|
litellmUrl: 'http://mock',
|
|
litellmApiKey: 'sk-test',
|
|
});
|
|
const r = await client.call(buildInput());
|
|
expect(fetchCallCount).toBe(1);
|
|
expect(r.failureMode).toBeNull();
|
|
expect(r.text).toBe('hello-response');
|
|
});
|
|
|
|
it('retries once on TypeError and succeeds on the second attempt', async () => {
|
|
vi.stubGlobal('fetch', async () => {
|
|
fetchCallCount++;
|
|
if (fetchCallCount === 1) throwTypeError();
|
|
return mockSuccess('recovered');
|
|
});
|
|
const client = createLlmClient({
|
|
dryRun: false,
|
|
litellmUrl: 'http://mock',
|
|
litellmApiKey: 'sk-test',
|
|
});
|
|
const started = Date.now();
|
|
const r = await client.call(buildInput());
|
|
const elapsed = Date.now() - started;
|
|
expect(fetchCallCount).toBe(2);
|
|
expect(r.failureMode).toBeNull();
|
|
expect(r.text).toBe('recovered');
|
|
// 1s backoff should be observable in the total latency.
|
|
expect(elapsed).toBeGreaterThanOrEqual(900);
|
|
expect(r.latencyMs).toBeGreaterThanOrEqual(900);
|
|
});
|
|
|
|
it('gives up after two TypeError attempts and returns failureMode', async () => {
|
|
vi.stubGlobal('fetch', async () => {
|
|
fetchCallCount++;
|
|
throwTypeError();
|
|
});
|
|
const client = createLlmClient({
|
|
dryRun: false,
|
|
litellmUrl: 'http://mock',
|
|
litellmApiKey: 'sk-test',
|
|
});
|
|
const r = await client.call(buildInput());
|
|
// 1 initial + 1 retry = 2 attempts total (FETCH_RETRY_MAX = 1).
|
|
expect(fetchCallCount).toBe(2);
|
|
expect(r.failureMode).toBe('fetch_error_TypeError');
|
|
expect(r.text).toBe('');
|
|
});
|
|
|
|
it('does NOT retry on AbortError (timeout) — returns immediately', async () => {
|
|
vi.stubGlobal('fetch', async () => {
|
|
fetchCallCount++;
|
|
throwAbortError();
|
|
});
|
|
const client = createLlmClient({
|
|
dryRun: false,
|
|
litellmUrl: 'http://mock',
|
|
litellmApiKey: 'sk-test',
|
|
});
|
|
const r = await client.call(buildInput());
|
|
expect(fetchCallCount).toBe(1);
|
|
expect(r.failureMode).toBe('timeout');
|
|
});
|
|
|
|
it('does NOT retry on http_5xx — returns immediately', async () => {
|
|
vi.stubGlobal('fetch', async () => {
|
|
fetchCallCount++;
|
|
return mockHttpError(502);
|
|
});
|
|
const client = createLlmClient({
|
|
dryRun: false,
|
|
litellmUrl: 'http://mock',
|
|
litellmApiKey: 'sk-test',
|
|
});
|
|
const r = await client.call(buildInput());
|
|
expect(fetchCallCount).toBe(1);
|
|
expect(r.failureMode).toBe('http_502');
|
|
});
|
|
|
|
it('does NOT retry on non-TypeError JS errors — returns immediately', async () => {
|
|
vi.stubGlobal('fetch', async () => {
|
|
fetchCallCount++;
|
|
throwRangeError();
|
|
});
|
|
const client = createLlmClient({
|
|
dryRun: false,
|
|
litellmUrl: 'http://mock',
|
|
litellmApiKey: 'sk-test',
|
|
});
|
|
const r = await client.call(buildInput());
|
|
expect(fetchCallCount).toBe(1);
|
|
expect(r.failureMode).toBe('fetch_error_RangeError');
|
|
});
|
|
|
|
it('latencyMs on successful retry reflects total wall-clock (including backoff)', async () => {
|
|
vi.stubGlobal('fetch', async () => {
|
|
fetchCallCount++;
|
|
if (fetchCallCount === 1) throwTypeError();
|
|
return mockSuccess('ok');
|
|
});
|
|
const client = createLlmClient({
|
|
dryRun: false,
|
|
litellmUrl: 'http://mock',
|
|
litellmApiKey: 'sk-test',
|
|
});
|
|
const r = await client.call(buildInput());
|
|
// Total latency must include the 1000 ms backoff, NOT just the second
|
|
// attempt's round-trip. Otherwise budget accounting underestimates.
|
|
expect(r.latencyMs).toBeGreaterThanOrEqual(900);
|
|
});
|
|
|
|
it('DryRunClient path is unaffected by retry logic', async () => {
|
|
// Dry-run never touches fetch; retry loop shouldn't run.
|
|
vi.stubGlobal('fetch', async () => {
|
|
fetchCallCount++;
|
|
throwTypeError();
|
|
});
|
|
const client = createLlmClient({
|
|
dryRun: true,
|
|
litellmUrl: 'http://mock',
|
|
litellmApiKey: 'sk-test',
|
|
});
|
|
const r = await client.call(buildInput());
|
|
expect(fetchCallCount).toBe(0);
|
|
expect(r.failureMode).toBeNull();
|
|
expect(r.text.startsWith('DRY_RUN:')).toBe(true);
|
|
});
|
|
});
|