Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/app/v1/_lib/proxy/client-error-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { ALL_PROVIDERS_UNAVAILABLE_MESSAGE } from "./errors";
export interface DeriveClientSafeUpstreamErrorMessageInput {
rawText?: string;
candidateMessage?: string;
candidateMessages?: Array<string | null | undefined>;
providerName?: string | null;
forbiddenProviderLabels?: string[];
allowDefaultProviderLabels?: boolean;
}

const MAX_CLIENT_ERROR_MESSAGE_CHARS = 240;
Expand Down Expand Up @@ -36,7 +38,7 @@ const INTERNAL_ONLY_RE = new RegExp(
`^(?:FAKE_200_[A-Z0-9_]+|EMPTY_RESPONSE|HTTP\\s+\\d{3}|No available providers?|No available provider endpoints|${ALL_PROVIDERS_UNAVAILABLE_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})$`,
"iu"
);
const PROVIDER_PREFIX_RE = /\bProvider\s+[\w.-]+(?:\s+returned|\s*:|\s+-)/iu;
const PROVIDER_PREFIX_RE = /\bProvider\s+(?:returned\b|[\w.-]+(?:\s+returned|\s*:|\s+-))/iu;
// Reject both anchored-at-start and embedded raw JSON blobs; embedded JSON would otherwise
// leak provider-shaped payloads (e.g. `Bad request: {"error":{"message":"..."}}`).
const RAW_JSON_BLOB_ANCHORED_RE = /^[[{]/u;
Expand Down Expand Up @@ -161,17 +163,22 @@ function deriveCandidateFromRawText(rawText: string | undefined): string | null
return detected.detail;
}

/**
* 从上游原始响应文本或候选消息中提取并脱敏可安全返回给客户端的错误消息
*/
export function deriveClientSafeUpstreamErrorMessage(
input: DeriveClientSafeUpstreamErrorMessageInput
): string | null {
const labels = [
input.providerName ?? "",
...DEFAULT_FORBIDDEN_PROVIDER_LABELS,
...(input.allowDefaultProviderLabels ? [] : DEFAULT_FORBIDDEN_PROVIDER_LABELS),
...(input.forbiddenProviderLabels ?? []),
].filter(Boolean);
const candidates = [deriveCandidateFromRawText(input.rawText), input.candidateMessage].filter(
(value): value is string => typeof value === "string" && value.trim().length > 0
);
const candidates = [
deriveCandidateFromRawText(input.rawText),
input.candidateMessage,
...(input.candidateMessages ?? []),
].filter((value): value is string => typeof value === "string" && value.trim().length > 0);

for (const candidate of candidates) {
const trimmed = candidate.trim();
Expand Down
25 changes: 19 additions & 6 deletions src/app/v1/_lib/proxy/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { attachSessionIdToErrorResponse } from "./error-session-id";
import {
ALL_PROVIDERS_UNAVAILABLE_MESSAGE,
getErrorOverrideAsync,
hasMatchedErrorRuleWithoutOverrideResponse,
isEmptyResponseError,
isRateLimitError,
ProxyError,
Expand Down Expand Up @@ -113,16 +114,23 @@ function getGenericProxyErrorFallbackMessage(
}
}

/**
* 解析最终返回给客户端的错误消息
*
* 优先级:显式覆写响应体 > 命中无覆写错误规则白名单透传 > 系统设置上游错误透传 > 通用状态码兜底文案
*/
export function resolveFinalClientErrorMessage({
error,
currentFallbackMessage,
settings,
override,
matchedRuleWithoutOverride,
}: {
error: unknown;
currentFallbackMessage: string;
settings?: Pick<SystemSettings, "passThroughUpstreamErrorMessage"> | null;
override?: ErrorOverrideForMessageResolver;
matchedRuleWithoutOverride?: boolean;
}): string {
if (override?.response) {
return currentFallbackMessage;
Expand All @@ -137,20 +145,22 @@ export function resolveFinalClientErrorMessage({
error,
strippedFallback
);
const shouldPassThrough = settings?.passThroughUpstreamErrorMessage ?? true;
const isRuleMatchedWithoutResponseOverride =
matchedRuleWithoutOverride ?? hasMatchedErrorRuleWithoutOverrideResponse(error);
const shouldPassThrough =
isRuleMatchedWithoutResponseOverride || (settings?.passThroughUpstreamErrorMessage ?? true);
if (!shouldPassThrough) {
return fallback;
}

if (error instanceof ProxyError) {
return (
deriveClientSafeUpstreamErrorMessage({
rawText: error.upstreamError?.rawBody,
candidateMessage:
error.upstreamError?.safeClientMessageCandidate ??
error.upstreamError?.body ??
error.message,
rawText: error.upstreamError?.rawBody ?? error.upstreamError?.body,
candidateMessage: error.upstreamError?.safeClientMessageCandidate,
candidateMessages: [error.upstreamError?.body, error.message],
providerName: error.upstreamError?.providerName ?? null,
allowDefaultProviderLabels: isRuleMatchedWithoutResponseOverride,
}) ?? fallback
);
}
Expand Down Expand Up @@ -389,6 +399,7 @@ export class ProxyErrorHandler {
currentFallbackMessage: clientErrorMessage,
settings,
override: { response: null, statusCode: override.statusCode },
matchedRuleWithoutOverride: false,
});
return await finalizeErrorResponse(
ProxyResponses.buildError(
Expand All @@ -408,6 +419,7 @@ export class ProxyErrorHandler {
currentFallbackMessage: clientErrorMessage,
settings,
override: { response: null, statusCode: null },
matchedRuleWithoutOverride: false,
});
return await finalizeErrorResponse(
ProxyResponses.buildError(
Expand Down Expand Up @@ -439,6 +451,7 @@ export class ProxyErrorHandler {
currentFallbackMessage: clientErrorMessage,
settings,
override: hasExplicitOverrideMessage ? override : null,
matchedRuleWithoutOverride: false,
});
const responseBody = {
...override.response,
Expand Down
18 changes: 18 additions & 0 deletions src/app/v1/_lib/proxy/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,24 @@ export async function getErrorOverrideAsync(
return undefined;
}

/**
* 判断错误是否已命中错误规则且未配置响应体覆写
*
* 当正则/包含/精确规则命中但未配置 overrideResponse 时,
* 代表将该错误加入客户端错误白名单并期望原样透传脱敏后的上游错误消息。
*/
export function hasMatchedErrorRuleWithoutOverrideResponse(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const cached = errorDetectionCache.get(error);
return (
cached?.matched === true &&
!cached.overrideResponse &&
cached.hasConfiguredOverrideResponse !== true
);
}

/**
* 检测是否为客户端中断错误
*
Expand Down
9 changes: 5 additions & 4 deletions src/app/v1/_lib/proxy/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9097,6 +9097,9 @@ export class ProxyForwarder {
return true;
}

/**
* 构建所有供应商不可用时的终态 503 ProxyError,并携带最后一次上游错误的安全候选文案
*/
private static buildAllProvidersUnavailableError(finalError?: Error | null): ProxyError {
const safeClientMessageCandidate =
finalError instanceof ProxyError &&
Expand All @@ -9105,10 +9108,8 @@ export class ProxyForwarder {
finalError.upstreamError?.body)
? (deriveClientSafeUpstreamErrorMessage({
rawText: finalError.upstreamError?.rawBody ?? finalError.upstreamError?.body,
candidateMessage:
finalError.upstreamError?.safeClientMessageCandidate ??
finalError.upstreamError?.body ??
finalError.message,
candidateMessage: finalError.upstreamError?.safeClientMessageCandidate,
candidateMessages: [finalError.upstreamError?.body, finalError.message],
providerName: finalError.upstreamError?.providerName ?? null,
}) ?? undefined)
: undefined;
Expand Down
19 changes: 19 additions & 0 deletions src/lib/error-rule-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface ErrorDetectionResult {
description?: string; // 规则描述
/** 覆写响应体:如果配置了则用此响应替换原始错误响应 */
overrideResponse?: ErrorOverrideResponse;
/** 规则是否配置过响应体覆写(含因格式非法在加载时被拒绝的覆写) */
hasConfiguredOverrideResponse?: boolean;
/** 覆写状态码:如果配置了则用此状态码替换原始状态码 */
overrideStatusCode?: number;
}
Expand All @@ -42,6 +44,7 @@ interface RegexPattern {
category: string;
description?: string;
overrideResponse?: ErrorOverrideResponse;
hasConfiguredOverrideResponse?: boolean;
overrideStatusCode?: number;
}

Expand All @@ -55,6 +58,7 @@ interface ContainsPattern {
category: string;
description?: string;
overrideResponse?: ErrorOverrideResponse;
hasConfiguredOverrideResponse?: boolean;
overrideStatusCode?: number;
}

Expand All @@ -68,6 +72,7 @@ interface ExactPattern {
category: string;
description?: string;
overrideResponse?: ErrorOverrideResponse;
hasConfiguredOverrideResponse?: boolean;
overrideStatusCode?: number;
}

Expand Down Expand Up @@ -229,6 +234,9 @@ class ErrorRuleDetector {

for (const rule of rules) {
// 在加载阶段验证 overrideResponse 格式,过滤畸形数据
const hasConfiguredOverrideResponse =
rule.hasConfiguredOverrideResponse === true ||
(rule.overrideResponse !== null && rule.overrideResponse !== undefined);
let validatedOverrideResponse: ErrorOverrideResponse | undefined;
if (rule.overrideResponse) {
if (isValidErrorOverrideResponse(rule.overrideResponse)) {
Expand All @@ -251,6 +259,7 @@ class ErrorRuleDetector {
category: rule.category,
description: rule.description ?? undefined,
overrideResponse: validatedOverrideResponse,
...(hasConfiguredOverrideResponse ? { hasConfiguredOverrideResponse: true } : {}),
overrideStatusCode: rule.overrideStatusCode ?? undefined,
});
break;
Expand All @@ -265,6 +274,7 @@ class ErrorRuleDetector {
category: rule.category,
description: rule.description ?? undefined,
overrideResponse: validatedOverrideResponse,
...(hasConfiguredOverrideResponse ? { hasConfiguredOverrideResponse: true } : {}),
overrideStatusCode: rule.overrideStatusCode ?? undefined,
});
break;
Expand All @@ -289,6 +299,9 @@ class ErrorRuleDetector {
category: rule.category,
description: rule.description ?? undefined,
overrideResponse: validatedOverrideResponse,
...(hasConfiguredOverrideResponse
? { hasConfiguredOverrideResponse: true }
: {}),
overrideStatusCode: rule.overrideStatusCode ?? undefined,
});
validRegexCount++;
Expand Down Expand Up @@ -402,6 +415,7 @@ class ErrorRuleDetector {
matchType: "contains",
description: pattern.description,
overrideResponse: pattern.overrideResponse,
...(pattern.hasConfiguredOverrideResponse ? { hasConfiguredOverrideResponse: true } : {}),
overrideStatusCode: pattern.overrideStatusCode,
};
}
Expand All @@ -418,6 +432,9 @@ class ErrorRuleDetector {
matchType: "exact",
description: exactMatch.description,
overrideResponse: exactMatch.overrideResponse,
...(exactMatch.hasConfiguredOverrideResponse
? { hasConfiguredOverrideResponse: true }
: {}),
overrideStatusCode: exactMatch.overrideStatusCode,
};
}
Expand All @@ -430,6 +447,7 @@ class ErrorRuleDetector {
category,
description,
overrideResponse,
hasConfiguredOverrideResponse,
overrideStatusCode,
} of this.regexPatterns) {
if (pattern.test(errorMessage)) {
Expand All @@ -441,6 +459,7 @@ class ErrorRuleDetector {
matchType: "regex",
description,
overrideResponse,
...(hasConfiguredOverrideResponse ? { hasConfiguredOverrideResponse: true } : {}),
overrideStatusCode,
};
}
Expand Down
5 changes: 5 additions & 0 deletions src/repository/error-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface ErrorRule {
description: string | null;
/** 覆写响应体(JSON):匹配成功时用此响应替换原始错误响应,null 表示不覆写 */
overrideResponse: ErrorOverrideResponse | null;
/** 数据库中是否配置过非空 override_response(即使因格式非法被清理为 null) */
hasConfiguredOverrideResponse?: boolean;
/** 覆写状态码:null 表示透传上游状态码 */
overrideStatusCode: number | null;
isEnabled: boolean;
Expand Down Expand Up @@ -119,6 +121,9 @@ export async function getActiveErrorRules(): Promise<ErrorRule[]> {
r.overrideResponse,
`getActiveErrorRules id=${r.id}`
),
...(r.overrideResponse !== null && r.overrideResponse !== undefined
? { hasConfiguredOverrideResponse: true }
: {}),
overrideStatusCode: r.overrideStatusCode,
isEnabled: r.isEnabled,
isDefault: r.isDefault,
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/proxy/error-handler-client-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,34 @@ describe("resolveFinalClientErrorMessage", () => {

expect(message).toBe("custom override");
});

test("passes through message from ProxyError.fromUpstreamResponse when rawBody is undefined and body is JSON", async () => {
const upstreamResponse = new Response(
JSON.stringify({
error: {
message: 'level "minimal" not supported, valid levels: low, medium, high',
type: "invalid_request_error",
},
}),
{
status: 400,
headers: { "content-type": "application/json" },
}
);
const error = await ProxyError.fromUpstreamResponse(upstreamResponse, {
id: 89,
name: "cpa-us-gemini",
});

const message = resolveFinalClientErrorMessage({
error,
currentFallbackMessage: error.getClientSafeMessage(),
settings: { passThroughUpstreamErrorMessage: true },
override: null,
});

expect(message).toBe('level "minimal" not supported, valid levels: low, medium, high');
});
});

describe("ProxyErrorHandler.handle client message", () => {
Expand Down
Loading
Loading