From 710b756281af080d6753facf594fe7eadc4ae276 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 15 Jun 2026 08:09:44 +0800 Subject: [PATCH] test(chat): cover gateway-resilience error classification; fix PKIX casing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ErrorClassificationTest regression cases for the AI-gateway retry hardening: 5xx-before-4xx ordering (a proxy 502 whose body says "bad request" stays retryable), Chinese / numeric provider billing patterns, and DNS / TLS infrastructure-fatal detection. Fix the cert-trust pattern while adding its test: Java's ValidatorException emits "PKIX path building failed" with an uppercase PKIX and the error chain is not lower-cased, so the previous lowercase pattern never matched — an untrusted/expired cert chain fell through to the retryable SERVER_ERROR bucket and was retried in vain instead of failing over. --- .../agent/graph/NodeStreamingChatHelper.java | 6 +- .../agent/graph/ErrorClassificationTest.java | 96 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index 0aaa1418..d1d35757 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -395,7 +395,11 @@ public class NodeStreamingChatHelper { if (msg.contains("UnknownHostException") || msg.contains("CertificateException") || msg.contains("SSLPeerUnverifiedException") - || msg.contains("pkix path building failed") + // Java's ValidatorException emits "PKIX path building failed" with an + // uppercase PKIX, and the error chain is not lower-cased — the pattern + // must match the real casing, otherwise the fatal cert failure falls + // through to the retryable SERVER_ERROR bucket and is retried in vain. + || msg.contains("PKIX path building failed") || msg.contains("certificate verify failed") || msg.contains("certificate_unknown")) { return ErrorType.AUTH_ERROR; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java index 66c2e2f7..45c536c5 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java @@ -190,4 +190,100 @@ class ErrorClassificationTest { assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, classify(new RuntimeException("401 Unauthorized: Invalid API Key (WebClientResponseException)"))); } + + // ===== AI-gateway resilience: 5xx classified BEFORE 4xx ===== + // + // Reverse proxies / AI gateways often surface an upstream 5xx as an HTTP 400 + // whose body still describes the outage. SERVER_ERROR must be matched before + // CLIENT_ERROR so the transient root cause wins and the call is retried, + // instead of being terminated as a non-retryable client error. + + @Test + @DisplayName("502 whose chain also carries 'Bad Request' → SERVER_ERROR (5xx wins)") + void gateway502WithBadRequestWinsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new RuntimeException("502 Bad Gateway: Bad Request from upstream proxy"))); + } + + @Test + @DisplayName("503 mixed with 'invalid_request_error' → SERVER_ERROR") + void mixed503And400IsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new RuntimeException("503 Service Unavailable (invalid_request_error in body)"))); + } + + @Test + @DisplayName("Gateway-rewritten 400 'model is overloaded' → SERVER_ERROR") + void gatewayOverloadedIsServerError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, + classify(new RuntimeException("400 Bad Request: model is overloaded, please try again"))); + } + + // ===== Provider billing patterns (Chinese + numeric codes) → BILLING ===== + + @Test + @DisplayName("Chinese '余额不足 / 请充值' → BILLING") + void chineseInsufficientBalanceIsBilling() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.BILLING, + classify(new RuntimeException("调用失败:账户余额不足,请充值后重试"))); + } + + @Test + @DisplayName("Zhipu '\"code\":\"1113\"' → BILLING") + void zhipuCode1113IsBilling() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.BILLING, + classify(new RuntimeException("{\"error\":{\"code\":\"1113\",\"message\":\"insufficient balance\"}}"))); + } + + @Test + @DisplayName("'AccountBalanceNotEnough' → BILLING") + void accountBalanceNotEnoughIsBilling() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.BILLING, + classify(new RuntimeException("AccountBalanceNotEnough: balance not enough"))); + } + + // ===== Infrastructure-fatal errors → AUTH_ERROR (HARD, no same-model retry) ===== + // + // DNS / TLS-trust failures do not self-heal on retry. They are routed through + // AUTH_ERROR so the loop breaks straight to the fallback chain instead of + // burning the SERVER_ERROR retry budget on an unrecoverable condition. + + @Test + @DisplayName("UnknownHostException (DNS) → AUTH_ERROR") + void unknownHostIsAuthError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new java.net.UnknownHostException("api.example.com"))); + } + + @Test + @DisplayName("CertificateException → AUTH_ERROR") + void certificateExceptionIsAuthError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new java.security.cert.CertificateException("certificate expired"))); + } + + @Test + @DisplayName("SSLPeerUnverifiedException → AUTH_ERROR") + void sslPeerUnverifiedIsAuthError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new javax.net.ssl.SSLPeerUnverifiedException("peer not authenticated"))); + } + + @Test + @DisplayName("OpenSSL-style 'certificate verify failed' → AUTH_ERROR") + void certificateVerifyFailedIsAuthError() throws Exception { + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new RuntimeException("SSL error: certificate verify failed (self-signed certificate in chain)"))); + } + + @Test + @DisplayName("Java TLS 'PKIX path building failed' (uppercase PKIX) → AUTH_ERROR") + void pkixPathBuildingIsAuthError() throws Exception { + // The real Java message capitalizes PKIX. Since the error chain is not + // lower-cased, a lowercase pattern would never match and the fatal cert + // failure would be retried as SERVER_ERROR. Guard against that regression. + assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, + classify(new RuntimeException( + "PKIX path building failed: unable to find valid certification path to requested target"))); + } }