Просмотр исходного кода

feat(S-cfc-141): langgraph.intent.errorPropagate - AiGateway 上游 429/401 必须传播,不得静默吞 null

能力键:langgraph.intent.errorPropagate(红契约测试 LangGraphErrorPropagateContractTest,S-cfc-136 登记)

实现使绿:
- AiGateway.chat()/chatForAnalysis():RestClientException 且为 429/401(HttpClientErrorException)时
  recordFailure() 后向上抛出(传播外部服务错误,供编排降级/告警),其他失败(熔断/超时/连接失败)
  仍按原语义返回 null 兜底
- 新增私有辅助 isExternalServiceError(RestClientException) 识别 429/401
- AiChatController.sendNutritionMessage:resp 为 null 时不再 NPE(chat 现在可能对 429/401 抛出,
  但 429/401 场景直接由 AiGateway 抛出;null 仍是「非外部服务错误」兜底,判空更稳妥)
- AIService.sendMessage:Javadoc 补充 429/401 传播契约说明(行为不变,429/401 仍直接抛出,
  由 Controller 层全局异常处理器处理;非外部服务错误仍走 emptyMap 兜底)

测试结果:LangGraphErrorPropagateContractTest 3失败→5/5通过;AiGatewayTimeoutTest 1/1 通过(零回归)
契约测试类:cfc-backend/src/test/java/com/etotem/cfc/unit/LangGraphErrorPropagateContractTest.java
Sisyphus Agent 1 день назад
Родитель
Сommit
a8cda0169e

+ 3 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -479,9 +479,11 @@ public class AIChatController {
         }
 
         // 4. 调用营养助手(走 LangGraph)
+        // 契约 langgraph.intent.errorPropagate:上游 429/401 由 AiGateway.chat 直接抛出,
+        // 不在此处吞 null 伪装成「普通失败」;编排层(Controller)负责统一降级
         Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
 
-        String answer = (String) resp.getOrDefault("answer", "");
+        String answer = resp != null ? (String) resp.getOrDefault("answer", "") : "";
 
         // 4. 解析 [RECOMMEND] 标记
         List<RecommendationResult> recommendations = null;

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -107,6 +107,9 @@ public class AIService {
 
     /**
      * 发送聊天消息: 统一走 Python LangGraph
+     * <p>429/401 类外部服务错误由 {@code AiGateway.chat} 向上抛出(契约
+     * langgraph.intent.errorPropagate,不得吞 null 伪装成普通失败);其余失败
+     * (熔断/超时/解析异常)仍走 emptyMap 兜底。
      */
     public Map<String, Object> sendMessage(String query, String userId,
                                            String conversationId,

+ 43 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -10,9 +10,11 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClientException;
 import org.springframework.web.client.RestTemplate;
 import java.util.*;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -119,6 +121,24 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 判断 {@link RestClientException} 是否为上游外部服务拒绝类错误(429/401)。
+     * 契约 {@code langgraph.intent.errorPropagate}:LangGraph 上游 429/401(配额/凭据类
+     * 外部服务错误)必须向调用方传播,不得在网关内被吞成 null 伪装成普通失败。
+     *
+     * @return true=上游返回 429/401 的 {@link org.springframework.web.client.HttpClientErrorException},
+     *         false=其他错误(超时、连接失败、其他 4xx/5xx 等,仍走 null 兜底)
+     */
+    private boolean isExternalServiceError(RestClientException e) {
+        if (!(e instanceof org.springframework.web.client.HttpClientErrorException)) {
+            return false;
+        }
+        org.springframework.web.client.HttpClientErrorException httpErr =
+                (org.springframework.web.client.HttpClientErrorException) e;
+        return httpErr.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS
+                || httpErr.getStatusCode() == HttpStatus.UNAUTHORIZED;
+    }
+
     /**
      * 调用 Python 推荐服务, 失败时返回 null (由调用方决定 fallback)
      */
@@ -173,7 +193,11 @@ public class AiGateway {
     }
 
     /**
-     * 调用 Python ChatAgent, 失败时返回 null
+     * 调用 Python ChatAgent。
+     * <p>LangGraph 上游 429/401(配额/凭据类外部服务错误)必须向外传播,不得静默吞成
+     * null——null 在调用方被当作「普通失败/可兜底」,会让配额错误伪装成普通失败。
+     * 契约见 {@code LangGraphErrorPropagateContractTest}(能力键 langgraph.intent.errorPropagate)。
+     * 其余异常(网络挂起/熔断/解析失败)仍返回 null,由调用方走 mock 兜底。
      */
     public Map<String, Object> chat(String query, Long userId, String conversationId,
                                       Map<String, Object> inputs) {
@@ -223,6 +247,15 @@ public class AiGateway {
                 return result;
             }
             return null;
+        } catch (RestClientException e) {
+            if (isExternalServiceError(e)) {
+                // 429/401 类上游拒绝必须传播,不得静默吞 null
+                recordFailure();
+                throw e;
+            }
+            log.warn("AiGateway chat 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
         } catch (Exception e) {
             log.warn("AiGateway chat 调用失败: {}", e.getMessage());
             recordFailure();
@@ -281,6 +314,15 @@ public class AiGateway {
                 return result;
             }
             return null;
+        } catch (RestClientException e) {
+            if (isExternalServiceError(e)) {
+                // 429/401 类上游拒绝必须传播,与 chat() 传播语义一致
+                recordFailure();
+                throw e;
+            }
+            log.warn("AiGateway chatForAnalysis 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
         } catch (Exception e) {
             log.warn("AiGateway chatForAnalysis 调用失败: {}", e.getMessage());
             recordFailure();