diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json
index 6256e867e..6bd2beb95 100644
--- a/messages/en/dashboard.json
+++ b/messages/en/dashboard.json
@@ -244,6 +244,18 @@
},
"errorMessage": "Error Message",
"fake200ForwardedNotice": "Note: For streaming requests, this failure may be detected only after the stream ends; the response content may already have been forwarded to the client.",
+ "fake200DetectedReason": "Detected reason: {reason}",
+ "fake200Reasons": {
+ "emptyBody": "Empty response body",
+ "htmlBody": "HTML document returned (likely an error page)",
+ "jsonErrorNonEmpty": "JSON has a non-empty `error` field",
+ "jsonErrorMessageNonEmpty": "JSON has a non-empty `error.message`",
+ "jsonMessageKeywordMatch": "JSON `message` contains the word \"error\" (heuristic)",
+ "unknown": "Response body indicates an error"
+ },
+ "statusCodeInferredBadge": "Inferred",
+ "statusCodeInferredTooltip": "This status code is inferred from response body content (e.g., fake 200) and may differ from the upstream HTTP status.",
+ "statusCodeInferredSuffix": "(inferred)",
"filteredProviders": "Filtered Providers",
"providerChain": {
"title": "Provider Decision Chain Timeline",
diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json
index e8f6678a5..bf9cb81a7 100644
--- a/messages/en/provider-chain.json
+++ b/messages/en/provider-chain.json
@@ -35,6 +35,7 @@
"candidate": "{name}({probability}%)",
"requestChain": "Request Chain:",
"systemError": "System Error",
+ "resourceNotFound": "Resource Not Found (404)",
"concurrentLimit": "Concurrent Limit",
"http2Fallback": "HTTP/2 Fallback",
"clientError": "Client Error",
@@ -46,6 +47,7 @@
"retry_success": "Retry Success",
"retry_failed": "Retry Failed",
"system_error": "System Error",
+ "resource_not_found": "Resource Not Found (404)",
"client_error_non_retryable": "Client Error",
"concurrent_limit_failed": "Concurrent Limit",
"http2_fallback": "HTTP/2 Fallback",
@@ -128,11 +130,13 @@
"candidateInfo": " • {name}: weight={weight} cost={cost} probability={probability}%",
"selected": "✓ Selected: {provider}",
"requestFailed": "Request Failed (Attempt {attempt})",
+ "resourceNotFoundFailed": "Resource Not Found (404) (Attempt {attempt})",
"attemptNumber": "Attempt {number}",
"firstAttempt": "First Attempt",
"nthAttempt": "Attempt {attempt}",
"provider": "Provider: {provider}",
"statusCode": "Status Code: {code}",
+ "statusCodeInferred": "Status Code (inferred): {code}",
"error": "Error: {error}",
"requestDuration": "Request Duration: {duration}ms",
"requestDurationSeconds": "Request Duration: {duration}s",
@@ -158,6 +162,7 @@
"meaning": "Meaning",
"notCountedInCircuit": "This error is not counted in provider circuit breaker",
"systemErrorNote": "Note: This error is not counted in provider circuit breaker",
+ "resourceNotFoundNote": "Note: This error is not counted in the circuit breaker and will trigger failover after retries are exhausted.",
"reselection": "Reselecting Provider",
"reselect": "Reselecting Provider",
"excluded": "Excluded: {providers}",
diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json
index 3a4aae78d..1d2729f76 100644
--- a/messages/ja/dashboard.json
+++ b/messages/ja/dashboard.json
@@ -244,6 +244,18 @@
},
"errorMessage": "エラーメッセージ",
"fake200ForwardedNotice": "注意:ストリーミング要求では、失敗判定がストリーム終了後になる場合があります。応答内容は既にクライアントへ転送されている可能性があります。",
+ "fake200DetectedReason": "検出理由:{reason}",
+ "fake200Reasons": {
+ "emptyBody": "レスポンス本文が空です",
+ "htmlBody": "HTML ドキュメントが返されました (エラーページの可能性)",
+ "jsonErrorNonEmpty": "JSON の `error` フィールドが空ではありません",
+ "jsonErrorMessageNonEmpty": "JSON の `error.message` が空ではありません",
+ "jsonMessageKeywordMatch": "JSON の `message` に \"error\" が含まれています (ヒューリスティック)",
+ "unknown": "レスポンス本文がエラーを示しています"
+ },
+ "statusCodeInferredBadge": "推定",
+ "statusCodeInferredTooltip": "このステータスコードは応答本文の内容(例: fake 200)から推定されており、上流の HTTP ステータスと異なる場合があります。",
+ "statusCodeInferredSuffix": "(推定)",
"filteredProviders": "フィルタされたプロバイダー",
"providerChain": {
"title": "プロバイダー決定チェーンタイムライン",
diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json
index cf9ebdb78..d8e55285b 100644
--- a/messages/ja/provider-chain.json
+++ b/messages/ja/provider-chain.json
@@ -35,6 +35,7 @@
"candidate": "{name}({probability}%)",
"requestChain": "リクエストチェーン:",
"systemError": "システムエラー",
+ "resourceNotFound": "リソースが見つかりません(404)",
"concurrentLimit": "同時実行制限",
"http2Fallback": "HTTP/2 フォールバック",
"clientError": "クライアントエラー",
@@ -46,6 +47,7 @@
"retry_success": "リトライ成功",
"retry_failed": "リトライ失敗",
"system_error": "システムエラー",
+ "resource_not_found": "リソースが見つかりません(404)",
"client_error_non_retryable": "クライアントエラー",
"concurrent_limit_failed": "同時実行制限",
"http2_fallback": "HTTP/2 フォールバック",
@@ -128,11 +130,13 @@
"candidateInfo": " • {name}: 重み={weight} コスト={cost} 確率={probability}%",
"selected": "✓ 選択: {provider}",
"requestFailed": "リクエスト失敗(試行{attempt})",
+ "resourceNotFoundFailed": "リソースが見つかりません(404)(試行{attempt})",
"attemptNumber": "試行 {number}",
"firstAttempt": "初回試行",
"nthAttempt": "試行{attempt}",
"provider": "プロバイダー: {provider}",
"statusCode": "ステータスコード: {code}",
+ "statusCodeInferred": "ステータスコード(推定): {code}",
"error": "エラー: {error}",
"requestDuration": "リクエスト時間: {duration}ms",
"requestDurationSeconds": "リクエスト時間: {duration}s",
@@ -158,6 +162,7 @@
"meaning": "意味",
"notCountedInCircuit": "このエラーはプロバイダーサーキットブレーカーにカウントされません",
"systemErrorNote": "注記:このエラーはプロバイダーサーキットブレーカーにカウントされません",
+ "resourceNotFoundNote": "注記:このエラーはサーキットブレーカーにカウントされず、リトライ枯渇後にフェイルオーバーします。",
"reselection": "プロバイダー再選択",
"reselect": "プロバイダー再選択",
"excluded": "除外済み: {providers}",
diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json
index 6c46440e6..867d3449a 100644
--- a/messages/ru/dashboard.json
+++ b/messages/ru/dashboard.json
@@ -244,6 +244,18 @@
},
"errorMessage": "Сообщение об ошибке",
"fake200ForwardedNotice": "Примечание: для потоковых запросов эта ошибка может быть обнаружена только после завершения потока; содержимое ответа могло уже быть передано клиенту.",
+ "fake200DetectedReason": "Причина обнаружения: {reason}",
+ "fake200Reasons": {
+ "emptyBody": "Пустое тело ответа",
+ "htmlBody": "Получен HTML-документ (возможно, страница ошибки)",
+ "jsonErrorNonEmpty": "В JSON непустое поле `error`",
+ "jsonErrorMessageNonEmpty": "В JSON непустое `error.message`",
+ "jsonMessageKeywordMatch": "В JSON `message` содержит слово \"error\" (эвристика)",
+ "unknown": "Тело ответа указывает на ошибку"
+ },
+ "statusCodeInferredBadge": "Предположено",
+ "statusCodeInferredTooltip": "Этот код состояния выведен по содержимому тела ответа (например, fake 200) и может отличаться от HTTP-кода апстрима.",
+ "statusCodeInferredSuffix": "(предп.)",
"filteredProviders": "Отфильтрованные поставщики",
"providerChain": {
"title": "Хронология цепочки решений поставщика",
diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json
index c123208b8..10019665b 100644
--- a/messages/ru/provider-chain.json
+++ b/messages/ru/provider-chain.json
@@ -35,6 +35,7 @@
"candidate": "{name}({probability}%)",
"requestChain": "Цепочка запросов:",
"systemError": "Системная ошибка",
+ "resourceNotFound": "Ресурс не найден (404)",
"concurrentLimit": "Лимит параллельных запросов",
"http2Fallback": "Откат HTTP/2",
"clientError": "Ошибка клиента",
@@ -46,6 +47,7 @@
"retry_success": "Повтор успешен",
"retry_failed": "Повтор не удался",
"system_error": "Системная ошибка",
+ "resource_not_found": "Ресурс не найден (404)",
"client_error_non_retryable": "Ошибка клиента",
"concurrent_limit_failed": "Лимит параллельных запросов",
"http2_fallback": "Откат HTTP/2",
@@ -128,11 +130,13 @@
"candidateInfo": " • {name}: вес={weight} стоимость={cost} вероятность={probability}%",
"selected": "✓ Выбрано: {provider}",
"requestFailed": "Запрос не выполнен (Попытка {attempt})",
+ "resourceNotFoundFailed": "Ресурс не найден (404) (Попытка {attempt})",
"attemptNumber": "Попытка {number}",
"firstAttempt": "Первая попытка",
"nthAttempt": "Попытка {attempt}",
"provider": "Провайдер: {provider}",
"statusCode": "Код состояния: {code}",
+ "statusCodeInferred": "Код состояния (выведено): {code}",
"error": "Ошибка: {error}",
"requestDuration": "Длительность запроса: {duration}мс",
"requestDurationSeconds": "Длительность запроса: {duration}с",
@@ -158,6 +162,7 @@
"meaning": "Значение",
"notCountedInCircuit": "Эта ошибка не учитывается в автомате защиты провайдера",
"systemErrorNote": "Примечание: Эта ошибка не учитывается в автомате защиты провайдера",
+ "resourceNotFoundNote": "Примечание: Эта ошибка не учитывается в автомате защиты; после исчерпания повторов произойдёт переключение.",
"reselection": "Повторный выбор провайдера",
"reselect": "Повторный выбор провайдера",
"excluded": "Исключено: {providers}",
diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json
index e08a15acf..b28281175 100644
--- a/messages/zh-CN/dashboard.json
+++ b/messages/zh-CN/dashboard.json
@@ -244,6 +244,18 @@
},
"errorMessage": "错误信息",
"fake200ForwardedNotice": "提示:对于流式请求,该失败可能在流结束后才被识别;响应内容可能已原样透传给客户端。",
+ "fake200DetectedReason": "检测原因:{reason}",
+ "fake200Reasons": {
+ "emptyBody": "响应体为空",
+ "htmlBody": "返回了 HTML 文档(可能是错误页)",
+ "jsonErrorNonEmpty": "JSON 顶层 error 字段非空",
+ "jsonErrorMessageNonEmpty": "JSON 中 error.message 非空",
+ "jsonMessageKeywordMatch": "JSON message 字段包含 \"error\"(启发式)",
+ "unknown": "响应体内容指示错误"
+ },
+ "statusCodeInferredBadge": "推测",
+ "statusCodeInferredTooltip": "该状态码根据响应体内容推断(例如假200),可能与上游真实 HTTP 状态码不同。",
+ "statusCodeInferredSuffix": "(推测)",
"filteredProviders": "被过滤的供应商",
"providerChain": {
"title": "供应商决策链时间线",
diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json
index 8691d9aa1..dfe3daad7 100644
--- a/messages/zh-CN/provider-chain.json
+++ b/messages/zh-CN/provider-chain.json
@@ -35,6 +35,7 @@
"candidate": "{name}({probability}%)",
"requestChain": "请求链路:",
"systemError": "系统错误",
+ "resourceNotFound": "资源不存在(404)",
"concurrentLimit": "并发限制",
"http2Fallback": "HTTP/2 回退",
"clientError": "客户端错误",
@@ -46,6 +47,7 @@
"retry_success": "重试成功",
"retry_failed": "重试失败",
"system_error": "系统错误",
+ "resource_not_found": "资源不存在(404)",
"client_error_non_retryable": "客户端错误",
"concurrent_limit_failed": "并发限制",
"http2_fallback": "HTTP/2 回退",
@@ -128,11 +130,13 @@
"candidateInfo": " • {name}: 权重={weight} 成本={cost} 概率={probability}%",
"selected": "✓ 选择: {provider}",
"requestFailed": "请求失败(第 {attempt} 次尝试)",
+ "resourceNotFoundFailed": "资源不存在(404,第 {attempt} 次尝试)",
"attemptNumber": "第 {number} 次",
"firstAttempt": "首次尝试",
"nthAttempt": "第 {attempt} 次尝试",
"provider": "供应商: {provider}",
"statusCode": "状态码: {code}",
+ "statusCodeInferred": "状态码(推测): {code}",
"error": "错误: {error}",
"requestDuration": "请求耗时: {duration}ms",
"requestDurationSeconds": "请求耗时: {duration}s",
@@ -158,6 +162,7 @@
"meaning": "含义",
"notCountedInCircuit": "此错误不计入供应商熔断器",
"systemErrorNote": "说明:此错误不计入供应商熔断器",
+ "resourceNotFoundNote": "说明:该错误不计入熔断器;重试耗尽后将触发故障转移。",
"reselection": "重新选择供应商",
"reselect": "重新选择供应商",
"excluded": "已排除: {providers}",
diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json
index a4d2d8fe7..73d0c1753 100644
--- a/messages/zh-TW/dashboard.json
+++ b/messages/zh-TW/dashboard.json
@@ -244,6 +244,18 @@
},
"errorMessage": "錯誤訊息",
"fake200ForwardedNotice": "提示:對於串流請求,此失敗可能在串流結束後才被識別;回應內容可能已原樣透傳給用戶端。",
+ "fake200DetectedReason": "檢測原因:{reason}",
+ "fake200Reasons": {
+ "emptyBody": "回應本文為空",
+ "htmlBody": "回傳了 HTML 文件(可能是錯誤頁)",
+ "jsonErrorNonEmpty": "JSON 頂層 error 欄位非空",
+ "jsonErrorMessageNonEmpty": "JSON 中 error.message 非空",
+ "jsonMessageKeywordMatch": "JSON message 欄位包含 \"error\"(啟發式)",
+ "unknown": "回應本文內容顯示錯誤"
+ },
+ "statusCodeInferredBadge": "推測",
+ "statusCodeInferredTooltip": "此狀態碼係根據回應內容推測(例如假200),可能與上游真實 HTTP 狀態碼不同。",
+ "statusCodeInferredSuffix": "(推測)",
"filteredProviders": "被過濾的供應商",
"providerChain": {
"title": "供應商決策鏈時間軸",
diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json
index 699b37bc6..c9846479c 100644
--- a/messages/zh-TW/provider-chain.json
+++ b/messages/zh-TW/provider-chain.json
@@ -35,6 +35,7 @@
"candidate": "{name}({probability}%)",
"requestChain": "請求鏈路:",
"systemError": "系統錯誤",
+ "resourceNotFound": "資源不存在(404)",
"concurrentLimit": "並發限制",
"http2Fallback": "HTTP/2 回退",
"clientError": "客戶端錯誤",
@@ -46,6 +47,7 @@
"retry_success": "重試成功",
"retry_failed": "重試失敗",
"system_error": "系統錯誤",
+ "resource_not_found": "資源不存在(404)",
"client_error_non_retryable": "客戶端錯誤",
"concurrent_limit_failed": "並發限制",
"http2_fallback": "HTTP/2 回退",
@@ -128,11 +130,13 @@
"candidateInfo": " • {name}: 權重={weight} 成本={cost} 概率={probability}%",
"selected": "✓ 選擇: {provider}",
"requestFailed": "請求失敗(第 {attempt} 次嘗試)",
+ "resourceNotFoundFailed": "資源不存在(404,第 {attempt} 次嘗試)",
"attemptNumber": "第 {number} 次",
"firstAttempt": "首次嘗試",
"nthAttempt": "第 {attempt} 次嘗試",
"provider": "供應商: {provider}",
"statusCode": "狀態碼: {code}",
+ "statusCodeInferred": "狀態碼(推測): {code}",
"error": "錯誤: {error}",
"requestDuration": "請求耗時: {duration}ms",
"requestDurationSeconds": "請求耗時: {duration}s",
@@ -158,6 +162,7 @@
"meaning": "含義",
"notCountedInCircuit": "此錯誤不計入供應商熔斷器",
"systemErrorNote": "說明:此錯誤不計入供應商熔斷器",
+ "resourceNotFoundNote": "說明:該錯誤不計入熔斷器;重試耗盡後將觸發故障轉移。",
"reselection": "重新選擇供應商",
"reselect": "重新選擇供應商",
"excluded": "已排除: {providers}",
diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx
index 9cb749975..45cd78101 100644
--- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx
+++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx
@@ -253,6 +253,15 @@ const messages = {
},
errorMessage: "Error message",
fake200ForwardedNotice: "Note: detected after stream end; payload may have been forwarded",
+ fake200DetectedReason: "Detected reason: {reason}",
+ fake200Reasons: {
+ emptyBody: "Empty response body",
+ htmlBody: "HTML document returned",
+ jsonErrorNonEmpty: "JSON has non-empty error field",
+ jsonErrorMessageNonEmpty: "JSON has non-empty error.message",
+ jsonMessageKeywordMatch: 'JSON message contains "error"',
+ unknown: "Response body indicates an error",
+ },
viewDetails: "View details",
filteredProviders: "Filtered providers",
providerChain: {
diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx
index 2a10408ec..79a7e501c 100644
--- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx
+++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx
@@ -39,7 +39,9 @@ function getRequestStatus(item: ProviderChainItem): StepStatus {
if (
item.reason === "retry_failed" ||
item.reason === "system_error" ||
+ item.reason === "resource_not_found" ||
item.reason === "client_error_non_retryable" ||
+ item.reason === "endpoint_pool_exhausted" ||
item.reason === "concurrent_limit_failed"
) {
return "failure";
@@ -464,10 +466,10 @@ export function LogicTraceTab({
subtitle={
isSessionReuse
? item.statusCode
- ? `HTTP ${item.statusCode}`
+ ? `HTTP ${item.statusCode}${item.statusCodeInferred ? ` ${t("statusCodeInferredSuffix")}` : ""}`
: item.name
: item.statusCode
- ? `HTTP ${item.statusCode}`
+ ? `HTTP ${item.statusCode}${item.statusCodeInferred ? ` ${t("statusCodeInferredSuffix")}` : ""}`
: item.reason
? tChain(`reasons.${item.reason}`)
: undefined
diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx
index 3052c02d8..68807ac4f 100644
--- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx
+++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx
@@ -28,6 +28,25 @@ import {
shouldHideOutputRate,
} from "../types";
+// UI 仅用于“解释”内部的 FAKE_200_* 错误码,不参与判定逻辑。
+// 这些 code 代表:上游返回了 2xx(看起来成功),但响应体内容更像错误页/错误 JSON。
+function getFake200ReasonKey(code: string): string {
+ switch (code) {
+ case "FAKE_200_EMPTY_BODY":
+ return "fake200Reasons.emptyBody";
+ case "FAKE_200_HTML_BODY":
+ return "fake200Reasons.htmlBody";
+ case "FAKE_200_JSON_ERROR_NON_EMPTY":
+ return "fake200Reasons.jsonErrorNonEmpty";
+ case "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY":
+ return "fake200Reasons.jsonErrorMessageNonEmpty";
+ case "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH":
+ return "fake200Reasons.jsonMessageKeywordMatch";
+ default:
+ return "fake200Reasons.unknown";
+ }
+}
+
export function SummaryTab({
statusCode,
errorMessage,
@@ -67,6 +86,10 @@ export function SummaryTab({
specialSettings && specialSettings.length > 0 ? JSON.stringify(specialSettings, null, 2) : null;
const isFake200PostStreamFailure =
typeof errorMessage === "string" && errorMessage.startsWith("FAKE_200_");
+ const fake200Reason =
+ isFake200PostStreamFailure && typeof errorMessage === "string"
+ ? t(getFake200ReasonKey(errorMessage))
+ : null;
return (
@@ -426,6 +449,11 @@ export function SummaryTab({
{errorMessage.length > 200 ? `${errorMessage.slice(0, 200)}...` : errorMessage}
+ {isFake200PostStreamFailure && fake200Reason && (
+
+ {t("fake200DetectedReason", { reason: fake200Reason })}
+
+ )}
{/* 注意:假 200 检测发生在 SSE 流式结束后;此时内容已可能透传给客户端,因此需要提示用户避免误解。 */}
{isFake200PostStreamFailure && (
diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx
index c4f77d5df..c93284a78 100644
--- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx
+++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx
@@ -86,6 +86,18 @@ const messages = {
details: {
clickStatusCode: "Click status code",
fake200ForwardedNotice: "Note: payload may have been forwarded",
+ fake200DetectedReason: "Detected reason: {reason}",
+ statusCodeInferredBadge: "Inferred",
+ statusCodeInferredTooltip: "This status code is inferred from response body content.",
+ statusCodeInferredSuffix: "(inferred)",
+ fake200Reasons: {
+ emptyBody: "Empty response body",
+ htmlBody: "HTML document returned",
+ jsonErrorNonEmpty: "JSON has non-empty error field",
+ jsonErrorMessageNonEmpty: "JSON has non-empty error.message",
+ jsonMessageKeywordMatch: 'JSON message contains "error"',
+ unknown: "Response body indicates an error",
+ },
},
},
},
@@ -276,6 +288,25 @@ describe("provider-chain-popover layout", () => {
expect(html).toContain("Note: payload may have been forwarded");
});
+ test("renders inferred status code badge when statusCodeInferred=true", () => {
+ const html = renderWithIntl(
+
+ );
+
+ expect(html).toContain("Inferred");
+ });
+
test("requestCount<=1 branch keeps truncation container shrinkable", () => {
const html = renderWithIntl(
typeof item.errorMessage === "string" && item.errorMessage.startsWith("FAKE_200_")
);
+ const fake200CodeForDisplay = chain.find(
+ (item) => typeof item.errorMessage === "string" && item.errorMessage.startsWith("FAKE_200_")
+ )?.errorMessage;
// Calculate actual request count (excluding intermediate states)
const requestCount = chain.filter(isActualRequest).length;
@@ -144,6 +167,7 @@ export function ProviderChainPopover({
(item) => item.reason === "session_reuse" || item.selectionMethod === "session_reuse"
);
const sessionReuseContext = sessionReuseItem?.decisionContext;
+ const singleRequestItem = chain.find(isActualRequest);
return (
@@ -166,12 +190,45 @@ export function ProviderChainPopover({
{/* Provider name */}
{displayName}
+ {singleRequestItem?.statusCode && (
+
+ = 200 && singleRequestItem.statusCode < 300
+ ? "border-emerald-500 text-emerald-600"
+ : "border-rose-500 text-rose-600"
+ )}
+ >
+ {singleRequestItem.statusCode}
+
+ {singleRequestItem.statusCodeInferred && (
+
+ {t("logs.details.statusCodeInferredBadge")}
+
+ )}
+
+ )}
{/* 注意:假 200 检测发生在 SSE 流式结束后;此时内容已可能透传给客户端。 */}
{hasFake200PostStreamFailure && (
-
{t("logs.details.fake200ForwardedNotice")}
+
+ {typeof fake200CodeForDisplay === "string" && (
+
+ {t("logs.details.fake200DetectedReason", {
+ reason: t(getFake200ReasonKey(fake200CodeForDisplay)),
+ })}
+
+ )}
+
{t("logs.details.fake200ForwardedNotice")}
+
)}
@@ -458,6 +515,15 @@ export function ProviderChainPopover({
{item.statusCode}
)}
+ {item.statusCode && item.statusCodeInferred && (
+
+ {t("logs.details.statusCodeInferredBadge")}
+
+ )}
{item.reason && !item.statusCode && (
{tChain(`reasons.${item.reason}`)}
@@ -465,9 +531,19 @@ export function ProviderChainPopover({
)}
{item.errorMessage && (
-
- {item.errorMessage}
-
+ <>
+
+ {item.errorMessage}
+
+ {typeof item.errorMessage === "string" &&
+ item.errorMessage.startsWith("FAKE_200_") && (
+
+ {t("logs.details.fake200DetectedReason", {
+ reason: t(getFake200ReasonKey(item.errorMessage)),
+ })}
+
+ )}
+ >
)}
diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts
index 977f7f92f..4ec42b069 100644
--- a/src/app/v1/_lib/proxy/error-handler.ts
+++ b/src/app/v1/_lib/proxy/error-handler.ts
@@ -1,3 +1,4 @@
+import { getCachedSystemSettings } from "@/lib/config/system-settings-cache";
import {
isClaudeErrorFormat,
isGeminiErrorFormat,
@@ -6,6 +7,7 @@ import {
} from "@/lib/error-override-validator";
import { logger } from "@/lib/logger";
import { ProxyStatusTracker } from "@/lib/proxy-status-tracker";
+import { sanitizeErrorTextForDetail } from "@/lib/utils/upstream-error-detection";
import { updateMessageRequestDetails, updateMessageRequestDuration } from "@/repository/message";
import { attachSessionIdToErrorResponse } from "./error-session-id";
import {
@@ -236,9 +238,61 @@ export class ProxyErrorHandler {
overridden: false,
});
+ // verboseProviderError(系统设置)开启时:对“假 200/空响应”等上游异常返回更详细的报告,便于排查。
+ // 注意:
+ // - 该逻辑放在 error override 之后:确保优先级更低,不覆盖用户自定义覆写。
+ // - rawBody 仅用于本次错误响应回传(受系统设置控制),不写入数据库/决策链;
+ // - 出于安全考虑,这里会对 rawBody 做基础脱敏(Bearer/key/JWT/email 等),避免上游错误页意外回显敏感信息。
+ let details: Record
| undefined;
+ let upstreamRequestId: string | undefined;
+ const shouldAttachVerboseDetails =
+ (error instanceof ProxyError && error.message.startsWith("FAKE_200_")) ||
+ isEmptyResponseError(error);
+
+ if (shouldAttachVerboseDetails) {
+ const settings = await getCachedSystemSettings();
+ if (settings.verboseProviderError) {
+ if (error instanceof ProxyError) {
+ upstreamRequestId = error.upstreamError?.requestId;
+ const rawBody =
+ typeof error.upstreamError?.rawBody === "string" && error.upstreamError.rawBody
+ ? sanitizeErrorTextForDetail(error.upstreamError.rawBody)
+ : error.upstreamError?.rawBody;
+ details = {
+ upstreamError: {
+ kind: "fake_200",
+ code: error.message,
+ statusCode: error.statusCode,
+ statusCodeInferred: error.upstreamError?.statusCodeInferred ?? false,
+ statusCodeInferenceMatcherId:
+ error.upstreamError?.statusCodeInferenceMatcherId ?? null,
+ clientSafeMessage: error.getClientSafeMessage(),
+ rawBody,
+ rawBodyTruncated: error.upstreamError?.rawBodyTruncated ?? false,
+ },
+ };
+ } else if (isEmptyResponseError(error)) {
+ details = {
+ upstreamError: {
+ kind: "empty_response",
+ reason: error.reason,
+ clientSafeMessage: error.getClientSafeMessage(),
+ rawBody: "",
+ rawBodyTruncated: false,
+ },
+ };
+ }
+ }
+ }
+
+ const safeRequestId =
+ typeof upstreamRequestId === "string" && upstreamRequestId.trim()
+ ? upstreamRequestId.trim()
+ : undefined;
+
return await attachSessionIdToErrorResponse(
session.sessionId,
- ProxyResponses.buildError(statusCode, clientErrorMessage)
+ ProxyResponses.buildError(statusCode, clientErrorMessage, undefined, details, safeRequestId)
);
}
diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts
index 13d0ad9d0..ed59b5659 100644
--- a/src/app/v1/_lib/proxy/errors.ts
+++ b/src/app/v1/_lib/proxy/errors.ts
@@ -18,11 +18,41 @@ export class ProxyError extends Error {
message: string,
public readonly statusCode: number,
public readonly upstreamError?: {
- body: string; // 原始响应体(智能截断)
+ /**
+ * 上游响应体(智能截断)。
+ *
+ * 注意:该字段会进入 getDetailedErrorMessage(),并被记录到数据库中,
+ * 因此不要在这里放入“大段原文”或未脱敏的敏感内容。
+ */
+ body: string;
parsed?: unknown; // 解析后的 JSON(如果有)
providerId?: number;
providerName?: string;
requestId?: string; // 上游请求 ID(用于覆写响应时注入)
+
+ /**
+ * 上游响应体原文(通常为前缀片段)。
+ *
+ * 设计目标:
+ * - 仅用于“本次错误响应”返回给客户端(受系统设置控制);
+ * - 不参与规则匹配与持久化(避免污染数据库/日志)。
+ *
+ * 目前主要用于“假 200”检测:HTTP 状态码为 2xx,但 body 实际为错误页/错误 JSON。
+ */
+ rawBody?: string;
+ rawBodyTruncated?: boolean;
+
+ /**
+ * 标记该 ProxyError 的 statusCode 是否由“响应体内容”推断得出(而非上游真实 HTTP 状态码)。
+ *
+ * 典型场景:上游返回 HTTP 200,但 body 为错误页/错误 JSON(假 200)。此时 CCH 会根据响应体内容推断更贴近语义的 4xx/5xx,
+ * 以便让故障转移/熔断/会话绑定逻辑与“真实上游错误状态码”保持一致。
+ */
+ statusCodeInferred?: boolean;
+ /**
+ * 命中的推断规则 id(仅用于内部调试/审计,不应用于用户展示文案)。
+ */
+ statusCodeInferenceMatcherId?: string;
}
) {
super(message);
@@ -447,6 +477,58 @@ export class ProxyError extends Error {
* - getClientSafeMessage(): 不包含供应商名称,用于返回给客户端
*/
getClientSafeMessage(): string {
+ // 注意:一些内部检测/统计用的“错误码”(例如 FAKE_200_*)不适合直接暴露给客户端。
+ // 这里做最小映射:当 message 为 FAKE_200_* 时返回“可读原因说明”,并附带安全的上游片段(若有)。
+ if (this.message.startsWith("FAKE_200_")) {
+ // 说明:这些 code 都来自内部的“假 200”检测,代表:上游返回 HTTP 200,但响应体内容更像错误页/错误 JSON。
+ // 我们需要:
+ // 1) 给用户清晰的错误原因(避免只看到一个内部 code);
+ // 2) 不泄露内部错误码/供应商名称;
+ // 3) 在有 detail 时附带一小段“脱敏 + 截断”的上游片段,帮助排查。
+ const reason = (() => {
+ switch (this.message) {
+ case "FAKE_200_EMPTY_BODY":
+ return "Upstream returned HTTP 200, but the response body was empty.";
+ case "FAKE_200_HTML_BODY":
+ return "Upstream returned HTTP 200, but the response body looks like an HTML document (likely an error page).";
+ case "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY":
+ return "Upstream returned HTTP 200, but the JSON body contains a non-empty `error.message`.";
+ case "FAKE_200_JSON_ERROR_NON_EMPTY":
+ return "Upstream returned HTTP 200, but the JSON body contains a non-empty `error` field.";
+ case "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH":
+ return "Upstream returned HTTP 200, but the JSON `message` suggests an error (heuristic).";
+ default:
+ return "Upstream returned HTTP 200, but the response body indicates an error.";
+ }
+ })();
+
+ const inferredNote = this.upstreamError?.statusCodeInferred
+ ? ` Inferred HTTP status: ${this.statusCode}.`
+ : "";
+
+ const detail = this.upstreamError?.body?.trim();
+ if (detail) {
+ // 注意:对 FAKE_200_* 路径,我们期望 upstreamError.body 来自内部检测得到的“脱敏 + 截断片段”(详见 upstream-error-detection.ts)。
+ //
+ // 但为避免未来调用方误把“未脱敏的大段原文”塞进 upstreamError.body 导致泄露,
+ // 这里再做一次防御性处理:
+ // - whitespace 归一化(避免多行污染客户端日志)
+ // - 二次截断(上限 200 字符)
+ // - 轻量脱敏(避免明显的 token/key 泄露)
+ const normalized = detail.replace(/\s+/g, " ").trim();
+ const maxChars = 200;
+ const clipped =
+ normalized.length > maxChars ? `${normalized.slice(0, maxChars)}…` : normalized;
+ const safe = clipped
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]")
+ .replace(/\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}\b/giu, "[REDACTED_KEY]")
+ .replace(/\bAIza[0-9A-Za-z_-]{16,}\b/g, "[REDACTED_KEY]");
+ return `${reason}${inferredNote} Upstream detail: ${safe}`;
+ }
+
+ return `${reason}${inferredNote}`;
+ }
+
return this.message;
}
}
diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts
index 3759a8570..739edae66 100644
--- a/src/app/v1/_lib/proxy/forwarder.ts
+++ b/src/app/v1/_lib/proxy/forwarder.ts
@@ -25,6 +25,10 @@ import {
import { getGlobalAgentPool, getProxyAgentForProvider } from "@/lib/proxy-agent";
import { SessionManager } from "@/lib/session-manager";
import { CONTEXT_1M_BETA_HEADER, shouldApplyContext1m } from "@/lib/special-attributes";
+import {
+ detectUpstreamErrorFromSseOrJsonText,
+ inferUpstreamErrorStatusCodeFromText,
+} from "@/lib/utils/upstream-error-detection";
import {
isVendorTypeCircuitOpen,
recordVendorTypeAllEndpointsTimeout,
@@ -84,6 +88,81 @@ const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商(
type CacheTtlOption = CacheTtlPreference | null | undefined;
+// 非流式响应体检查的上限(字节):避免上游在 2xx 场景返回超大内容导致内存占用失控。
+// 说明:
+// - 该检查仅用于“空响应/假 200”启发式判定,不用于业务逻辑解析;
+// - 超过上限时,仍认为“非空”,但会跳过 JSON 内容结构检查(避免截断导致误判)。
+const NON_STREAM_BODY_INSPECTION_MAX_BYTES = 32 * 1024; // 32 KiB
+
+/**
+ * 读取响应体文本,但最多读取 `maxBytes` 字节(用于非流式 2xx 的“空响应/假 200”嗅探)。
+ *
+ * 注意:
+ * - 该函数只用于启发式检测,不用于业务逻辑解析;
+ * - 超过上限时会 `cancel()` reader,避免继续占用资源;
+ * - 调用方应使用 `response.clone()`,避免消费掉原始响应体,影响后续透传/解析。
+ */
+async function readResponseTextUpTo(
+ response: Response,
+ maxBytes: number
+): Promise<{ text: string; truncated: boolean }> {
+ const reader = response.body?.getReader();
+ if (!reader) {
+ return { text: "", truncated: false };
+ }
+
+ const decoder = new TextDecoder();
+ const chunks: string[] = [];
+ let bytesRead = 0;
+ let truncated = false;
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (!value || value.byteLength === 0) continue;
+
+ const remaining = maxBytes - bytesRead;
+ // 注意:remaining<=0 发生在“已经读到下一块 chunk”之后。
+ // 对启发式嗅探而言,直接标记 truncated 并退出即可(等价于丢弃超出上限的后续字节),
+ // 避免对超出部分做无谓的解码开销。
+ if (remaining <= 0) {
+ truncated = true;
+ break;
+ }
+
+ if (value.byteLength > remaining) {
+ chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }));
+ bytesRead += remaining;
+ truncated = true;
+ break;
+ }
+
+ chunks.push(decoder.decode(value, { stream: true }));
+ bytesRead += value.byteLength;
+ }
+
+ const flushed = decoder.decode();
+ if (flushed) chunks.push(flushed);
+ } finally {
+ if (truncated) {
+ try {
+ await reader.cancel();
+ } catch {
+ // ignore
+ }
+ }
+
+ try {
+ reader.releaseLock();
+ } catch {
+ // ignore
+ }
+ }
+
+ return { text: chunks.join(""), truncated };
+}
+
function resolveCacheTtlPreference(
keyPref: CacheTtlOption,
providerPref: CacheTtlOption
@@ -523,6 +602,9 @@ export class ProxyForwarder {
session.addProviderToChain(currentProvider, {
reason: "endpoint_pool_exhausted",
strictBlockCause: strictBlockCause as ProviderChainItem["strictBlockCause"],
+ // 为避免被 initial_selection/session_reuse 去重吞掉,这里需要写入 attemptNumber。
+ // 同时也能让“决策链/技术时间线”把它当作一次实际尝试(虽然请求未发出)。
+ attemptNumber: 1,
...(filterStats ? { endpointFilterStats: filterStats } : {}),
errorMessage: endpointSelectionError?.message,
});
@@ -619,7 +701,14 @@ export class ProxyForwarder {
// ========== 空响应检测(仅非流式)==========
const contentType = response.headers.get("content-type") || "";
- const isSSE = contentType.includes("text/event-stream");
+ const normalizedContentType = contentType.toLowerCase();
+ const isSSE = normalizedContentType.includes("text/event-stream");
+ const isHtml =
+ normalizedContentType.includes("text/html") ||
+ normalizedContentType.includes("application/xhtml+xml");
+ const isJson =
+ normalizedContentType.includes("application/json") ||
+ normalizedContentType.includes("+json");
// ========== 流式响应:延迟成功判定(避免“假 200”)==========
// 背景:上游可能返回 HTTP 200,但 SSE 内容为错误 JSON(如 {"error": "..."})。
@@ -655,29 +744,111 @@ export class ProxyForwarder {
return response;
}
- if (!isSSE) {
- // 非流式响应:检测空响应
- const contentLength = response.headers.get("content-length");
+ // 非流式响应:检测空响应
+ const contentLengthHeader = response.headers.get("content-length");
+ const contentLength = contentLengthHeader?.trim() || undefined;
+ const contentLengthBytes = (() => {
+ if (!contentLength) return null;
+
+ // Content-Length 必须是纯数字;parseInt("12abc") 会返回 12,容易误判为合法值,
+ // 从而跳过 “!hasValidContentLength” 的检查分支。
+ if (!/^\d+$/.test(contentLength)) return null;
+
+ const num = Number(contentLength);
+ if (!Number.isSafeInteger(num) || num < 0) return null;
+ return num;
+ })();
+ const hasValidContentLength = contentLengthBytes !== null;
+
+ // 检测 Content-Length: 0 的情况
+ if (contentLengthBytes === 0) {
+ throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body");
+ }
- // 检测 Content-Length: 0 的情况
- if (contentLength === "0") {
+ // 200 + text/html(或 xhtml)通常是上游网关/WAF/Cloudflare 的错误页,但被包装成了 HTTP 200。
+ // 这种“假 200”会导致:
+ // - 熔断/故障转移统计被误记为成功;
+ // - session 智能绑定被更新到不可用 provider(影响后续重试)。
+ // 因此这里在进入成功分支前做一次强信号检测:仅当 body 看起来是完整 HTML 文档时才视为错误。
+ let inspectedText: string | undefined;
+ let inspectedTruncated = false;
+ // 注意:这里不会对“大体积 JSON”做假 200 检测(例如 Content-Length > 32KiB)。
+ // 原因:
+ // - 非流式路径需要 clone 并额外读取响应体,会带来额外的内存/延迟开销;
+ // - 大体积 JSON 更可能是正常响应(而不是网关/WAF 的短错误 JSON)。
+ // 这意味着:极少数“超大 JSON 错误体 + HTTP 200”的上游异常可能会漏检。
+ const shouldInspectJson =
+ isJson &&
+ hasValidContentLength &&
+ contentLengthBytes <= NON_STREAM_BODY_INSPECTION_MAX_BYTES;
+ const shouldInspectBody = isHtml || !hasValidContentLength || shouldInspectJson;
+ if (shouldInspectBody) {
+ // 注意:Response.clone() 会 tee 底层 ReadableStream,可能带来一定的瞬时内存开销;
+ // 这里通过“最多读取 32 KiB”并在截断时 cancel 克隆分支来控制开销。
+ const clonedResponse = response.clone();
+ const inspected = await readResponseTextUpTo(
+ clonedResponse,
+ NON_STREAM_BODY_INSPECTION_MAX_BYTES
+ );
+ inspectedText = inspected.text;
+ inspectedTruncated = inspected.truncated;
+ }
+
+ if (inspectedText !== undefined) {
+ // 对非流式 2xx 响应:只启用“强信号”判定(HTML 文档 / 顶层 error 非空 / 空 body)。
+ // `message` 关键字匹配属于弱信号,误判风险更高;该规则主要用于 SSE 结束后的补充检测。
+ const detected = detectUpstreamErrorFromSseOrJsonText(inspectedText, {
+ maxJsonCharsForMessageCheck: 0,
+ });
+
+ if (detected.isError && detected.code === "FAKE_200_EMPTY_BODY") {
throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body");
}
- // 对于没有 Content-Length 的情况,需要 clone 并检查响应体
- // 注意:这会增加一定的性能开销,但对于非流式响应是可接受的
- if (!contentLength) {
- const clonedResponse = response.clone();
- const responseText = await clonedResponse.text();
-
- if (!responseText || responseText.trim() === "") {
- throw new EmptyResponseError(
- currentProvider.id,
- currentProvider.name,
- "empty_body"
- );
- }
+ const isStrongFake200 =
+ detected.isError &&
+ (detected.code === "FAKE_200_HTML_BODY" ||
+ detected.code === "FAKE_200_JSON_ERROR_NON_EMPTY" ||
+ detected.code === "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY");
+
+ if (isStrongFake200) {
+ const inferredStatus = inferUpstreamErrorStatusCodeFromText(inspectedText);
+ const inferredStatusCode = inferredStatus?.statusCode;
+
+ throw new ProxyError(detected.code, inferredStatusCode ?? 502, {
+ body: detected.detail ?? "",
+ providerId: currentProvider.id,
+ providerName: currentProvider.name,
+ // 注意:rawBody 仅用于“本次错误响应”向客户端提供更多排查信息(受系统设置控制),
+ // 不参与规则匹配/持久化,避免污染数据库或误触发覆写规则。
+ rawBody: inspectedText,
+ rawBodyTruncated: inspectedTruncated,
+ statusCodeInferred: inferredStatusCode !== undefined,
+ statusCodeInferenceMatcherId: inferredStatus?.matcherId,
+ });
+ }
+ }
+
+ // 对于缺失或非法 Content-Length 的情况,需要 clone 并检查响应体
+ // 注意:这会增加一定的性能开销,但对于非流式响应是可接受的
+ if (!contentLength || !hasValidContentLength) {
+ const responseText = inspectedText ?? "";
+ if (!responseText || responseText.trim() === "") {
+ throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body");
+ }
+
+ if (inspectedTruncated) {
+ logger.debug(
+ "ProxyForwarder: Response body too large for non-stream content check, skipping JSON parse",
+ {
+ providerId: currentProvider.id,
+ providerName: currentProvider.name,
+ contentType,
+ maxBytes: NON_STREAM_BODY_INSPECTION_MAX_BYTES,
+ }
+ );
+ } else {
// 尝试解析 JSON 并检查是否有输出内容
try {
const responseJson = JSON.parse(responseText) as Record;
@@ -722,7 +893,12 @@ export class ProxyForwarder {
// 注意:不抛出错误,因为某些请求(如 count_tokens)可能合法地返回 0 output tokens
}
}
- } catch (_parseError) {
+ } catch (_parseOrContentError) {
+ // EmptyResponseError 会触发重试/故障转移,不能在这里被当作 JSON 解析错误吞掉。
+ if (isEmptyResponseError(_parseOrContentError)) {
+ throw _parseOrContentError;
+ }
+
// JSON 解析失败但响应体不为空,不视为空响应错误
logger.debug("ProxyForwarder: Non-JSON response body, skipping content check", {
providerId: currentProvider.id,
@@ -964,6 +1140,7 @@ export class ProxyForwarder {
attemptNumber: attemptCount,
errorMessage,
statusCode: lastError.statusCode,
+ statusCodeInferred: lastError.upstreamError?.statusCodeInferred ?? false,
errorDetails: {
provider: {
id: currentProvider.id,
@@ -1096,6 +1273,7 @@ export class ProxyForwarder {
attemptNumber: attemptCount,
errorMessage,
statusCode: lastError.statusCode,
+ statusCodeInferred: lastError.upstreamError?.statusCodeInferred ?? false,
errorDetails: {
provider: {
id: currentProvider.id,
@@ -1160,6 +1338,7 @@ export class ProxyForwarder {
providerId: currentProvider.id,
providerName: currentProvider.name,
statusCode: statusCode,
+ statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false,
error: errorMessage,
attemptNumber: attemptCount,
totalProvidersAttempted,
@@ -1176,6 +1355,7 @@ export class ProxyForwarder {
attemptNumber: attemptCount,
errorMessage: errorMessage,
statusCode: statusCode,
+ statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false,
errorDetails: {
provider: {
id: currentProvider.id,
@@ -1298,6 +1478,7 @@ export class ProxyForwarder {
providerId: currentProvider.id,
providerName: currentProvider.name,
statusCode: 404,
+ statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false,
error: errorMessage,
attemptNumber: attemptCount,
totalProvidersAttempted,
@@ -1312,6 +1493,7 @@ export class ProxyForwarder {
attemptNumber: attemptCount,
errorMessage: errorMessage,
statusCode: 404,
+ statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false,
errorDetails: {
provider: {
id: currentProvider.id,
@@ -1454,6 +1636,7 @@ export class ProxyForwarder {
providerId: currentProvider.id,
providerName: currentProvider.name,
statusCode: statusCode,
+ statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false,
error: errorMessage,
attemptNumber: attemptCount,
totalProvidersAttempted,
@@ -1473,6 +1656,7 @@ export class ProxyForwarder {
circuitFailureCount: health.failureCount + 1, // 包含本次失败
circuitFailureThreshold: config.failureThreshold,
statusCode: statusCode,
+ statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false,
errorDetails: {
provider: {
id: currentProvider.id,
diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts
index c2afe9d90..356b1ba23 100644
--- a/src/app/v1/_lib/proxy/response-handler.ts
+++ b/src/app/v1/_lib/proxy/response-handler.ts
@@ -91,7 +91,8 @@ type FinalizeDeferredStreamingResult = {
* - 如果内容看起来是上游错误 JSON(假 200),则:
* - 计入熔断器失败;
* - 不更新 session 智能绑定(避免把会话粘到坏 provider);
- * - 内部状态码改为 502(只影响统计与后续重试选择,不影响本次客户端响应)。
+ * - 内部状态码改为“推断得到的 4xx/5xx”(未命中则回退 502),
+ * 仅影响统计与后续重试选择,不影响本次客户端响应。
* - 如果流正常结束且未命中错误判定,则按成功结算并更新绑定/熔断/endpoint 成功率。
*
* @param streamEndedNormally - 必须是 reader 读到 done=true 的“自然结束”;超时/中断等异常结束由其它逻辑处理。
@@ -122,12 +123,21 @@ async function finalizeDeferredStreamingFinalizationIfNeeded(
: ({ isError: false } as const);
// “内部结算用”的状态码(不会改变客户端实际 HTTP 状态码)。
- // - 假 200:映射为 502,确保内部统计/熔断/会话绑定把它当作失败。
+ // - 假 200:优先映射为“推断得到的 4xx/5xx”(未命中则回退 502),确保内部统计/熔断/会话绑定把它当作失败。
// - 未自然结束:也应映射为失败(避免把中断/部分流误记为 200 completed)。
let effectiveStatusCode: number;
let errorMessage: string | null;
+ let statusCodeInferred = false;
+ let statusCodeInferenceMatcherId: string | undefined;
if (detected.isError) {
- effectiveStatusCode = 502;
+ const inferred = inferUpstreamErrorStatusCodeFromText(allContent);
+ if (inferred) {
+ effectiveStatusCode = inferred.statusCode;
+ statusCodeInferred = true;
+ statusCodeInferenceMatcherId = inferred.matcherId;
+ } else {
+ effectiveStatusCode = 502;
+ }
errorMessage = detected.code;
} else if (!streamEndedNormally) {
effectiveStatusCode = clientAborted ? 499 : 502;
@@ -233,21 +243,29 @@ async function finalizeDeferredStreamingFinalizationIfNeeded(
providerName: meta.providerName,
upstreamStatusCode: meta.upstreamStatusCode,
effectiveStatusCode,
+ statusCodeInferred,
+ statusCodeInferenceMatcherId: statusCodeInferenceMatcherId ?? null,
code: detected.code,
detail: detected.detail ?? null,
});
- // 计入熔断器:让后续请求能正确触发故障转移/熔断
- try {
- // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。
- const { recordFailure } = await import("@/lib/circuit-breaker");
- await recordFailure(meta.providerId, new Error(detected.code));
- } catch (cbError) {
- logger.warn("[ResponseHandler] Failed to record fake-200 error in circuit breaker", {
- providerId: meta.providerId,
- sessionId: session.sessionId ?? null,
- error: cbError,
- });
+ const chainReason = effectiveStatusCode === 404 ? "resource_not_found" : "retry_failed";
+
+ // 计入熔断器:让后续请求能正确触发故障转移/熔断。
+ //
+ // 注意:404 语义在 forwarder 中属于 RESOURCE_NOT_FOUND,不计入熔断器(避免把“资源/模型不存在”当作供应商故障)。
+ if (effectiveStatusCode !== 404) {
+ try {
+ // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。
+ const { recordFailure } = await import("@/lib/circuit-breaker");
+ await recordFailure(meta.providerId, new Error(detected.code));
+ } catch (cbError) {
+ logger.warn("[ResponseHandler] Failed to record fake-200 error in circuit breaker", {
+ providerId: meta.providerId,
+ sessionId: session.sessionId ?? null,
+ error: cbError,
+ });
+ }
}
// NOTE: Do NOT call recordEndpointFailure here. Fake-200 errors are key-level
@@ -255,14 +273,16 @@ async function finalizeDeferredStreamingFinalizationIfNeeded(
// the error is in the response content, not endpoint connectivity.
// 记录到决策链(用于日志展示与 DB 持久化)。
- // 注意:这里用 effectiveStatusCode(502)而不是 upstreamStatusCode(200),
- // 以便让内部链路明确显示这是一次失败(否则会被误读为成功)。
+ // 注意:这里用 effectiveStatusCode(推断得到的 4xx/5xx,或回退 502)
+ // 而不是 upstreamStatusCode(200),以便让内部链路明确显示这是一次失败
+ // (否则会被误读为成功)。
session.addProviderToChain(providerForChain, {
endpointId: meta.endpointId,
endpointUrl: meta.endpointUrl,
- reason: "retry_failed",
+ reason: chainReason,
attemptNumber: meta.attemptNumber,
statusCode: effectiveStatusCode,
+ statusCodeInferred,
errorMessage: detected.code,
});
@@ -279,16 +299,21 @@ async function finalizeDeferredStreamingFinalizationIfNeeded(
errorMessage,
});
- // 计入熔断器:让后续请求能正确触发故障转移/熔断
- try {
- const { recordFailure } = await import("@/lib/circuit-breaker");
- await recordFailure(meta.providerId, new Error(errorMessage));
- } catch (cbError) {
- logger.warn("[ResponseHandler] Failed to record non-200 error in circuit breaker", {
- providerId: meta.providerId,
- sessionId: session.sessionId ?? null,
- error: cbError,
- });
+ const chainReason = effectiveStatusCode === 404 ? "resource_not_found" : "retry_failed";
+
+ // 计入熔断器:让后续请求能正确触发故障转移/熔断。
+ // 注意:与 forwarder 口径保持一致:404 不计入熔断器(资源不存在不是供应商故障)。
+ if (effectiveStatusCode !== 404) {
+ try {
+ const { recordFailure } = await import("@/lib/circuit-breaker");
+ await recordFailure(meta.providerId, new Error(errorMessage));
+ } catch (cbError) {
+ logger.warn("[ResponseHandler] Failed to record non-200 error in circuit breaker", {
+ providerId: meta.providerId,
+ sessionId: session.sessionId ?? null,
+ error: cbError,
+ });
+ }
}
// NOTE: Do NOT call recordEndpointFailure here. Non-200 HTTP errors (401, 429,
@@ -299,7 +324,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded(
session.addProviderToChain(providerForChain, {
endpointId: meta.endpointId,
endpointUrl: meta.endpointUrl,
- reason: "retry_failed",
+ reason: chainReason,
attemptNumber: meta.attemptNumber,
statusCode: effectiveStatusCode,
errorMessage: errorMessage,
@@ -2601,7 +2626,8 @@ async function updateRequestCostFromUsage(
* 统一的请求统计处理方法
* 用于消除 Gemini 透传、普通非流式、普通流式之间的重复统计逻辑
*
- * @param statusCode - 内部结算状态码(可能与客户端实际收到的 HTTP 状态不同,例如“假 200”会被映射为 502)
+ * @param statusCode - 内部结算状态码(可能与客户端实际收到的 HTTP 状态不同,例如“假 200”会被推断并映射为更贴近语义的 4xx/5xx;
+ * 未命中推断规则时回退为 502)
* @param errorMessage - 可选的内部错误原因(用于把假 200/解析失败等信息写入 DB 与监控)
*/
export async function finalizeRequestStats(
diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts
index c6c2ba9f8..02450a756 100644
--- a/src/app/v1/_lib/proxy/session.ts
+++ b/src/app/v1/_lib/proxy/session.ts
@@ -474,6 +474,7 @@ export class ProxySession {
endpointUrl?: string;
// 修复:添加新字段
statusCode?: number; // 成功时的状态码
+ statusCodeInferred?: boolean; // statusCode 是否为响应体推断
circuitFailureCount?: number; // 熔断失败计数
circuitFailureThreshold?: number; // 熔断阈值
errorDetails?: ProviderChainItem["errorDetails"]; // 结构化错误详情
@@ -502,6 +503,7 @@ export class ProxySession {
errorMessage: metadata?.errorMessage, // 记录错误信息
// 修复:记录新字段
statusCode: metadata?.statusCode,
+ statusCodeInferred: metadata?.statusCodeInferred,
circuitFailureCount: metadata?.circuitFailureCount,
circuitFailureThreshold: metadata?.circuitFailureThreshold,
errorDetails: metadata?.errorDetails, // 结构化错误详情
diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts
index ace105ca8..d1f9f6950 100644
--- a/src/lib/utils/provider-chain-formatter.test.ts
+++ b/src/lib/utils/provider-chain-formatter.test.ts
@@ -375,6 +375,113 @@ describe("vendor_type_all_timeout", () => {
});
});
+// =============================================================================
+// resource_not_found reason tests
+// =============================================================================
+
+describe("resource_not_found", () => {
+ const baseNotFoundItem: ProviderChainItem = {
+ id: 1,
+ name: "provider-a",
+ reason: "resource_not_found",
+ attemptNumber: 1,
+ statusCode: 404,
+ errorMessage: "Not Found",
+ timestamp: 1000,
+ errorDetails: {
+ provider: {
+ id: 1,
+ name: "provider-a",
+ statusCode: 404,
+ statusText: "Not Found",
+ },
+ },
+ };
+
+ describe("formatProviderSummary", () => {
+ test("renders resource_not_found item as failure in summary", () => {
+ const chain: ProviderChainItem[] = [baseNotFoundItem];
+ const result = formatProviderSummary(chain, mockT);
+
+ expect(result).toContain("provider-a");
+ expect(result).toContain("✗");
+ });
+
+ test("renders resource_not_found alongside a successful retry in multi-provider chain", () => {
+ const chain: ProviderChainItem[] = [
+ baseNotFoundItem,
+ {
+ id: 2,
+ name: "provider-b",
+ reason: "retry_success",
+ statusCode: 200,
+ timestamp: 2000,
+ attemptNumber: 1,
+ },
+ ];
+ const result = formatProviderSummary(chain, mockT);
+
+ expect(result).toContain("provider-a");
+ expect(result).toContain("provider-b");
+ expect(result).toMatch(/provider-a\(.*\).*provider-b\(.*\)/);
+ });
+ });
+
+ describe("formatProviderDescription", () => {
+ test("shows resource not found label in request chain", () => {
+ const chain: ProviderChainItem[] = [baseNotFoundItem];
+ const result = formatProviderDescription(chain, mockT);
+
+ expect(result).toContain("provider-a");
+ expect(result).toContain("description.resourceNotFound");
+ });
+ });
+
+ describe("formatProviderTimeline", () => {
+ test("renders resource_not_found with status code and note", () => {
+ const chain: ProviderChainItem[] = [baseNotFoundItem];
+ const { timeline } = formatProviderTimeline(chain, mockT);
+
+ expect(timeline).toContain("timeline.resourceNotFoundFailed [attempt=1]");
+ expect(timeline).toContain("timeline.statusCode [code=404]");
+ expect(timeline).toContain("timeline.resourceNotFoundNote");
+ });
+
+ test("renders inferred status code label when statusCodeInferred=true", () => {
+ const chain: ProviderChainItem[] = [{ ...baseNotFoundItem, statusCodeInferred: true }];
+ const { timeline } = formatProviderTimeline(chain, mockT);
+
+ expect(timeline).toContain("timeline.resourceNotFoundFailed [attempt=1]");
+ expect(timeline).toContain("timeline.statusCodeInferred [code=404]");
+ expect(timeline).toContain("timeline.resourceNotFoundNote");
+ });
+
+ test("degrades gracefully when errorDetails.provider is missing", () => {
+ const chain: ProviderChainItem[] = [
+ {
+ ...baseNotFoundItem,
+ errorDetails: {
+ request: {
+ method: "POST",
+ url: "https://example.com/v1/messages",
+ headers: "{}",
+ body: "{}",
+ bodyTruncated: false,
+ },
+ },
+ },
+ ];
+ const { timeline } = formatProviderTimeline(chain, mockT);
+
+ expect(timeline).toContain("timeline.resourceNotFoundFailed [attempt=1]");
+ expect(timeline).toContain("timeline.provider [provider=provider-a]");
+ expect(timeline).toContain("timeline.statusCode [code=404]");
+ expect(timeline).toContain("timeline.error [error=Not Found]");
+ expect(timeline).toContain("timeline.resourceNotFoundNote");
+ });
+ });
+});
+
// =============================================================================
// Unknown reason graceful degradation
// =============================================================================
diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts
index 46d2f4e24..98e3188f5 100644
--- a/src/lib/utils/provider-chain-formatter.ts
+++ b/src/lib/utils/provider-chain-formatter.ts
@@ -63,6 +63,7 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | "
if (
item.reason === "retry_failed" ||
item.reason === "system_error" ||
+ item.reason === "resource_not_found" ||
item.reason === "client_error_non_retryable" ||
item.reason === "endpoint_pool_exhausted" ||
item.reason === "vendor_type_all_timeout"
@@ -92,6 +93,7 @@ function isActualRequest(item: ProviderChainItem): boolean {
if (
item.reason === "retry_failed" ||
item.reason === "system_error" ||
+ item.reason === "resource_not_found" ||
item.reason === "client_error_non_retryable" ||
item.reason === "endpoint_pool_exhausted" ||
item.reason === "vendor_type_all_timeout"
@@ -127,6 +129,16 @@ function translateCircuitState(state: string | undefined, t: (key: string) => st
}
}
+function formatTimelineStatusCode(
+ item: ProviderChainItem,
+ code: number,
+ t: (key: string, values?: Record) => string
+): string {
+ return item.statusCodeInferred
+ ? t("timeline.statusCodeInferred", { code })
+ : t("timeline.statusCode", { code });
+}
+
/**
* 辅助函数:获取错误码含义
*/
@@ -313,6 +325,8 @@ export function formatProviderDescription(
desc += ` ${t("description.http2Fallback")}`;
} else if (item.reason === "client_error_non_retryable") {
desc += ` ${t("description.clientError")}`;
+ } else if (item.reason === "resource_not_found") {
+ desc += ` ${t("description.resourceNotFound")}`;
} else if (item.reason === "endpoint_pool_exhausted") {
desc += ` ${t("description.endpointPoolExhausted")}`;
} else if (item.reason === "vendor_type_all_timeout") {
@@ -445,6 +459,47 @@ export function formatProviderTimeline(
continue;
}
+ // === 资源不存在(上游 404) ===
+ if (item.reason === "resource_not_found") {
+ const attempt = actualAttemptNumber ?? item.attemptNumber ?? 0;
+ timeline += `${t("timeline.resourceNotFoundFailed", { attempt })}\n\n`;
+
+ if (item.errorDetails?.provider) {
+ const p = item.errorDetails.provider;
+ timeline += `${t("timeline.provider", { provider: p.name })}\n`;
+ timeline += `${formatTimelineStatusCode(item, p.statusCode, t)}\n`;
+ timeline += `${t("timeline.error", { error: p.statusText })}\n`;
+
+ // 计算请求耗时
+ if (i > 0 && item.timestamp && chain[i - 1]?.timestamp) {
+ const duration = item.timestamp - (chain[i - 1]?.timestamp || 0);
+ timeline += `${t("timeline.requestDuration", { duration })}\n`;
+ }
+
+ // 错误详情(格式化 JSON)
+ if (p.upstreamParsed) {
+ timeline += `\n${t("timeline.errorDetails")}:\n`;
+ timeline += JSON.stringify(p.upstreamParsed, null, 2);
+ } else if (p.upstreamBody) {
+ timeline += `\n${t("timeline.errorDetails")}:\n${p.upstreamBody}`;
+ }
+ } else {
+ timeline += `${t("timeline.provider", { provider: item.name })}\n`;
+ if (item.statusCode) {
+ timeline += `${formatTimelineStatusCode(item, item.statusCode, t)}\n`;
+ }
+ timeline += t("timeline.error", { error: item.errorMessage || t("timeline.unknown") });
+ }
+
+ // 请求详情(用于问题排查)
+ if (item.errorDetails?.request) {
+ timeline += formatRequestDetails(item.errorDetails.request, t);
+ }
+
+ timeline += `\n${t("timeline.resourceNotFoundNote")}`;
+ continue;
+ }
+
// === 供应商错误(请求失败) ===
if (item.reason === "retry_failed") {
timeline += `${t("timeline.requestFailed", { attempt: actualAttemptNumber ?? 0 })}\n\n`;
@@ -453,7 +508,7 @@ export function formatProviderTimeline(
if (item.errorDetails?.provider) {
const p = item.errorDetails.provider;
timeline += `${t("timeline.provider", { provider: p.name })}\n`;
- timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`;
+ timeline += `${formatTimelineStatusCode(item, p.statusCode, t)}\n`;
timeline += `${t("timeline.error", { error: p.statusText })}\n`;
// 计算请求耗时
@@ -500,7 +555,7 @@ export function formatProviderTimeline(
// 降级:使用 errorMessage
timeline += `${t("timeline.provider", { provider: item.name })}\n`;
if (item.statusCode) {
- timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`;
+ timeline += `${formatTimelineStatusCode(item, item.statusCode, t)}\n`;
}
timeline += t("timeline.error", { error: item.errorMessage || t("timeline.unknown") });
@@ -588,12 +643,12 @@ export function formatProviderTimeline(
if (item.errorDetails?.provider) {
const p = item.errorDetails.provider;
timeline += `${t("timeline.provider", { provider: p.name })}\n`;
- timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`;
+ timeline += `${formatTimelineStatusCode(item, p.statusCode, t)}\n`;
timeline += `${t("timeline.error", { error: p.statusText })}\n`;
} else {
timeline += `${t("timeline.provider", { provider: item.name })}\n`;
if (item.statusCode) {
- timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`;
+ timeline += `${formatTimelineStatusCode(item, item.statusCode, t)}\n`;
}
timeline += `${t("timeline.error", { error: item.errorMessage || t("timeline.unknown") })}\n`;
}
diff --git a/src/lib/utils/upstream-error-detection.test.ts b/src/lib/utils/upstream-error-detection.test.ts
index d1facd969..957ef374b 100644
--- a/src/lib/utils/upstream-error-detection.test.ts
+++ b/src/lib/utils/upstream-error-detection.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, test } from "vitest";
-import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection";
+import {
+ detectUpstreamErrorFromSseOrJsonText,
+ inferUpstreamErrorStatusCodeFromText,
+} from "@/lib/utils/upstream-error-detection";
describe("detectUpstreamErrorFromSseOrJsonText", () => {
test("空响应体视为错误", () => {
@@ -16,6 +19,49 @@ describe("detectUpstreamErrorFromSseOrJsonText", () => {
});
});
+ test("明显的 HTML 文档视为错误(覆盖 200+text/html 的“假 200”)", () => {
+ const html = [
+ "",
+ '',
+ "New API",
+ "Something went wrong",
+ "",
+ ].join("\n");
+ const res = detectUpstreamErrorFromSseOrJsonText(html);
+ expect(res).toEqual({
+ isError: true,
+ code: "FAKE_200_HTML_BODY",
+ detail: expect.any(String),
+ });
+ });
+
+ test("带 BOM 的 HTML 文档也应视为错误", () => {
+ const htmlWithBom = "\uFEFF \n\nblocked";
+ const res = detectUpstreamErrorFromSseOrJsonText(htmlWithBom);
+ expect(res.isError).toBe(true);
+ if (res.isError) {
+ expect(res.code).toBe("FAKE_200_HTML_BODY");
+ }
+ });
+
+ test("带 BOM 的 JSON error 也应正常识别", () => {
+ const jsonWithBom = '\uFEFF \n{"error":"当前无可用凭证"}';
+ const res = detectUpstreamErrorFromSseOrJsonText(jsonWithBom);
+ expect(res.isError).toBe(true);
+ if (res.isError) {
+ expect(res.code).toBe("FAKE_200_JSON_ERROR_NON_EMPTY");
+ }
+ });
+
+ test("纯 JSON:content 内包含 文本不应误判为 HTML 错误", () => {
+ const body = JSON.stringify({
+ type: "message",
+ content: [{ type: "text", text: "not an error" }],
+ });
+ const res = detectUpstreamErrorFromSseOrJsonText(body);
+ expect(res.isError).toBe(false);
+ });
+
test("纯 JSON:error 字段非空视为错误", () => {
const res = detectUpstreamErrorFromSseOrJsonText('{"error":"当前无可用凭证"}');
expect(res.isError).toBe(true);
@@ -211,3 +257,63 @@ describe("detectUpstreamErrorFromSseOrJsonText", () => {
expect(res.isError).toBe(false);
});
});
+
+describe("inferUpstreamErrorStatusCodeFromText", () => {
+ test("空文本不推断状态码", () => {
+ expect(inferUpstreamErrorStatusCodeFromText("")).toBeNull();
+ expect(inferUpstreamErrorStatusCodeFromText(" \n\t ")).toBeNull();
+ });
+
+ test("可从错误文本中推断 429(rate limit)", () => {
+ expect(inferUpstreamErrorStatusCodeFromText('{"error":"Rate limit exceeded"}')).toEqual({
+ statusCode: 429,
+ matcherId: "rate_limit",
+ });
+ });
+
+ test("可从错误文本中推断 401(invalid api key)", () => {
+ expect(inferUpstreamErrorStatusCodeFromText('{"error":"Invalid API key"}')).toEqual({
+ statusCode: 401,
+ matcherId: "unauthorized",
+ });
+ });
+
+ test("可从错误文本中推断 403(access denied)", () => {
+ expect(inferUpstreamErrorStatusCodeFromText("Access denied")).toEqual({
+ statusCode: 403,
+ matcherId: "forbidden",
+ });
+ });
+
+ test("可从错误文本中推断 402(billing hard limit)", () => {
+ expect(inferUpstreamErrorStatusCodeFromText("billing_hard_limit_reached")).toEqual({
+ statusCode: 402,
+ matcherId: "payment_required",
+ });
+ });
+
+ test("可从错误文本中推断 404(model not found)", () => {
+ expect(inferUpstreamErrorStatusCodeFromText("model not found")).toEqual({
+ statusCode: 404,
+ matcherId: "not_found",
+ });
+ });
+
+ test("可从错误文本中推断 413(payload too large)", () => {
+ expect(inferUpstreamErrorStatusCodeFromText("payload too large")).toEqual({
+ statusCode: 413,
+ matcherId: "payload_too_large",
+ });
+ });
+
+ test("可从错误文本中推断 415(unsupported media type)", () => {
+ expect(inferUpstreamErrorStatusCodeFromText("Unsupported Media Type")).toEqual({
+ statusCode: 415,
+ matcherId: "unsupported_media_type",
+ });
+ });
+
+ test("仅包含泛化 error 字样时不推断(避免误判)", () => {
+ expect(inferUpstreamErrorStatusCodeFromText('{"message":"some error happened"}')).toBeNull();
+ });
+});
diff --git a/src/lib/utils/upstream-error-detection.ts b/src/lib/utils/upstream-error-detection.ts
index 066f1bc8f..faca099b0 100644
--- a/src/lib/utils/upstream-error-detection.ts
+++ b/src/lib/utils/upstream-error-detection.ts
@@ -18,6 +18,7 @@ import { parseSSEData } from "@/lib/utils/sse";
*
* 设计目标(偏保守)
* - 仅基于结构化字段做启发式判断:`error` 与 `message`;
+ * - 对明显的 HTML 文档(doctype/html 标签)做强信号判定,覆盖部分网关/WAF/Cloudflare 返回的“假 200”;
* - 不扫描模型生成的正文内容(例如 content/choices),避免把用户/模型自然语言里的 "error" 误判为上游错误;
* - message 关键字检测仅对“小体积 JSON”启用,降低误判与性能开销。
* - 返回的 `code` 是语言无关的错误码(便于写入 DB/监控/告警);
@@ -31,6 +32,22 @@ export type UpstreamErrorDetectionResult =
detail?: string;
};
+/**
+ * 基于“响应体文本内容”的状态码推断结果。
+ *
+ * 设计目标(偏保守):
+ * - 仅用于“假 200”场景:上游返回 HTTP 200,但 body 明显是错误页/错误 JSON;
+ * - 用于把内部结算/熔断/故障转移的 statusCode 调整为更贴近真实错误语义的 4xx/5xx;
+ * - 若未命中任何规则,应保持调用方既有默认行为(通常回退为 502)。
+ */
+export type UpstreamErrorStatusInferenceResult = {
+ statusCode: number;
+ /**
+ * 命中的规则 id(用于内部审计/调试;不应作为用户展示文案)。
+ */
+ matcherId: string;
+};
+
type DetectionOptions = {
/**
* 仅对小体积 JSON 启用 message 关键字检测,避免误判与无谓开销。
@@ -53,6 +70,7 @@ const DEFAULT_MESSAGE_KEYWORD = /error/i;
const FAKE_200_CODES = {
EMPTY_BODY: "FAKE_200_EMPTY_BODY",
+ HTML_BODY: "FAKE_200_HTML_BODY",
JSON_ERROR_NON_EMPTY: "FAKE_200_JSON_ERROR_NON_EMPTY",
JSON_ERROR_MESSAGE_NON_EMPTY: "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY",
JSON_MESSAGE_KEYWORD_MATCH: "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH",
@@ -63,6 +81,142 @@ const FAKE_200_CODES = {
const MAY_HAVE_JSON_ERROR_KEY = /"error"\s*:/;
const MAY_HAVE_JSON_MESSAGE_KEY = /"message"\s*:/;
+const HTML_DOC_SNIFF_MAX_CHARS = 1024;
+const HTML_DOCTYPE_RE = /^]/i;
+const HTML_HTML_TAG_RE = /^]/i;
+
+// 状态码推断:为避免在极端大响应体上执行正则带来额外开销,仅取前缀做匹配。
+// 说明:对“假 200”错误页/错误 JSON 来说,关键错误信息通常会出现在前段。
+const STATUS_INFERENCE_MAX_CHARS = 64 * 1024;
+
+// 注意:这些正则只用于“假 200”场景,且仅在 detectUpstreamErrorFromSseOrJsonText 已判定 isError=true 时才会被调用。
+// 因此允许包含少量“关键词启发式”,但仍应尽量避免过宽匹配,降低误判导致“错误码误推断”的概率。
+const ERROR_STATUS_MATCHERS: Array<{ statusCode: number; matcherId: string; re: RegExp }> = [
+ {
+ statusCode: 429,
+ matcherId: "rate_limit",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+429\b|\b429\s+too\s+many\s+requests\b|\btoo\s+many\s+requests\b|\brate\s*limit(?:ed|ing)?\b|\bthrottl(?:e|ed|ing)\b|\bretry-after\b|\bRESOURCE_EXHAUSTED\b|\bRequestLimitExceeded\b|\bThrottling(?:Exception)?\b|\bError\s*1015\b|超出频率|请求过于频繁|限流|稍后重试)/iu,
+ },
+ {
+ statusCode: 402,
+ matcherId: "payment_required",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+402\b|\bpayment\s+required\b|\binsufficient\s+(?:balance|funds|credits)\b|\b(?:out\s+of|no)\s+credits\b|\binsufficient_balance\b|\bbilling_hard_limit_reached\b|\bcard\s+(?:declined|expired)\b|\bpayment\s+(?:method|failed)\b|余额不足|欠费|请充值|支付(?:失败|方式))/iu,
+ },
+ {
+ statusCode: 401,
+ matcherId: "unauthorized",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+401\b|\bunauthori(?:sed|zed)\b|\bunauthenticated\b|\bauthentication\s+failed\b|\b(?:invalid|incorrect|missing)\s+api[-_ ]?key\b|\binvalid\s+token\b|\bexpired\s+token\b|\bsignature\s+(?:invalid|mismatch)\b|\bUNAUTHENTICATED\b|未授权|鉴权失败|密钥无效|token\s*过期)/iu,
+ },
+ {
+ statusCode: 403,
+ matcherId: "forbidden",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+403\b|\bforbidden\b|\bpermission\s+denied\b|\baccess\s+denied\b|\bnot\s+allowed\b|\baccount\s+(?:disabled|suspended|banned)\b|\bnot\s+whitelisted\b|\bPERMISSION_DENIED\b|\bAccessDenied(?:Exception)?\b|\bError\s*1020\b|\b(?:region|country)\b[\s\S]{0,40}\b(?:not\s+supported|blocked)\b|地区不支持|禁止访问|无权限|权限不足|账号被封|地区(?:限制|屏蔽))/iu,
+ },
+ {
+ statusCode: 404,
+ matcherId: "not_found",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+404\b|\bnot\s+found\b|\b(?:model|deployment|endpoint|resource)\s+not\s+found\b|\bunknown\s+model\b|\bdoes\s+not\s+exist\b|\bNOT_FOUND\b|\bResourceNotFoundException\b|未找到|不存在|模型不存在)/iu,
+ },
+ {
+ statusCode: 413,
+ matcherId: "payload_too_large",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+413\b|\bpayload\s+too\s+large\b|\brequest\s+entity\s+too\s+large\b|\bbody\s+too\s+large\b|\bContent-Length\b[\s\S]{0,40}\btoo\s+large\b|\bexceed(?:s|ed)?\b[\s\S]{0,40}\b(?:max(?:imum)?|limit)\b[\s\S]{0,40}\b(?:size|length)\b|请求体过大|内容过大|超过最大)/iu,
+ },
+ {
+ statusCode: 415,
+ matcherId: "unsupported_media_type",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+415\b|\bunsupported\s+media\s+type\b|\binvalid\s+content-type\b|\bContent-Type\b[\s\S]{0,40}\b(?:must\s+be|required)\b|不支持的媒体类型|Content-Type\s*错误)/iu,
+ },
+ {
+ statusCode: 409,
+ matcherId: "conflict",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+409\b|\bconflict\b|\bidempotency(?:-key)?\b|\bABORTED\b|冲突|幂等)/iu,
+ },
+ {
+ statusCode: 422,
+ matcherId: "unprocessable_entity",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+422\b|\bunprocessable\s+entity\b|\bINVALID_ARGUMENT\b[\s\S]{0,40}\bvalidation\b|\bschema\s+validation\b|实体无法处理)/iu,
+ },
+ {
+ statusCode: 408,
+ matcherId: "request_timeout",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+408\b|\brequest\s+timeout\b|请求\s*超时)/iu,
+ },
+ {
+ statusCode: 451,
+ matcherId: "legal_restriction",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+451\b|\bunavailable\s+for\s+legal\s+reasons\b|\bexport\s+control\b|\bsanctions?\b|法律原因不可用|合规限制|出口管制)/iu,
+ },
+ {
+ statusCode: 503,
+ matcherId: "service_unavailable",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+503\b|\bservice\s+unavailable\b|\boverloaded\b|\bserver\s+is\s+busy\b|\btry\s+again\s+later\b|\btemporarily\s+unavailable\b|\bmaintenance\b|\bUNAVAILABLE\b|\bServiceUnavailableException\b|\bError\s*521\b|服务不可用|过载|系统繁忙|维护中)/iu,
+ },
+ {
+ statusCode: 504,
+ matcherId: "gateway_timeout",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+504\b|\bgateway\s+timeout\b|\bupstream\b[\s\S]{0,40}\btim(?:e|ed)\s*out\b|\bDEADLINE_EXCEEDED\b|\bError\s*522\b|\bError\s*524\b|网关超时|上游超时)/iu,
+ },
+ {
+ statusCode: 500,
+ matcherId: "internal_server_error",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+500\b|\binternal\s+server\s+error\b|\bInternalServerException\b|\bINTERNAL\b|内部错误|服务器错误)/iu,
+ },
+ {
+ statusCode: 400,
+ matcherId: "bad_request",
+ re: /(?:\bHTTP\/\d(?:\.\d)?\s+400\b|\bbad\s+request\b|\bINVALID_ARGUMENT\b|\bjson\s+parse\b|\binvalid\s+json\b|\bunexpected\s+token\b|无效请求|格式错误|JSON\s*解析失败)/iu,
+ },
+];
+
+/**
+ * 从上游响应体文本中推断一个“更贴近错误语义”的 HTTP 状态码(用于假200修正)。
+ *
+ * 注意:
+ * - 该函数不会判断“是否为错误”,只做“状态码推断”;调用方应确保仅在已判定错误时才调用。
+ * - 未命中时返回 null,调用方应保持现有默认错误码(通常为 502)。
+ */
+export function inferUpstreamErrorStatusCodeFromText(
+ text: string
+): UpstreamErrorStatusInferenceResult | null {
+ let trimmed = text.trim();
+ if (!trimmed) return null;
+
+ // 与 detectUpstreamErrorFromSseOrJsonText 保持一致:移除 UTF-8 BOM,避免关键字匹配失效。
+ if (trimmed.charCodeAt(0) === 0xfeff) {
+ trimmed = trimmed.slice(1).trimStart();
+ }
+
+ const limited =
+ trimmed.length > STATUS_INFERENCE_MAX_CHARS
+ ? trimmed.slice(0, STATUS_INFERENCE_MAX_CHARS)
+ : trimmed;
+
+ for (const matcher of ERROR_STATUS_MATCHERS) {
+ if (matcher.re.test(limited)) {
+ return { statusCode: matcher.statusCode, matcherId: matcher.matcherId };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * 判断文本是否“很像”一个完整的 HTML 文档(强信号)。
+ *
+ * 规则(偏保守):
+ * - 仅当文本以 `<` 开头时才继续;
+ * - 仅在前 1024 字符内检测 `` 或以 `` 开头;
+ * - 不做 HTML 解析/清洗,避免误判与额外开销。
+ *
+ * 说明:调用方应先对文本做 `trim()`,并在需要时移除 BOM(`\uFEFF`)。
+ */
+function isLikelyHtmlDocument(trimmedText: string): boolean {
+ if (!trimmedText.startsWith("<")) return false;
+ const head = trimmedText.slice(0, HTML_DOC_SNIFF_MAX_CHARS);
+ return HTML_DOCTYPE_RE.test(head) || HTML_HTML_TAG_RE.test(head);
+}
+
function isPlainRecord(value: unknown): value is Record {
return !!value && typeof value === "object" && !Array.isArray(value);
}
@@ -82,7 +236,7 @@ function hasNonEmptyValue(value: unknown): boolean {
return true;
}
-function sanitizeErrorTextForDetail(text: string): string {
+export function sanitizeErrorTextForDetail(text: string): string {
// 注意:这里的目的不是“完美脱敏”,而是尽量降低上游错误信息中意外夹带敏感内容的风险。
// 若后续发现更多敏感模式,可在不改变检测语义的前提下补充。
let sanitized = text;
@@ -189,11 +343,31 @@ export function detectUpstreamErrorFromSseOrJsonText(
messageKeyword: options.messageKeyword ?? DEFAULT_MESSAGE_KEYWORD,
};
- const trimmed = text.trim();
+ let trimmed = text.trim();
if (!trimmed) {
return { isError: true, code: FAKE_200_CODES.EMPTY_BODY };
}
+ // 某些上游会带 UTF-8 BOM(\uFEFF),会导致 startsWith("{") / startsWith("<") 等快速判断失效。
+ // 这里仅剥离首字符 BOM,并再做一次 trimStart,避免误判。
+ if (trimmed.charCodeAt(0) === 0xfeff) {
+ trimmed = trimmed.slice(1).trimStart();
+ }
+
+ // 情况 0:明显的 HTML 文档(通常是网关/WAF/Cloudflare 返回的错误页)
+ //
+ // 说明:
+ // - 此处不依赖 Content-Type:部分上游会缺失/错误设置该字段;
+ // - 仅匹配 doctype/html 标签等“强信号”,避免把普通 `<...>` 文本误判为 HTML 页面。
+ if (isLikelyHtmlDocument(trimmed)) {
+ return {
+ isError: true,
+ code: FAKE_200_CODES.HTML_BODY,
+ // 避免对超大 HTML 做无谓处理:仅截取前段用于脱敏/截断与排查
+ detail: truncateForDetail(trimmed.slice(0, 4096)),
+ };
+ }
+
// 情况 1:纯 JSON(对象)
// 上游可能 Content-Type 设置为 SSE,但实际上返回 JSON;此处只处理对象格式({...}),
// 不处理数组([...])以避免误判(数组场景的语义差异较大,后续若确认需要再扩展)。
diff --git a/src/types/message.ts b/src/types/message.ts
index 56fab4abd..faa2e3f6f 100644
--- a/src/types/message.ts
+++ b/src/types/message.ts
@@ -71,6 +71,15 @@ export interface ProviderChainItem {
// 修复:新增成功时的状态码
statusCode?: number;
+ /**
+ * 标记 statusCode 是否为“基于响应体内容推断”的结果(而非上游真实返回的 HTTP 状态码)。
+ *
+ * 典型场景:上游返回 HTTP 200,但 body 为错误页/错误 JSON(假 200)。
+ * 此时为了让熔断/故障转移/会话绑定与“真实错误语义”保持一致,CCH 会推断更合理的 4xx/5xx。
+ *
+ * 该字段用于在决策链 / 技术时间线 / UI 中显著提示“此状态码为推断”,避免误读。
+ */
+ statusCodeInferred?: boolean;
// 模型重定向信息(在供应商级别记录)
modelRedirect?: {
diff --git a/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts
new file mode 100644
index 000000000..c4c01dece
--- /dev/null
+++ b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts
@@ -0,0 +1,184 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const mocks = vi.hoisted(() => {
+ return {
+ getCachedSystemSettings: vi.fn(async () => ({ verboseProviderError: false }) as any),
+ getErrorOverrideAsync: vi.fn(async () => undefined),
+ };
+});
+
+vi.mock("@/lib/config/system-settings-cache", () => ({
+ getCachedSystemSettings: mocks.getCachedSystemSettings,
+}));
+
+vi.mock("@/lib/logger", () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ trace: vi.fn(),
+ error: vi.fn(),
+ fatal: vi.fn(),
+ },
+}));
+
+vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getErrorOverrideAsync: mocks.getErrorOverrideAsync,
+ };
+});
+
+import { ProxyErrorHandler } from "@/app/v1/_lib/proxy/error-handler";
+import { EmptyResponseError, ProxyError } from "@/app/v1/_lib/proxy/errors";
+
+function createSession(): any {
+ return {
+ sessionId: null,
+ messageContext: null,
+ startTime: Date.now(),
+ getProviderChain: () => [],
+ getCurrentModel: () => null,
+ getContext1mApplied: () => false,
+ provider: null,
+ };
+}
+
+describe("ProxyErrorHandler.handle - verboseProviderError details", () => {
+ beforeEach(() => {
+ mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: false } as any);
+ mocks.getErrorOverrideAsync.mockResolvedValue(undefined);
+ });
+
+ test("verboseProviderError=false 时,不应附带 fake-200 raw body/details", async () => {
+ const session = createSession();
+ const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 429, {
+ body: "sanitized",
+ providerId: 1,
+ providerName: "p1",
+ requestId: "req_123",
+ rawBody: '{"error":"boom"}',
+ rawBodyTruncated: false,
+ statusCodeInferred: true,
+ statusCodeInferenceMatcherId: "rate_limit",
+ });
+
+ const res = await ProxyErrorHandler.handle(session, err);
+ expect(res.status).toBe(429);
+
+ const body = await res.json();
+ expect(body.error.details).toBeUndefined();
+ expect(body.request_id).toBeUndefined();
+ });
+
+ test("verboseProviderError=true 时,fake-200 应返回详细报告与上游原文", async () => {
+ mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any);
+
+ const session = createSession();
+ const err = new ProxyError("FAKE_200_HTML_BODY", 429, {
+ body: "redacted snippet",
+ providerId: 1,
+ providerName: "p1",
+ requestId: "req_123",
+ rawBody: "blocked",
+ rawBodyTruncated: false,
+ statusCodeInferred: true,
+ statusCodeInferenceMatcherId: "rate_limit",
+ });
+
+ const res = await ProxyErrorHandler.handle(session, err);
+ expect(res.status).toBe(429);
+
+ const body = await res.json();
+ expect(body.request_id).toBe("req_123");
+ expect(body.error.details).toEqual({
+ upstreamError: {
+ kind: "fake_200",
+ code: "FAKE_200_HTML_BODY",
+ statusCode: 429,
+ statusCodeInferred: true,
+ statusCodeInferenceMatcherId: "rate_limit",
+ clientSafeMessage: expect.any(String),
+ rawBody: "blocked",
+ rawBodyTruncated: false,
+ },
+ });
+ });
+
+ test("verboseProviderError=true 时,rawBody 应做基础脱敏(避免泄露 token/key)", async () => {
+ mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any);
+
+ const session = createSession();
+ const err = new ProxyError("FAKE_200_HTML_BODY", 429, {
+ body: "redacted snippet",
+ providerId: 1,
+ providerName: "p1",
+ requestId: "req_123",
+ rawBody:
+ "Authorization: Bearer abc123 sk-1234567890abcdef1234567890 test@example.com",
+ rawBodyTruncated: false,
+ statusCodeInferred: true,
+ statusCodeInferenceMatcherId: "rate_limit",
+ });
+
+ const res = await ProxyErrorHandler.handle(session, err);
+ expect(res.status).toBe(429);
+
+ const body = await res.json();
+ expect(body.request_id).toBe("req_123");
+ expect(body.error.details.upstreamError.kind).toBe("fake_200");
+
+ const rawBody = body.error.details.upstreamError.rawBody as string;
+ expect(rawBody).toContain("Bearer [REDACTED]");
+ expect(rawBody).toContain("[REDACTED_KEY]");
+ expect(rawBody).toContain("[EMAIL]");
+ expect(rawBody).not.toContain("Bearer abc123");
+ expect(rawBody).not.toContain("sk-1234567890abcdef1234567890");
+ expect(rawBody).not.toContain("test@example.com");
+ });
+
+ test("verboseProviderError=true 时,空响应错误也应返回详细报告(rawBody 为空字符串)", async () => {
+ mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any);
+
+ const session = createSession();
+ const err = new EmptyResponseError(1, "p1", "empty_body");
+
+ const res = await ProxyErrorHandler.handle(session, err);
+ expect(res.status).toBe(502);
+
+ const body = await res.json();
+ expect(body.error.details).toEqual({
+ upstreamError: {
+ kind: "empty_response",
+ reason: "empty_body",
+ clientSafeMessage: "Empty response: Response body is empty",
+ rawBody: "",
+ rawBodyTruncated: false,
+ },
+ });
+ });
+
+ test("有 error override 时,verbose details 不应覆盖覆写逻辑(优先级更低)", async () => {
+ mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any);
+ mocks.getErrorOverrideAsync.mockResolvedValue({ response: null, statusCode: 418 });
+
+ const session = createSession();
+ const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 429, {
+ body: "sanitized",
+ providerId: 1,
+ providerName: "p1",
+ requestId: "req_123",
+ rawBody: '{"error":"boom"}',
+ rawBodyTruncated: false,
+ statusCodeInferred: true,
+ statusCodeInferenceMatcherId: "rate_limit",
+ });
+
+ const res = await ProxyErrorHandler.handle(session, err);
+ expect(res.status).toBe(418);
+
+ const body = await res.json();
+ expect(body.error.details).toBeUndefined();
+ });
+});
diff --git a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts
index 6891fff23..0a3e23e53 100644
--- a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts
+++ b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts
@@ -562,6 +562,59 @@ describe("ProxyForwarder - endpoint audit", () => {
expect(exhaustedItem!.errorMessage).toBeUndefined();
});
+ test("endpoint_pool_exhausted should not be deduped away when initial_selection already recorded", async () => {
+ const requestPath = "/v1/messages";
+ const session = createSession(new URL(`https://example.com${requestPath}`));
+ const provider = createProvider({
+ providerType: "claude",
+ providerVendorId: 123,
+ url: "https://provider.example.com/v1/messages",
+ });
+ session.setProvider(provider);
+
+ // Simulate ProviderSelector already recorded initial_selection for the same provider
+ session.addProviderToChain(provider, { reason: "initial_selection" });
+
+ mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([]);
+ mocks.getEndpointFilterStats.mockResolvedValueOnce({
+ total: 0,
+ enabled: 0,
+ circuitOpen: 0,
+ available: 0,
+ });
+
+ const doForward = vi.spyOn(
+ ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
+ "doForward"
+ );
+
+ await expect(ProxyForwarder.send(session)).rejects.toThrow();
+
+ expect(doForward).not.toHaveBeenCalled();
+
+ const chain = session.getProviderChain();
+ expect(chain.some((item) => item.reason === "initial_selection")).toBe(true);
+
+ const exhaustedItems = chain.filter((item) => item.reason === "endpoint_pool_exhausted");
+ expect(exhaustedItems).toHaveLength(1);
+
+ expect(exhaustedItems[0]).toEqual(
+ expect.objectContaining({
+ id: provider.id,
+ name: provider.name,
+ reason: "endpoint_pool_exhausted",
+ strictBlockCause: "no_endpoint_candidates",
+ attemptNumber: 1,
+ endpointFilterStats: {
+ total: 0,
+ enabled: 0,
+ circuitOpen: 0,
+ available: 0,
+ },
+ })
+ );
+ });
+
test("endpoint pool exhausted (selector_error) should record endpoint_pool_exhausted with selectorError in decisionContext", async () => {
const requestPath = "/v1/responses";
const session = createSession(new URL(`https://example.com${requestPath}`));
diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts
new file mode 100644
index 000000000..007ecb371
--- /dev/null
+++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts
@@ -0,0 +1,536 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const mocks = vi.hoisted(() => {
+ return {
+ pickRandomProviderWithExclusion: vi.fn(),
+ recordSuccess: vi.fn(),
+ recordFailure: vi.fn(async () => {}),
+ getCircuitState: vi.fn(() => "closed"),
+ getProviderHealthInfo: vi.fn(async () => ({
+ health: { failureCount: 0 },
+ config: { failureThreshold: 3 },
+ })),
+ updateMessageRequestDetails: vi.fn(async () => {}),
+ isHttp2Enabled: vi.fn(async () => false),
+ getPreferredProviderEndpoints: vi.fn(async () => []),
+ getEndpointFilterStats: vi.fn(async () => null),
+ recordEndpointSuccess: vi.fn(async () => {}),
+ recordEndpointFailure: vi.fn(async () => {}),
+ isVendorTypeCircuitOpen: vi.fn(async () => false),
+ recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}),
+ // ErrorCategory.PROVIDER_ERROR
+ categorizeErrorAsync: vi.fn(async () => 0),
+ };
+});
+
+vi.mock("@/lib/logger", () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ trace: vi.fn(),
+ error: vi.fn(),
+ fatal: vi.fn(),
+ },
+}));
+
+vi.mock("@/lib/config", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ isHttp2Enabled: mocks.isHttp2Enabled,
+ };
+});
+
+vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({
+ getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints,
+ getEndpointFilterStats: mocks.getEndpointFilterStats,
+}));
+
+vi.mock("@/lib/endpoint-circuit-breaker", () => ({
+ recordEndpointSuccess: mocks.recordEndpointSuccess,
+ recordEndpointFailure: mocks.recordEndpointFailure,
+}));
+
+vi.mock("@/lib/circuit-breaker", () => ({
+ getCircuitState: mocks.getCircuitState,
+ getProviderHealthInfo: mocks.getProviderHealthInfo,
+ recordFailure: mocks.recordFailure,
+ recordSuccess: mocks.recordSuccess,
+}));
+
+vi.mock("@/lib/vendor-type-circuit-breaker", () => ({
+ isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen,
+ recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout,
+}));
+
+vi.mock("@/repository/message", () => ({
+ updateMessageRequestDetails: mocks.updateMessageRequestDetails,
+}));
+
+vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({
+ ProxyProviderResolver: {
+ pickRandomProviderWithExclusion: mocks.pickRandomProviderWithExclusion,
+ },
+}));
+
+vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ categorizeErrorAsync: mocks.categorizeErrorAsync,
+ };
+});
+
+import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
+import { ProxyError } from "@/app/v1/_lib/proxy/errors";
+import { ProxySession } from "@/app/v1/_lib/proxy/session";
+import type { Provider } from "@/types/provider";
+
+function createProvider(overrides: Partial = {}): Provider {
+ return {
+ id: 1,
+ name: "p1",
+ url: "https://provider.example.com",
+ key: "k",
+ providerVendorId: null,
+ isEnabled: true,
+ weight: 1,
+ priority: 0,
+ groupPriorities: null,
+ costMultiplier: 1,
+ groupTag: null,
+ providerType: "claude",
+ preserveClientIp: false,
+ modelRedirects: null,
+ allowedModels: null,
+ mcpPassthroughType: "none",
+ mcpPassthroughUrl: null,
+ limit5hUsd: null,
+ limitDailyUsd: null,
+ dailyResetMode: "fixed",
+ dailyResetTime: "00:00",
+ limitWeeklyUsd: null,
+ limitMonthlyUsd: null,
+ limitTotalUsd: null,
+ totalCostResetAt: null,
+ limitConcurrentSessions: 0,
+ maxRetryAttempts: 1,
+ circuitBreakerFailureThreshold: 5,
+ circuitBreakerOpenDuration: 1_800_000,
+ circuitBreakerHalfOpenSuccessThreshold: 2,
+ proxyUrl: null,
+ proxyFallbackToDirect: false,
+ firstByteTimeoutStreamingMs: 30_000,
+ streamingIdleTimeoutMs: 10_000,
+ requestTimeoutNonStreamingMs: 1_000,
+ websiteUrl: null,
+ faviconUrl: null,
+ cacheTtlPreference: null,
+ context1mPreference: null,
+ codexReasoningEffortPreference: null,
+ codexReasoningSummaryPreference: null,
+ codexTextVerbosityPreference: null,
+ codexParallelToolCallsPreference: null,
+ anthropicMaxTokensPreference: null,
+ anthropicThinkingBudgetPreference: null,
+ anthropicAdaptiveThinking: null,
+ geminiGoogleSearchPreference: null,
+ tpm: 0,
+ rpm: 0,
+ rpd: 0,
+ cc: 0,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ deletedAt: null,
+ ...overrides,
+ };
+}
+
+function createSession(): ProxySession {
+ const headers = new Headers();
+ const session = Object.create(ProxySession.prototype);
+
+ Object.assign(session, {
+ startTime: Date.now(),
+ method: "POST",
+ requestUrl: new URL("https://example.com/v1/messages"),
+ headers,
+ originalHeaders: new Headers(headers),
+ headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
+ request: {
+ model: "claude-test",
+ log: "(test)",
+ message: {
+ model: "claude-test",
+ messages: [{ role: "user", content: "hi" }],
+ },
+ },
+ userAgent: null,
+ context: null,
+ clientAbortSignal: null,
+ userName: "test-user",
+ authState: { success: true, user: null, key: null, apiKey: null },
+ provider: null,
+ messageContext: null,
+ sessionId: null,
+ requestSequence: 1,
+ originalFormat: "claude",
+ providerType: null,
+ originalModelName: null,
+ originalUrlPathname: null,
+ providerChain: [],
+ cacheTtlResolved: null,
+ context1mApplied: false,
+ specialSettings: [],
+ cachedPriceData: undefined,
+ cachedBillingModelSource: undefined,
+ isHeaderModified: () => false,
+ });
+
+ return session as ProxySession;
+}
+
+describe("ProxyForwarder - fake 200 HTML body", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("200 + text/html 的 HTML 页面应视为失败并切换供应商", async () => {
+ const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
+ const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
+
+ const session = createSession();
+ session.setProvider(provider1);
+
+ mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ const htmlBody = [
+ "",
+ "New API",
+ "blocked",
+ ].join("\n");
+ const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
+
+ doForward.mockResolvedValueOnce(
+ new Response(htmlBody, {
+ status: 200,
+ headers: {
+ "content-type": "text/html; charset=utf-8",
+ "content-length": String(htmlBody.length),
+ },
+ })
+ );
+
+ doForward.mockResolvedValueOnce(
+ new Response(okJson, {
+ status: 200,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": String(okJson.length),
+ },
+ })
+ );
+
+ const response = await ProxyForwarder.send(session);
+ expect(await response.text()).toContain("ok");
+
+ expect(doForward).toHaveBeenCalledTimes(2);
+ expect(doForward.mock.calls[0][1].id).toBe(1);
+ expect(doForward.mock.calls[1][1].id).toBe(2);
+
+ expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
+ expect(mocks.recordFailure).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ message: "FAKE_200_HTML_BODY" })
+ );
+ const failure1 = mocks.recordFailure.mock.calls[0]?.[1];
+ expect(failure1).toBeInstanceOf(ProxyError);
+ expect((failure1 as ProxyError).getClientSafeMessage()).toContain("HTML document");
+ expect((failure1 as ProxyError).getClientSafeMessage()).toContain("Upstream detail:");
+ expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
+ expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
+ });
+
+ test("200 + text/html 但 body 是 JSON error 也应视为失败并切换供应商", async () => {
+ const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
+ const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
+
+ const session = createSession();
+ session.setProvider(provider1);
+
+ mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ const jsonErrorBody = JSON.stringify({ error: "upstream blocked" });
+ const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
+
+ doForward.mockResolvedValueOnce(
+ new Response(jsonErrorBody, {
+ status: 200,
+ headers: {
+ // 故意使用 text/html:模拟部分上游 Content-Type 错配但 body 仍为错误 JSON 的情况
+ "content-type": "text/html; charset=utf-8",
+ "content-length": String(jsonErrorBody.length),
+ },
+ })
+ );
+
+ doForward.mockResolvedValueOnce(
+ new Response(okJson, {
+ status: 200,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": String(okJson.length),
+ },
+ })
+ );
+
+ const response = await ProxyForwarder.send(session);
+ expect(await response.text()).toContain("ok");
+
+ expect(doForward).toHaveBeenCalledTimes(2);
+ expect(doForward.mock.calls[0][1].id).toBe(1);
+ expect(doForward.mock.calls[1][1].id).toBe(2);
+
+ expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
+ expect(mocks.recordFailure).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
+ );
+ const failure2 = mocks.recordFailure.mock.calls[0]?.[1];
+ expect(failure2).toBeInstanceOf(ProxyError);
+ expect((failure2 as ProxyError).getClientSafeMessage()).toContain("JSON body");
+ expect((failure2 as ProxyError).getClientSafeMessage()).toContain("`error`");
+ expect((failure2 as ProxyError).getClientSafeMessage()).toContain("upstream blocked");
+ expect((failure2 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody);
+ expect((failure2 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false);
+ expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
+ expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
+ });
+
+ test("200 + application/json 且有 Content-Length 的 JSON error 也应视为失败并切换供应商", async () => {
+ const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
+ const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
+
+ const session = createSession();
+ session.setProvider(provider1);
+
+ mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ const jsonErrorBody = JSON.stringify({ error: "upstream blocked" });
+ const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
+
+ doForward.mockResolvedValueOnce(
+ new Response(jsonErrorBody, {
+ status: 200,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": String(jsonErrorBody.length),
+ },
+ })
+ );
+
+ doForward.mockResolvedValueOnce(
+ new Response(okJson, {
+ status: 200,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": String(okJson.length),
+ },
+ })
+ );
+
+ const response = await ProxyForwarder.send(session);
+ expect(await response.text()).toContain("ok");
+
+ expect(doForward).toHaveBeenCalledTimes(2);
+ expect(doForward.mock.calls[0][1].id).toBe(1);
+ expect(doForward.mock.calls[1][1].id).toBe(2);
+
+ expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
+ expect(mocks.recordFailure).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
+ );
+ const failure3 = mocks.recordFailure.mock.calls[0]?.[1];
+ expect(failure3).toBeInstanceOf(ProxyError);
+ expect((failure3 as ProxyError).getClientSafeMessage()).toContain("JSON body");
+ expect((failure3 as ProxyError).getClientSafeMessage()).toContain("`error`");
+ expect((failure3 as ProxyError).getClientSafeMessage()).toContain("upstream blocked");
+ expect((failure3 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody);
+ expect((failure3 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false);
+ expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
+ expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
+ });
+
+ test("假200 JSON error 命中 rate limit 关键字时,应推断为 429 并在决策链中标记为推断", async () => {
+ const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
+ const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
+
+ const session = createSession();
+ session.setProvider(provider1);
+
+ mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ const jsonErrorBody = JSON.stringify({ error: "Rate limit exceeded" });
+ const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
+
+ doForward.mockResolvedValueOnce(
+ new Response(jsonErrorBody, {
+ status: 200,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": String(jsonErrorBody.length),
+ },
+ })
+ );
+
+ doForward.mockResolvedValueOnce(
+ new Response(okJson, {
+ status: 200,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": String(okJson.length),
+ },
+ })
+ );
+
+ const response = await ProxyForwarder.send(session);
+ expect(await response.text()).toContain("ok");
+
+ expect(mocks.recordFailure).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" })
+ );
+
+ const failure = mocks.recordFailure.mock.calls[0]?.[1];
+ expect(failure).toBeInstanceOf(ProxyError);
+ expect((failure as ProxyError).statusCode).toBe(429);
+ expect((failure as ProxyError).upstreamError?.statusCodeInferred).toBe(true);
+
+ const chain = session.getProviderChain();
+ expect(
+ chain.some(
+ (item) =>
+ item.id === 1 &&
+ item.reason === "retry_failed" &&
+ item.statusCode === 429 &&
+ item.statusCodeInferred === true
+ )
+ ).toBe(true);
+ });
+
+ test("200 + 非法 Content-Length 时应按缺失处理,避免漏检 HTML 假200", async () => {
+ const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
+ const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
+
+ const session = createSession();
+ session.setProvider(provider1);
+
+ mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ const htmlErrorBody = "blocked";
+ const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
+
+ doForward.mockResolvedValueOnce(
+ new Response(htmlErrorBody, {
+ status: 200,
+ headers: {
+ // 故意不提供 html/json 的 Content-Type,覆盖“仅靠 body 嗅探”的假200检测分支
+ "content-type": "text/plain; charset=utf-8",
+ // 非法 Content-Length:parseInt("12abc") 会返回 12;修复后应视为非法并进入 body 检查分支
+ "content-length": "12abc",
+ },
+ })
+ );
+
+ doForward.mockResolvedValueOnce(
+ new Response(okJson, {
+ status: 200,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": String(okJson.length),
+ },
+ })
+ );
+
+ const response = await ProxyForwarder.send(session);
+ expect(await response.text()).toContain("ok");
+
+ expect(doForward).toHaveBeenCalledTimes(2);
+ expect(doForward.mock.calls[0][1].id).toBe(1);
+ expect(doForward.mock.calls[1][1].id).toBe(2);
+
+ expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
+ expect(mocks.recordFailure).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ message: "FAKE_200_HTML_BODY" })
+ );
+
+ const failure = mocks.recordFailure.mock.calls[0]?.[1];
+ expect(failure).toBeInstanceOf(ProxyError);
+ expect((failure as ProxyError).upstreamError?.rawBody).toBe(htmlErrorBody);
+ expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
+ expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
+ });
+
+ test("缺少 content 字段(missing_content)不应被 JSON 解析 catch 吞掉,应触发切换供应商", async () => {
+ const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 });
+ const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 });
+
+ const session = createSession();
+ session.setProvider(provider1);
+
+ mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2);
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ const missingContentJson = JSON.stringify({ type: "message", content: [] });
+ const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] });
+
+ doForward.mockResolvedValueOnce(
+ new Response(missingContentJson, {
+ status: 200,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ // 故意不提供 content-length:覆盖 forwarder 的 clone + JSON 内容结构检查分支
+ },
+ })
+ );
+
+ doForward.mockResolvedValueOnce(
+ new Response(okJson, {
+ status: 200,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "content-length": String(okJson.length),
+ },
+ })
+ );
+
+ const response = await ProxyForwarder.send(session);
+ expect(await response.text()).toContain("ok");
+
+ expect(doForward).toHaveBeenCalledTimes(2);
+ expect(doForward.mock.calls[0][1].id).toBe(1);
+ expect(doForward.mock.calls[1][1].id).toBe(2);
+
+ expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]);
+ expect(mocks.recordFailure).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ reason: "missing_content" })
+ );
+ expect(mocks.recordSuccess).toHaveBeenCalledWith(2);
+ expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1);
+ });
+});
diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts
index 16c531d24..533f8247f 100644
--- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts
+++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts
@@ -183,7 +183,7 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession {
ttfbMs: null,
getRequestSequence: () => 1,
addProviderToChain: function (
- this: ProxySession & { providerChain: unknown[] },
+ this: ProxySession & { providerChain: Record[] },
prov: {
id: number;
name: string;
@@ -193,7 +193,8 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession {
costMultiplier: number;
groupTag: string;
providerVendorId?: string;
- }
+ },
+ metadata?: Record
) {
this.providerChain.push({
id: prov.id,
@@ -204,7 +205,11 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession {
weight: prov.weight,
costMultiplier: prov.costMultiplier,
groupTag: prov.groupTag,
- timestamp: Date.now(),
+ timestamp:
+ typeof metadata?.timestamp === "number" && Number.isFinite(metadata.timestamp)
+ ? metadata.timestamp
+ : Date.now(),
+ ...(metadata ?? {}),
});
},
});
@@ -249,8 +254,8 @@ function setDeferredMeta(session: ProxySession, endpointId: number | null = 42)
}
/** Create an SSE stream that emits a fake-200 error body (valid HTTP 200 but error in content). */
-function createFake200StreamResponse(): Response {
- const body = `data: ${JSON.stringify({ error: { message: "invalid api key" } })}\n\n`;
+function createFake200StreamResponse(errorMessage: string = "invalid api key"): Response {
+ const body = `data: ${JSON.stringify({ error: { message: errorMessage } })}\n\n`;
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
@@ -353,6 +358,40 @@ describe("Endpoint circuit breaker isolation", () => {
expect.objectContaining({ message: expect.stringContaining("FAKE_200") })
);
expect(mockRecordEndpointFailure).not.toHaveBeenCalled();
+
+ const chain = session.getProviderChain();
+ expect(
+ chain.some(
+ (item) =>
+ item.id === 1 &&
+ item.reason === "retry_failed" &&
+ item.statusCode === 401 &&
+ item.statusCodeInferred === true
+ )
+ ).toBe(true);
+ });
+
+ it("fake-200 inferred 404 should NOT call recordFailure and should be marked as resource_not_found", async () => {
+ const session = createSession();
+ setDeferredMeta(session, 42);
+
+ const response = createFake200StreamResponse("model not found");
+ await ProxyResponseHandler.dispatch(session, response);
+ await drainAsyncTasks();
+
+ expect(mockRecordFailure).not.toHaveBeenCalled();
+ expect(mockRecordEndpointFailure).not.toHaveBeenCalled();
+
+ const chain = session.getProviderChain();
+ expect(
+ chain.some(
+ (item) =>
+ item.id === 1 &&
+ item.reason === "resource_not_found" &&
+ item.statusCode === 404 &&
+ item.statusCodeInferred === true
+ )
+ ).toBe(true);
});
it("non-200 HTTP status should call recordFailure but NOT recordEndpointFailure", async () => {