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
+23 -7
View File
@@ -1,5 +1,5 @@
import * as vscode from 'vscode';
import { EmptyContentError } from './providers/base';
import { isRetryableError } from './providers/base';
import type { AIProvider, ChatOptions } from './providers/base';
import { createProvider } from './factory';
import { getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIMaxTokens, getAIOutputLanguage, getApiKey } from '../config';
@@ -83,20 +83,36 @@ export function parseJsonResponse(raw: string): object {
}
}
const RETRY_MAX_ATTEMPTS = 3;
const RETRY_BASE_DELAY_MS = 1000;
let retryBaseDelayMs = RETRY_BASE_DELAY_MS;
export function setRetryBaseDelayForTest(ms: number): void {
retryBaseDelayMs = ms;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function chatWithRetry(
provider: AIProvider,
systemPrompt: string,
userPrompt: string,
options: ChatOptions
): Promise<string> {
try {
return await provider.chat(systemPrompt, userPrompt, options);
} catch (err) {
if (err instanceof EmptyContentError) {
return provider.chat(systemPrompt, userPrompt, options);
for (let attempt = 0; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
try {
return await provider.chat(systemPrompt, userPrompt, options);
} catch (err) {
if (attempt === RETRY_MAX_ATTEMPTS || !isRetryableError(err)) {
throw err;
}
await sleep(retryBaseDelayMs * 2 ** attempt);
}
throw err;
}
throw new Error('unreachable');
}
function buildCustomRuleSystemPrompt(): string {
+29
View File
@@ -28,3 +28,32 @@ export class EmptyContentError extends Error {
this.name = 'EmptyContentError';
}
}
export class ApiRequestError extends Error {
readonly status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'ApiRequestError';
this.status = status;
}
}
export function isRetryableError(err: unknown): boolean {
if (err instanceof EmptyContentError) {
return true;
}
if (err instanceof ApiRequestError) {
if (err.status === undefined) {
return false;
}
return err.status === 429 || err.status >= 500;
}
if (err instanceof DOMException && err.name === 'AbortError') {
return true;
}
if (err instanceof TypeError) {
return true;
}
return false;
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { AIProvider, ChatOptions, EmptyContentError } from './base';
import { AIProvider, ChatOptions, EmptyContentError, ApiRequestError } from './base';
import { t } from '../../i18n/messages';
export class ClaudeProvider extends AIProvider {
@@ -36,9 +36,9 @@ export class ClaudeProvider extends AIProvider {
if (!response.ok) {
const errorText = await response.text();
if (response.status === 401) {
throw new Error('API Key 无效,请重新设置');
throw new ApiRequestError('API Key 无效,请重新设置', 401);
}
throw new Error(`Claude API 请求失败 (${response.status}): ${errorText}`);
throw new ApiRequestError(`Claude API 请求失败 (${response.status}): ${errorText}`, response.status);
}
const data = await response.json() as {
+2 -2
View File
@@ -1,4 +1,4 @@
import { AIProvider, ChatOptions, EmptyContentError } from './base';
import { AIProvider, ChatOptions, EmptyContentError, ApiRequestError } from './base';
import { t } from '../../i18n/messages';
export class GeminiProvider extends AIProvider {
@@ -36,7 +36,7 @@ export class GeminiProvider extends AIProvider {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini API 请求失败 (${response.status}): ${errorText}`);
throw new ApiRequestError(`Gemini API 请求失败 (${response.status}): ${errorText}`, response.status);
}
const data = await response.json() as {
+3 -3
View File
@@ -1,4 +1,4 @@
import { AIProvider, ChatOptions, EmptyContentError } from './base';
import { AIProvider, ChatOptions, EmptyContentError, ApiRequestError } from './base';
import { t } from '../../i18n/messages';
export class OpenAICompatibleProvider extends AIProvider {
@@ -47,9 +47,9 @@ export class OpenAICompatibleProvider extends AIProvider {
if (!response.ok) {
const errorText = await response.text();
if (response.status === 401) {
throw new Error(t('adapter.invalidApiKey'));
throw new ApiRequestError(t('adapter.invalidApiKey'), 401);
}
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
throw new ApiRequestError(`API 请求失败 (${response.status}): ${errorText}`, response.status);
}
const data = await response.json() as {
+17 -17
View File
@@ -23,22 +23,12 @@ async function requestFix(
message: diag.message,
suggestion: diag.suggestion,
};
const attempt = async (): Promise<AiCodeFix | null> => {
try {
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(issueInput, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
} catch {
return null;
}
};
const first = await attempt();
if (first) { return first; }
return attempt();
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(issueInput, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
}
function sameRuleAtRegion(
@@ -112,7 +102,17 @@ export async function aiFixDiagnostic(
for (let round = 1; round <= maxIterations; round++) {
const context = buildFixContext(currentText, diag.range.start.line);
const fix = await requestFix(provider, options, diag, context);
let fix: AiCodeFix | null;
try {
fix = await requestFix(provider, options, diag, context);
} catch (err) {
return {
success: false,
attempts: round,
message: `ai-error: ${err instanceof Error ? err.message : String(err)}`,
appliedFixes,
};
}
if (!fix || fix.originalText.trim() === '') {
return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };
}
+17 -17
View File
@@ -20,22 +20,12 @@ async function requestFix(
diag: ReviewIssueInput,
context: string
): Promise<AiCodeFix | null> {
const attempt = async (): Promise<AiCodeFix | null> => {
try {
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(diag, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
} catch {
return null;
}
};
const first = await attempt();
if (first) { return first; }
return attempt();
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(diag, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
}
async function verifyFixed(
@@ -108,7 +98,17 @@ export async function aiFixReviewIssue(
for (let round = 1; round <= maxIterations; round++) {
const context = buildFixContext(currentText, diag.line);
const fix = await requestFix(provider, options, diag, context);
let fix: AiCodeFix | null;
try {
fix = await requestFix(provider, options, diag, context);
} catch (err) {
return {
success: false,
attempts: round,
message: `ai-error: ${err instanceof Error ? err.message : String(err)}`,
appliedFixes,
};
}
if (!fix || fix.originalText.trim() === '') {
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'ai-no-fix');
return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };