소스 검색

Merge pull request #418 from ding113/fix/issue-416-error-rule-log

feat(error-rules): record matched rule details in decision chain (#416)
Ding 1 개월 전
부모
커밋
417c0bc3cf

+ 12 - 2
messages/en/provider-chain.json

@@ -36,7 +36,8 @@
     "requestChain": "Request Chain:",
     "systemError": "System Error",
     "concurrentLimit": "Concurrent Limit",
-    "http2Fallback": "HTTP/2 Fallback"
+    "http2Fallback": "HTTP/2 Fallback",
+    "clientError": "Client Error"
   },
   "timeline": {
     "sessionReuse": "Session Reuse",
@@ -127,6 +128,15 @@
     "requestUrl": "URL",
     "requestHeaders": "Headers",
     "requestBody": "Body",
-    "requestBodyTruncated": "(truncated)"
+    "requestBodyTruncated": "(truncated)",
+    "clientErrorNonRetryable": "Client Error (Attempt {attempt}, non-retryable)",
+    "matchedRule": "Matched Error Rule",
+    "ruleId": "Rule ID: {id}",
+    "ruleCategory": "Rule Category: {category}",
+    "rulePattern": "Rule Pattern: {pattern}",
+    "ruleMatchType": "Match Type: {matchType}",
+    "ruleDescription": "Description: {description}",
+    "ruleHasOverride": "Overrides: response={response}, statusCode={statusCode}",
+    "clientErrorNote": "This error is caused by client input and is not retried or counted in the circuit breaker."
   }
 }

+ 12 - 2
messages/ja/provider-chain.json

@@ -36,7 +36,8 @@
     "requestChain": "リクエストチェーン:",
     "systemError": "システムエラー",
     "concurrentLimit": "同時実行制限",
-    "http2Fallback": "HTTP/2 フォールバック"
+    "http2Fallback": "HTTP/2 フォールバック",
+    "clientError": "クライアントエラー"
   },
   "timeline": {
     "sessionReuse": "セッション再利用",
@@ -127,6 +128,15 @@
     "requestUrl": "URL",
     "requestHeaders": "ヘッダー",
     "requestBody": "ボディ",
-    "requestBodyTruncated": "(切り捨て)"
+    "requestBodyTruncated": "(切り捨て)",
+    "clientErrorNonRetryable": "クライアントエラー(試行{attempt}、再試行不可)",
+    "matchedRule": "一致したエラールール",
+    "ruleId": "ルールID: {id}",
+    "ruleCategory": "カテゴリ: {category}",
+    "rulePattern": "パターン: {pattern}",
+    "ruleMatchType": "一致タイプ: {matchType}",
+    "ruleDescription": "説明: {description}",
+    "ruleHasOverride": "上書き: 応答={response} ステータスコード={statusCode}",
+    "clientErrorNote": "このエラーはクライアント入力が原因のため再試行せず、サーキットブレーカーにもカウントされません。"
   }
 }

+ 12 - 2
messages/ru/provider-chain.json

@@ -36,7 +36,8 @@
     "requestChain": "Цепочка запросов:",
     "systemError": "Системная ошибка",
     "concurrentLimit": "Лимит параллельных запросов",
-    "http2Fallback": "Откат HTTP/2"
+    "http2Fallback": "Откат HTTP/2",
+    "clientError": "Ошибка клиента"
   },
   "timeline": {
     "sessionReuse": "Повторное использование сессии",
@@ -127,6 +128,15 @@
     "requestUrl": "URL",
     "requestHeaders": "Заголовки",
     "requestBody": "Тело",
-    "requestBodyTruncated": "(обрезано)"
+    "requestBodyTruncated": "(обрезано)",
+    "clientErrorNonRetryable": "Ошибка клиента (попытка {attempt}, без повторов)",
+    "matchedRule": "Совпавшее правило ошибки",
+    "ruleId": "ID правила: {id}",
+    "ruleCategory": "Категория: {category}",
+    "rulePattern": "Шаблон: {pattern}",
+    "ruleMatchType": "Тип совпадения: {matchType}",
+    "ruleDescription": "Описание: {description}",
+    "ruleHasOverride": "Переопределения: response={response}, statusCode={statusCode}",
+    "clientErrorNote": "Эта ошибка вызвана вводом клиента, не повторяется и не учитывается в автомате защиты."
   }
 }

+ 12 - 2
messages/zh-CN/provider-chain.json

@@ -36,7 +36,8 @@
     "requestChain": "请求链路:",
     "systemError": "系统错误",
     "concurrentLimit": "并发限制",
-    "http2Fallback": "HTTP/2 回退"
+    "http2Fallback": "HTTP/2 回退",
+    "clientError": "客户端错误"
   },
   "timeline": {
     "sessionReuse": "会话复用",
@@ -127,6 +128,15 @@
     "requestUrl": "请求 URL",
     "requestHeaders": "请求头",
     "requestBody": "请求体",
-    "requestBodyTruncated": "(已截断)"
+    "requestBodyTruncated": "(已截断)",
+    "clientErrorNonRetryable": "客户端错误(第 {attempt} 次尝试,不可重试)",
+    "matchedRule": "匹配的错误规则",
+    "ruleId": "规则 ID: {id}",
+    "ruleCategory": "规则类别: {category}",
+    "rulePattern": "匹配模式: {pattern}",
+    "ruleMatchType": "匹配类型: {matchType}",
+    "ruleDescription": "规则描述: {description}",
+    "ruleHasOverride": "覆写配置: 响应体={response}, 状态码={statusCode}",
+    "clientErrorNote": "此错误由用户输入导致,不会重试,不计入熔断器。"
   }
 }

+ 12 - 2
messages/zh-TW/provider-chain.json

@@ -36,7 +36,8 @@
     "requestChain": "請求鏈路:",
     "systemError": "系統錯誤",
     "concurrentLimit": "並發限制",
-    "http2Fallback": "HTTP/2 回退"
+    "http2Fallback": "HTTP/2 回退",
+    "clientError": "客戶端錯誤"
   },
   "timeline": {
     "sessionReuse": "會話複用",
@@ -127,6 +128,15 @@
     "requestUrl": "請求 URL",
     "requestHeaders": "請求頭",
     "requestBody": "請求體",
-    "requestBodyTruncated": "(已截斷)"
+    "requestBodyTruncated": "(已截斷)",
+    "clientErrorNonRetryable": "客戶端錯誤(第 {attempt} 次嘗試,不可重試)",
+    "matchedRule": "匹配的錯誤規則",
+    "ruleId": "規則 ID: {id}",
+    "ruleCategory": "規則類別: {category}",
+    "rulePattern": "匹配模式: {pattern}",
+    "ruleMatchType": "匹配類型: {matchType}",
+    "ruleDescription": "規則描述: {description}",
+    "ruleHasOverride": "覆寫設定: 回應體={response}, 狀態碼={statusCode}",
+    "clientErrorNote": "此錯誤由使用者輸入導致,不會重試,不計入熔斷器。"
   }
 }

+ 10 - 0
src/app/v1/_lib/proxy/errors.ts

@@ -504,6 +504,16 @@ async function detectErrorRuleOnceAsync(error: Error): Promise<ErrorDetectionRes
   return result;
 }
 
+/**
+ * 获取错误规则检测结果(异步版本,带缓存)
+ *
+ * 用于在 forwarder/handler 中复用 detectErrorRuleOnceAsync 的 WeakMap 缓存,
+ * 避免对同一个 Error 对象重复执行规则匹配。
+ */
+export async function getErrorDetectionResultAsync(error: Error): Promise<ErrorDetectionResult> {
+  return detectErrorRuleOnceAsync(error);
+}
+
 /**
  * 向后兼容的同步检测入口,供尚未迁移的调用方/测试使用
  *

+ 19 - 0
src/app/v1/_lib/proxy/forwarder.ts

@@ -30,6 +30,7 @@ import {
   categorizeErrorAsync,
   EmptyResponseError,
   ErrorCategory,
+  getErrorDetectionResultAsync,
   isClientAbortError,
   isEmptyResponseError,
   isHttp2Error,
@@ -448,6 +449,23 @@ export class ProxyForwarder {
           if (errorCategory === ErrorCategory.NON_RETRYABLE_CLIENT_ERROR) {
             const proxyError = lastError as ProxyError;
             const statusCode = proxyError.statusCode;
+            const detectionResult = await getErrorDetectionResultAsync(lastError);
+            const matchedRule =
+              detectionResult.matched &&
+              detectionResult.ruleId !== undefined &&
+              detectionResult.pattern !== undefined &&
+              detectionResult.matchType !== undefined &&
+              detectionResult.category !== undefined
+                ? {
+                    ruleId: detectionResult.ruleId,
+                    pattern: detectionResult.pattern,
+                    matchType: detectionResult.matchType,
+                    category: detectionResult.category,
+                    description: detectionResult.description,
+                    hasOverrideResponse: detectionResult.overrideResponse !== undefined,
+                    hasOverrideStatusCode: detectionResult.overrideStatusCode !== undefined,
+                  }
+                : undefined;
 
             logger.warn("ProxyForwarder: Non-retryable client error, stopping immediately", {
               providerId: currentProvider.id,
@@ -478,6 +496,7 @@ export class ProxyForwarder {
                   upstreamParsed: proxyError.upstreamError?.parsed,
                 },
                 clientError: proxyError.getDetailedErrorMessage(),
+                matchedRule,
                 request: buildRequestDetails(session),
               },
             });

+ 32 - 4
src/lib/error-rule-detector.ts

@@ -21,9 +21,11 @@ import { type ErrorOverrideResponse, getActiveErrorRules } from "@/repository/er
  */
 export interface ErrorDetectionResult {
   matched: boolean;
+  ruleId?: number; // 规则 ID
   category?: string; // 触发的错误分类
   pattern?: string; // 匹配的规则模式
   matchType?: string; // 匹配类型(regex/contains/exact)
+  description?: string; // 规则描述
   /** 覆写响应体:如果配置了则用此响应替换原始错误响应 */
   overrideResponse?: ErrorOverrideResponse;
   /** 覆写状态码:如果配置了则用此状态码替换原始状态码 */
@@ -34,6 +36,8 @@ export interface ErrorDetectionResult {
  * 缓存的正则规则
  */
 interface RegexPattern {
+  ruleId: number;
+  rawPattern: string;
   pattern: RegExp;
   category: string;
   description?: string;
@@ -45,6 +49,8 @@ interface RegexPattern {
  * 缓存的包含规则
  */
 interface ContainsPattern {
+  ruleId: number;
+  pattern: string;
   text: string;
   category: string;
   description?: string;
@@ -56,6 +62,8 @@ interface ContainsPattern {
  * 缓存的精确规则
  */
 interface ExactPattern {
+  ruleId: number;
+  pattern: string;
   text: string;
   category: string;
   description?: string;
@@ -197,6 +205,8 @@ class ErrorRuleDetector {
           case "contains": {
             const lowerText = rule.pattern.toLowerCase();
             newContainsPatterns.push({
+              ruleId: rule.id,
+              pattern: rule.pattern,
               text: lowerText,
               category: rule.category,
               description: rule.description ?? undefined,
@@ -209,6 +219,8 @@ class ErrorRuleDetector {
           case "exact": {
             const lowerText = rule.pattern.toLowerCase();
             newExactPatterns.set(lowerText, {
+              ruleId: rule.id,
+              pattern: rule.pattern,
               text: lowerText,
               category: rule.category,
               description: rule.description ?? undefined,
@@ -231,6 +243,8 @@ class ErrorRuleDetector {
 
               const pattern = new RegExp(rule.pattern, "i");
               newRegexPatterns.push({
+                ruleId: rule.id,
+                rawPattern: rule.pattern,
                 pattern,
                 category: rule.category,
                 description: rule.description ?? undefined,
@@ -323,9 +337,11 @@ class ErrorRuleDetector {
       if (lowerMessage.includes(pattern.text)) {
         return {
           matched: true,
+          ruleId: pattern.ruleId,
           category: pattern.category,
-          pattern: pattern.text,
+          pattern: pattern.pattern,
           matchType: "contains",
+          description: pattern.description,
           overrideResponse: pattern.overrideResponse,
           overrideStatusCode: pattern.overrideStatusCode,
         };
@@ -337,22 +353,34 @@ class ErrorRuleDetector {
     if (exactMatch) {
       return {
         matched: true,
+        ruleId: exactMatch.ruleId,
         category: exactMatch.category,
-        pattern: exactMatch.text,
+        pattern: exactMatch.pattern,
         matchType: "exact",
+        description: exactMatch.description,
         overrideResponse: exactMatch.overrideResponse,
         overrideStatusCode: exactMatch.overrideStatusCode,
       };
     }
 
     // 3. 正则匹配(最慢,但最灵活)
-    for (const { pattern, category, overrideResponse, overrideStatusCode } of this.regexPatterns) {
+    for (const {
+      ruleId,
+      rawPattern,
+      pattern,
+      category,
+      description,
+      overrideResponse,
+      overrideStatusCode,
+    } of this.regexPatterns) {
       if (pattern.test(errorMessage)) {
         return {
           matched: true,
+          ruleId,
           category,
-          pattern: pattern.source,
+          pattern: rawPattern,
           matchType: "regex",
+          description,
           overrideResponse,
           overrideStatusCode,
         };

+ 56 - 2
src/lib/utils/provider-chain-formatter.ts

@@ -13,7 +13,11 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | "
     return "✓";
   }
   // 失败标记
-  if (item.reason === "retry_failed" || item.reason === "system_error") {
+  if (
+    item.reason === "retry_failed" ||
+    item.reason === "system_error" ||
+    item.reason === "client_error_non_retryable"
+  ) {
     return "✗";
   }
   // 并发限制失败
@@ -36,7 +40,13 @@ function isActualRequest(item: ProviderChainItem): boolean {
   if (item.reason === "concurrent_limit_failed") return true;
 
   // 失败记录
-  if (item.reason === "retry_failed" || item.reason === "system_error") return true;
+  if (
+    item.reason === "retry_failed" ||
+    item.reason === "system_error" ||
+    item.reason === "client_error_non_retryable"
+  ) {
+    return true;
+  }
 
   // HTTP/2 回退:算作一次中间事件(显示但不计入失败)
   if (item.reason === "http2_fallback") return true;
@@ -250,6 +260,8 @@ export function formatProviderDescription(
         desc += ` ${t("description.concurrentLimit")}`;
       } else if (item.reason === "http2_fallback") {
         desc += ` ${t("description.http2Fallback")}`;
+      } else if (item.reason === "client_error_non_retryable") {
+        desc += ` ${t("description.clientError")}`;
       }
 
       desc += "\n";
@@ -504,6 +516,48 @@ export function formatProviderTimeline(
       continue;
     }
 
+    // === 不可重试的客户端错误 ===
+    if (item.reason === "client_error_non_retryable") {
+      const attempt = item.attemptNumber ?? actualAttemptNumber ?? 0;
+      timeline += `${t("timeline.clientErrorNonRetryable", { attempt })}\n\n`;
+
+      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 += `${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 += `${t("timeline.error", { error: item.errorMessage || t("timeline.unknown") })}\n`;
+      }
+
+      if (item.errorDetails?.matchedRule) {
+        const rule = item.errorDetails.matchedRule;
+        timeline += `\n${t("timeline.matchedRule")}:\n`;
+        timeline += `${t("timeline.ruleId", { id: rule.ruleId })}\n`;
+        timeline += `${t("timeline.ruleCategory", { category: rule.category })}\n`;
+        timeline += `${t("timeline.rulePattern", { pattern: rule.pattern })}\n`;
+        timeline += `${t("timeline.ruleMatchType", { matchType: rule.matchType })}\n`;
+        if (rule.description) {
+          timeline += `${t("timeline.ruleDescription", { description: rule.description })}\n`;
+        }
+        timeline += `${t("timeline.ruleHasOverride", {
+          response: rule.hasOverrideResponse ? "true" : "false",
+          statusCode: rule.hasOverrideStatusCode ? "true" : "false",
+        })}\n`;
+      }
+
+      if (item.errorDetails?.request) {
+        timeline += formatRequestDetails(item.errorDetails.request, t);
+      }
+
+      timeline += `\n${t("timeline.clientErrorNote")}`;
+      continue;
+    }
+
     // === HTTP/2 协议回退 ===
     if (item.reason === "http2_fallback") {
       timeline += `${t("timeline.http2Fallback")}\n\n`;

+ 11 - 0
src/types/message.ts

@@ -89,6 +89,17 @@ export interface ProviderChainItem {
     // 客户端输入错误(不可重试)
     clientError?: string; // 详细的客户端错误消息(包含匹配的白名单模式)
 
+    // 匹配到的错误规则(用于排查不可重试的客户端错误)
+    matchedRule?: {
+      ruleId: number;
+      pattern: string;
+      matchType: "regex" | "contains" | "exact" | string;
+      category: string;
+      description?: string;
+      hasOverrideResponse: boolean;
+      hasOverrideStatusCode: boolean;
+    };
+
     // 新增:请求详情(用于问题排查)
     request?: {
       url: string; // 完整请求 URL(已脱敏查询参数中的 key)