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:
+23
-7
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user