feat: AI 调用稳定性增强(可重试错误分类 + 指数退避 + 修复链路错误透传)

- providers/base.ts 引入 ApiRequestError 与 isRetryableError,按 429/5xx/超时/网络错误/401 分类
- chatWithRetry 升级为最多 3 次指数退避(1s/2s/4s),非重试错误立即上抛;新增 setRetryBaseDelayForTest 钩子
- aiFixEngine/customFixEngine 不再吞错,修复失败原因透传为 ai-error: <原因>
- 扩展 ai-empty-response/customFixEngine 测试覆盖重试与分类
This commit is contained in:
范智鹏
2026-08-31 21:57:54 +08:00
parent 458647d82e
commit d6b8a3c897
11 changed files with 287 additions and 55 deletions
+125 -3
View File
@@ -1,6 +1,6 @@
import * as assert from 'assert';
import { parseJsonResponse, chatWithRetry } from '../src/ai/engine';
import { EmptyContentError } from '../src/ai/providers/base';
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';
@@ -13,6 +13,10 @@ const OPTIONS: ChatOptions = {
};
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/);
@@ -41,8 +45,10 @@ suite('AI Empty Response Handling', () => {
});
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;
@@ -51,9 +57,10 @@ suite('AI Empty Response Handling', () => {
() => chatWithRetry(provider, 'sys', 'user', OPTIONS),
EmptyContentError
);
assert.strictEqual(calls, 4);
});
test('chatWithRetry does not retry on non-empty-content errors', async () => {
test('chatWithRetry does not retry on non-retryable errors', async () => {
let calls = 0;
const provider = {
chat: async (): Promise<string> => {
@@ -66,6 +73,121 @@ suite('AI Empty Response Handling', () => {
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 () => ({
+1 -1
View File
@@ -83,7 +83,7 @@ suite('Custom FixEngine Tests', () => {
assert.strictEqual(result.appliedFixes.length, 1);
});
test('aiFixReviewIssue retries empty fix once then fails with ai-no-fix', async () => {
test('aiFixReviewIssue fails with ai-no-fix when AI provides empty fix', async () => {
const doc = await vscode.workspace.openTextDocument({
content: 'const x = BAD;\n',
language: 'javascript',