Skip to content

Commit f89d8a0

Browse files
committed
fix: 网络中断与标签缺失不再归为未知 AI 错误
OpenAI 兼容渠道单独实现的错误归类缺少 undici 的 terminated、socket hang up 以及标签缺失、空正文等关键词,导致任务列表只显示「发生未知 AI 错误」。 改为与各渠道共用 parseErrorCode:连接中断归为 AI_CONNECTION_FAILED, 空正文与标签缺失归为 AI_RESPONSE_ERROR。 Refs: #15
1 parent 02a2e7c commit f89d8a0

4 files changed

Lines changed: 45 additions & 45 deletions

File tree

‎src/__tests__/unit/ai/error-classification.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ describe('parseErrorCode', () => {
104104
it('匹配 econnreset', () => {
105105
expect(parseErrorCode(new Error('ECONNRESET'))).toBe('AI_CONNECTION_FAILED');
106106
});
107+
it('匹配 undici 连接中断 terminated', () => {
108+
expect(parseErrorCode(new TypeError('terminated'))).toBe('AI_CONNECTION_FAILED');
109+
});
110+
it('匹配 socket hang up', () => {
111+
expect(parseErrorCode(new Error('socket hang up'))).toBe('AI_CONNECTION_FAILED');
112+
});
113+
it('匹配 other side closed', () => {
114+
expect(parseErrorCode(new Error('other side closed'))).toBe('AI_CONNECTION_FAILED');
115+
});
107116
});
108117

109118
describe('AI_TIMEOUT_ERROR', () => {
@@ -149,6 +158,12 @@ describe('parseErrorCode', () => {
149158
it('匹配 missing critical xml', () => {
150159
expect(parseErrorCode(new Error('missing critical xml tag'))).toBe('AI_RESPONSE_ERROR');
151160
});
161+
it('匹配空正文', () => {
162+
expect(parseErrorCode(new Error('Empty response from AI'))).toBe('AI_RESPONSE_ERROR');
163+
});
164+
it('匹配标签缺失的完整错误', () => {
165+
expect(parseErrorCode(new Error('Invalid AI response: Missing critical XML tags (<question_text>, <answer_text>, or <analysis>)'))).toBe('AI_RESPONSE_ERROR');
166+
});
152167
});
153168

154169
describe('AI_UNKNOWN_ERROR', () => {

‎src/__tests__/unit/ai/openai-provider.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,11 @@ describe('OpenAI Provider 错误处理', () => {
283283
expect(() => asPrivateProvider(provider).handleError(networkError)).toThrow('AI_CONNECTION_FAILED');
284284
});
285285

286+
it('应该将 undici 连接中断转换为 AI_CONNECTION_FAILED', () => {
287+
const terminated = new TypeError('terminated');
288+
expect(() => asPrivateProvider(provider).handleError(terminated)).toThrow('AI_CONNECTION_FAILED');
289+
});
290+
286291
it('应该将认证错误转换为 AI_AUTH_ERROR', () => {
287292
const authError = new Error('Unauthorized: Invalid API key');
288293
expect(() => asPrivateProvider(provider).handleError(authError)).toThrow('AI_AUTH_ERROR');
@@ -293,6 +298,16 @@ describe('OpenAI Provider 错误处理', () => {
293298
expect(() => asPrivateProvider(provider).handleError(parseError)).toThrow('AI_RESPONSE_ERROR');
294299
});
295300

301+
it('应该将标签缺失转换为 AI_RESPONSE_ERROR', () => {
302+
const missingTags = new Error('Invalid AI response: Missing critical XML tags (<question_text>, <answer_text>, or <analysis>)');
303+
expect(() => asPrivateProvider(provider).handleError(missingTags)).toThrow('AI_RESPONSE_ERROR');
304+
});
305+
306+
it('应该将空正文转换为 AI_RESPONSE_ERROR', () => {
307+
const empty = new Error('Empty response from AI');
308+
expect(() => asPrivateProvider(provider).handleError(empty)).toThrow('AI_RESPONSE_ERROR');
309+
});
310+
296311
it('未知错误应该转换为 AI_UNKNOWN_ERROR', () => {
297312
const unknownError = new Error('Something went wrong');
298313
expect(() => asPrivateProvider(provider).handleError(unknownError)).toThrow('AI_UNKNOWN_ERROR');
@@ -477,7 +492,7 @@ describe('OpenAI Provider 修正题目文字', () => {
477492
mockCompletionCreate.mockResolvedValue({ choices: null });
478493
const provider = new OpenAIProvider({ apiKey: 'test-key', model: 'gpt-4o' });
479494

480-
await expect(provider.fixQuestionText([{ id: 'q1', text: '100公项' }])).rejects.toThrow('AI_UNKNOWN_ERROR');
495+
await expect(provider.fixQuestionText([{ id: 'q1', text: '100公项' }])).rejects.toThrow('AI_RESPONSE_ERROR');
481496
expect(mockCompletionCreate.mock.calls.at(-1)?.[0].messages).toBeDefined();
482497
});
483498
});

‎src/lib/ai/error-classification.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,11 @@ export function parseErrorCode(error: unknown): string {
5656
msgLower.includes('balance') || msgLower.includes('余额') || msgLower.includes('积分') || msgLower.includes('credit')) {
5757
return 'AI_QUOTA_EXCEEDED';
5858
}
59-
// 网络/连接错误
59+
// 网络/连接错误:undici 在 HTTP/2 连接中断时抛 TypeError「terminated」,另有 socket hang up 等
6060
if (msgLower.includes('fetch failed') || msgLower.includes('network') || msgLower.includes('connect') ||
6161
msgLower.includes('enotfound') || msgLower.includes('econnrefused') || msgLower.includes('etimedout') ||
62-
msgLower.includes('econnreset')) {
62+
msgLower.includes('econnreset') || msgLower.includes('terminated') || msgLower.includes('socket hang up') ||
63+
msgLower.includes('other side closed') || msgLower.includes('epipe')) {
6364
return 'AI_CONNECTION_FAILED';
6465
}
6566
// 超时 (包括 408)
@@ -71,8 +72,10 @@ export function parseErrorCode(error: unknown): string {
7172
msgLower.includes('无可用') || msgLower.includes('overloaded') || msgLower.includes('unavailable')) {
7273
return 'AI_SERVICE_UNAVAILABLE';
7374
}
74-
// AI 响应格式错误
75-
if (msgLower.includes('invalid json') || msgLower.includes('parse') || msgLower.includes('json') || msgLower.includes('missing critical xml')) {
75+
// AI 响应格式错误:标签缺失、空正文与 JSON 解析失败都按返回异常处理
76+
if (msgLower.includes('invalid json') || msgLower.includes('parse') || msgLower.includes('json') ||
77+
msgLower.includes('missing critical xml') || msgLower.includes('invalid ai response') ||
78+
msgLower.includes('empty response')) {
7679
return 'AI_RESPONSE_ERROR';
7780
}
7881

‎src/lib/ai/openai-provider.ts‎

Lines changed: 7 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { AI_OUTPUT_TRUNCATED, withModelOutputLimit } from './truncation';
1010
import type { FigureResult } from './schema';
1111
import { parseFigure } from './schema';
1212
import { extractTag } from './response-parser';
13-
import { isAIErrorCode } from './error-classification';
13+
import { isAIErrorCode, parseErrorCode } from './error-classification';
1414

1515
const logger = createLogger('ai:openai');
1616

@@ -238,45 +238,12 @@ export class OpenAIProvider extends BaseAIProvider implements AIService {
238238

239239
protected handleError(error: unknown): void {
240240
logger.error({ error }, 'OpenAI error');
241-
if (error instanceof Error) {
242-
// 已经归类的错误码原样透传,避免再次归类时丢失(如 AI_AUTH_ERROR 被当成未知错误)
243-
if (error.message === AI_OUTPUT_TRUNCATED || isAIErrorCode(error.message)) {
244-
throw error;
245-
}
246-
const msg = error.message.toLowerCase();
247-
if (msg.includes('fetch failed') || msg.includes('network') || msg.includes('connect')) {
248-
throw new Error("AI_CONNECTION_FAILED", { cause: error });
249-
}
250-
// 超时错误 (包括 408 Request Timeout)
251-
if (msg.includes('timeout') || msg.includes('timed out') || msg.includes('aborted') || msg.includes('408')) {
252-
throw new Error("AI_TIMEOUT_ERROR", { cause: error });
253-
}
254-
// 配额/频率限制/余额不足错误:402 与余额、积分相关文案同样按额度不足处理
255-
if (msg.includes('quota') || msg.includes('额度') || msg.includes('402') || msg.includes('insufficient') ||
256-
msg.includes('balance') || msg.includes('余额') || msg.includes('积分') || msg.includes('credit') ||
257-
msg.includes('rate limit') || msg.includes('429') || msg.includes('too many')) {
258-
throw new Error("AI_QUOTA_EXCEEDED", { cause: error });
259-
}
260-
// 权限/403 错误
261-
if (msg.includes('403') || msg.includes('forbidden') || msg.includes('permission')) {
262-
throw new Error("AI_PERMISSION_DENIED", { cause: error });
263-
}
264-
// 资源不存在/404 错误
265-
if (msg.includes('404') || msg.includes('not found') || msg.includes('does not exist')) {
266-
throw new Error("AI_NOT_FOUND", { cause: error });
267-
}
268-
// 服务器错误 (500/502/503/504)
269-
if (msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('504') ||
270-
msg.includes('无可用') || msg.includes('overloaded') || msg.includes('unavailable')) {
271-
throw new Error("AI_SERVICE_UNAVAILABLE", { cause: error });
272-
}
273-
if (msg.includes('invalid json') || msg.includes('parse')) {
274-
throw new Error("AI_RESPONSE_ERROR", { cause: error });
275-
}
276-
if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) {
277-
throw new Error("AI_AUTH_ERROR", { cause: error });
278-
}
241+
// 已经归类的错误码原样透传,避免再次归类时丢失(如 AI_AUTH_ERROR 被当成未知错误)
242+
if (error instanceof Error && (error.message === AI_OUTPUT_TRUNCATED || isAIErrorCode(error.message))) {
243+
throw error;
279244
}
280-
throw new Error("AI_UNKNOWN_ERROR", { cause: error });
245+
// 与各渠道共用同一套归类规则:网络中断、空正文、标签缺失等不再落到未知错误
246+
const code = parseErrorCode(error);
247+
throw new Error(code, { cause: error });
281248
}
282249
}

0 commit comments

Comments
 (0)