- providers/base.ts 引入 ApiRequestError 与 isRetryableError,按 429/5xx/超时/网络错误/401 分类 - chatWithRetry 升级为最多 3 次指数退避(1s/2s/4s),非重试错误立即上抛;新增 setRetryBaseDelayForTest 钩子 - aiFixEngine/customFixEngine 不再吞错,修复失败原因透传为 ai-error: <原因> - 扩展 ai-empty-response/customFixEngine 测试覆盖重试与分类
223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
import * as assert from 'assert';
|
|
import { parseJsonResponse, chatWithRetry, setRetryBaseDelayForTest } from '../src/ai/engine';
|
|
import { EmptyContentError, ApiRequestError, isRetryableError } from '../src/ai/providers/base';
|
|
import type { AIProvider } from '../src/ai/providers/base';
|
|
import type { ChatOptions } from '../src/ai/providers/base';
|
|
import { OpenAICompatibleProvider } from '../src/ai/providers/openai-compatible';
|
|
|
|
const OPTIONS: ChatOptions = {
|
|
model: 'test',
|
|
temperature: 0,
|
|
maxTokens: 1024,
|
|
timeoutMs: 5000,
|
|
};
|
|
|
|
suite('AI Empty Response Handling', () => {
|
|
suiteSetup(() => {
|
|
setRetryBaseDelayForTest(1);
|
|
});
|
|
|
|
test('parseJsonResponse throws empty-response error on blank input', () => {
|
|
assert.throws(() => parseJsonResponse(''), /空响应|empty response/);
|
|
assert.throws(() => parseJsonResponse(' \n\t '), /空响应|empty response/);
|
|
});
|
|
|
|
test('parseJsonResponse parses valid JSON normally', () => {
|
|
const parsed = parseJsonResponse('{"findings":[]}') as { findings: unknown[] };
|
|
assert.deepStrictEqual(parsed.findings, []);
|
|
});
|
|
|
|
test('chatWithRetry retries once on EmptyContentError', async () => {
|
|
const calls: string[] = [];
|
|
const provider = {
|
|
chat: async (system: string): Promise<string> => {
|
|
calls.push(system);
|
|
if (calls.length === 1) {
|
|
throw new EmptyContentError('finish_reason=length');
|
|
}
|
|
return '{"findings":[]}';
|
|
},
|
|
} as unknown as AIProvider;
|
|
|
|
const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS);
|
|
assert.strictEqual(result, '{"findings":[]}');
|
|
assert.strictEqual(calls.length, 2);
|
|
});
|
|
|
|
test('chatWithRetry propagates error when retry also returns empty', async () => {
|
|
let calls = 0;
|
|
const provider = {
|
|
chat: async (): Promise<string> => {
|
|
calls++;
|
|
throw new EmptyContentError('finish_reason=length');
|
|
},
|
|
} as unknown as AIProvider;
|
|
|
|
await assert.rejects(
|
|
() => chatWithRetry(provider, 'sys', 'user', OPTIONS),
|
|
EmptyContentError
|
|
);
|
|
assert.strictEqual(calls, 4);
|
|
});
|
|
|
|
test('chatWithRetry does not retry on non-retryable errors', async () => {
|
|
let calls = 0;
|
|
const provider = {
|
|
chat: async (): Promise<string> => {
|
|
calls++;
|
|
throw new Error('boom');
|
|
},
|
|
} as unknown as AIProvider;
|
|
|
|
await assert.rejects(() => chatWithRetry(provider, 'sys', 'user', OPTIONS), /boom/);
|
|
assert.strictEqual(calls, 1);
|
|
});
|
|
|
|
test('chatWithRetry retries on 429 and succeeds', async () => {
|
|
let calls = 0;
|
|
const provider = {
|
|
chat: async (): Promise<string> => {
|
|
calls++;
|
|
if (calls < 3) {
|
|
throw new ApiRequestError('API 请求失败 (429): rate limited', 429);
|
|
}
|
|
return '{"findings":[]}';
|
|
},
|
|
} as unknown as AIProvider;
|
|
|
|
const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS);
|
|
assert.strictEqual(result, '{"findings":[]}');
|
|
assert.strictEqual(calls, 3);
|
|
});
|
|
|
|
test('chatWithRetry retries 429 up to 3 times then throws', async () => {
|
|
let calls = 0;
|
|
const provider = {
|
|
chat: async (): Promise<string> => {
|
|
calls++;
|
|
throw new ApiRequestError('API 请求失败 (429): rate limited', 429);
|
|
},
|
|
} as unknown as AIProvider;
|
|
|
|
await assert.rejects(
|
|
() => chatWithRetry(provider, 'sys', 'user', OPTIONS),
|
|
(err: unknown) => err instanceof ApiRequestError
|
|
);
|
|
assert.strictEqual(calls, 4);
|
|
});
|
|
|
|
test('chatWithRetry fails immediately on 401', async () => {
|
|
let calls = 0;
|
|
const provider = {
|
|
chat: async (): Promise<string> => {
|
|
calls++;
|
|
throw new ApiRequestError('API Key 无效', 401);
|
|
},
|
|
} as unknown as AIProvider;
|
|
|
|
await assert.rejects(
|
|
() => chatWithRetry(provider, 'sys', 'user', OPTIONS),
|
|
(err: unknown) => err instanceof ApiRequestError
|
|
);
|
|
assert.strictEqual(calls, 1);
|
|
});
|
|
|
|
test('chatWithRetry retries on network error (TypeError)', async () => {
|
|
let calls = 0;
|
|
const provider = {
|
|
chat: async (): Promise<string> => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
throw new TypeError('fetch failed');
|
|
}
|
|
return '{"findings":[]}';
|
|
},
|
|
} as unknown as AIProvider;
|
|
|
|
const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS);
|
|
assert.strictEqual(result, '{"findings":[]}');
|
|
assert.strictEqual(calls, 2);
|
|
});
|
|
|
|
test('chatWithRetry retries on timeout (AbortError)', async () => {
|
|
let calls = 0;
|
|
const provider = {
|
|
chat: async (): Promise<string> => {
|
|
calls++;
|
|
if (calls === 1) {
|
|
throw new DOMException('The operation was aborted', 'AbortError');
|
|
}
|
|
return '{"findings":[]}';
|
|
},
|
|
} as unknown as AIProvider;
|
|
|
|
const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS);
|
|
assert.strictEqual(result, '{"findings":[]}');
|
|
assert.strictEqual(calls, 2);
|
|
});
|
|
|
|
test('chatWithRetry backoff is exponential', async () => {
|
|
setRetryBaseDelayForTest(10);
|
|
try {
|
|
const provider = {
|
|
chat: async (): Promise<string> => {
|
|
throw new ApiRequestError('API 请求失败 (503)', 503);
|
|
},
|
|
} as unknown as AIProvider;
|
|
|
|
const start = Date.now();
|
|
await assert.rejects(() => chatWithRetry(provider, 'sys', 'user', OPTIONS));
|
|
const elapsed = Date.now() - start;
|
|
assert.ok(elapsed >= 70, `expected backoff >= 70ms, got ${elapsed}ms`);
|
|
assert.ok(elapsed < 5000, `backoff too long: ${elapsed}ms`);
|
|
} finally {
|
|
setRetryBaseDelayForTest(1);
|
|
}
|
|
});
|
|
|
|
test('isRetryableError classifies error types', () => {
|
|
assert.strictEqual(isRetryableError(new EmptyContentError('x')), true);
|
|
assert.strictEqual(isRetryableError(new ApiRequestError('x', 429)), true);
|
|
assert.strictEqual(isRetryableError(new ApiRequestError('x', 500)), true);
|
|
assert.strictEqual(isRetryableError(new ApiRequestError('x', 503)), true);
|
|
assert.strictEqual(isRetryableError(new ApiRequestError('x', 401)), false);
|
|
assert.strictEqual(isRetryableError(new ApiRequestError('x', 400)), false);
|
|
assert.strictEqual(isRetryableError(new ApiRequestError('x')), false);
|
|
assert.strictEqual(isRetryableError(new TypeError('fetch failed')), true);
|
|
assert.strictEqual(isRetryableError(new DOMException('abort', 'AbortError')), true);
|
|
assert.strictEqual(isRetryableError(new Error('boom')), false);
|
|
});
|
|
|
|
test('openai-compatible reports max_tokens truncation clearly', async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async () => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [{ message: { content: null }, finish_reason: 'length' }],
|
|
}),
|
|
text: async () => '',
|
|
})) as unknown as typeof fetch;
|
|
|
|
try {
|
|
const provider = new OpenAICompatibleProvider('key', 'http://localhost', 'test', 'Test');
|
|
await assert.rejects(
|
|
() =>
|
|
provider.chat('sys', 'user', {
|
|
model: 'm',
|
|
temperature: 0,
|
|
maxTokens: 8192,
|
|
timeoutMs: 5000,
|
|
}),
|
|
(err: unknown) => {
|
|
assert.ok(err instanceof EmptyContentError);
|
|
assert.match((err as Error).message, /max_tokens/);
|
|
assert.match((err as Error).message, /8192/);
|
|
return true;
|
|
}
|
|
);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|