Handle overloaded AI provider responses

- Preserve upstream AI provider HTTP status codes and expose AI-specific error codes for overload, rate limit, unavailable, and generic provider failures.

- Add short retries for transient AI provider 429/5xx chat completion responses, with configurable retry delays.

- Show friendly AI busy/unavailable messages in the report editor instead of raw provider JSON.

- Preserve custom backend error codes in the shared API exception filter.

- Add AI service tests for retry behavior and overload error mapping.

- Update API, feature, and testing documentation for AI proxy retry and error handling.
This commit is contained in:
2026-05-09 03:35:55 +08:00
parent 1faaa05032
commit 95a4678340
7 changed files with 174 additions and 9 deletions

View File

@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SafeUser } from '../auth/auth.types';
import { AiService } from './ai.service';
const actor: SafeUser = {
id: 'user-1',
username: 'admin',
role: 'super',
name: '管理员',
tenantId: 'tenant-1',
departmentId: 'dept-1',
departmentName: '外科',
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const createService = () => {
const settingsService = {
getSystemSettings: vi.fn().mockResolvedValue({
activeAiProvider: 'kimi',
aiProviders: {
kimi: {
endpoint: 'https://provider.example/v1',
apiKey: 'test-key',
modelName: 'moonshot-v1',
},
},
}),
};
return new AiService(settingsService as never);
};
describe('AiService', () => {
const originalRetryDelays = process.env.AI_PROVIDER_RETRY_DELAYS_MS;
beforeEach(() => {
process.env.AI_PROVIDER_RETRY_DELAYS_MS = '0,0';
});
afterEach(() => {
vi.unstubAllGlobals();
if (originalRetryDelays === undefined) {
delete process.env.AI_PROVIDER_RETRY_DELAYS_MS;
} else {
process.env.AI_PROVIDER_RETRY_DELAYS_MS = originalRetryDelays;
}
});
it('retries transient provider overloads and then returns a completion', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已完善"}' } }] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = await createService().chat(actor, {
messages: [{ role: 'user', content: '请完善报告内容' }],
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(response).toEqual({ choices: [{ message: { content: '{"reply":"已完善"}' } }] });
});
it('preserves provider overload status and exposes an AI-specific error code', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }),
);
vi.stubGlobal('fetch', fetchMock);
await expect(createService().chat(actor, {
messages: [{ role: 'user', content: '请完善报告内容' }],
})).rejects.toMatchObject({
response: expect.objectContaining({
code: 'AI_PROVIDER_OVERLOADED',
message: expect.stringContaining('AI 服务请求失败429'),
}),
status: 429,
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, HttpException, Injectable } from '@nestjs/common';
import type { SafeUser } from '../auth/auth.types.js';
import { SettingsService } from '../settings/settings.service.js';
import { aiChatSchema } from './ai.schemas.js';
@@ -9,6 +9,9 @@ interface AiProvider {
modelName: string;
}
const RETRYABLE_PROVIDER_STATUSES = new Set([429, 500, 502, 503, 504]);
const DEFAULT_RETRY_DELAYS_MS = [600, 1200];
@Injectable()
export class AiService {
constructor(private readonly settingsService: SettingsService) {}
@@ -22,7 +25,7 @@ export class AiService {
const payload = await this.parseProviderResponse(response);
if (!response.ok) {
throw new BadRequestException(this.formatProviderError(response.status, payload));
throw this.createProviderException(response.status, payload);
}
const models = Array.isArray((payload as { data?: unknown[] }).data)
@@ -54,7 +57,7 @@ export class AiService {
model: provider.modelName || input.model,
};
const response = await this.fetchProvider(`${provider.endpoint}/chat/completions`, {
const response = await this.fetchProviderWithRetry(`${provider.endpoint}/chat/completions`, {
method: 'POST',
headers: this.headers(provider),
body: JSON.stringify(payload),
@@ -62,7 +65,7 @@ export class AiService {
const responsePayload = await this.parseProviderResponse(response);
if (!response.ok) {
throw new BadRequestException(this.formatProviderError(response.status, responsePayload));
throw this.createProviderException(response.status, responsePayload);
}
return responsePayload;
@@ -114,6 +117,17 @@ export class AiService {
}
}
private async fetchProviderWithRetry(url: string, init: RequestInit) {
const retryDelays = this.retryDelays();
let response = await this.fetchProvider(url, init);
for (const delayMs of retryDelays) {
if (!RETRYABLE_PROVIDER_STATUSES.has(response.status)) break;
await this.sleep(delayMs);
response = await this.fetchProvider(url, init);
}
return response;
}
private formatProviderError(status: number, payload: unknown) {
const message =
typeof payload === 'object' && payload !== null && 'error' in payload
@@ -123,4 +137,47 @@ export class AiService {
: JSON.stringify(payload);
return `AI 服务请求失败:${status}${message ? ` - ${message}` : ''}`;
}
private createProviderException(status: number, payload: unknown) {
return new HttpException(
{
code: this.providerErrorCode(status, payload),
message: this.formatProviderError(status, payload),
},
status,
);
}
private providerErrorCode(status: number, payload: unknown) {
const providerType =
typeof payload === 'object' && payload !== null && 'type' in payload
? String((payload as { type: unknown }).type)
: typeof payload === 'object' && payload !== null && 'error' in payload
? this.extractProviderErrorType((payload as { error: unknown }).error)
: '';
if (status === 429 && /overloaded/i.test(providerType)) return 'AI_PROVIDER_OVERLOADED';
if (status === 429) return 'AI_PROVIDER_RATE_LIMITED';
if (status >= 500) return 'AI_PROVIDER_UNAVAILABLE';
return 'AI_PROVIDER_ERROR';
}
private extractProviderErrorType(error: unknown) {
return typeof error === 'object' && error !== null && 'type' in error
? String((error as { type: unknown }).type)
: '';
}
private retryDelays() {
const raw = process.env.AI_PROVIDER_RETRY_DELAYS_MS;
if (!raw) return DEFAULT_RETRY_DELAYS_MS;
return raw
.split(',')
.map((value) => Number(value.trim()))
.filter((value) => Number.isFinite(value) && value >= 0);
}
private sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -26,14 +26,19 @@ export class ApiExceptionFilter implements ExceptionFilter {
response.status(status).json({
error: {
code: this.resolveCode(status),
code: this.resolveCode(status, payload),
message,
},
requestId: response.getHeader('x-request-id') ?? undefined,
});
}
private resolveCode(status: number) {
private resolveCode(status: number, payload?: unknown) {
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
const code = (payload as { code?: unknown }).code;
if (typeof code === 'string' && code) return code;
}
switch (status) {
case HttpStatus.BAD_REQUEST:
return 'BAD_REQUEST';
@@ -41,10 +46,14 @@ export class ApiExceptionFilter implements ExceptionFilter {
return 'UNAUTHORIZED';
case HttpStatus.FORBIDDEN:
return 'FORBIDDEN';
case HttpStatus.TOO_MANY_REQUESTS:
return 'TOO_MANY_REQUESTS';
case HttpStatus.NOT_FOUND:
return 'NOT_FOUND';
case HttpStatus.CONFLICT:
return 'CONFLICT';
case HttpStatus.SERVICE_UNAVAILABLE:
return 'SERVICE_UNAVAILABLE';
case HttpStatus.UNPROCESSABLE_ENTITY:
return 'VALIDATION_ERROR';
default: