From c1b878b7e13ab9a9b133506dd6fa843f475017de Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 19 May 2026 20:06:21 +0800 Subject: [PATCH] fix(agent): fail over to backup providers when the primary is rate-limited --- .../agent/graph/NodeStreamingChatHelper.java | 20 ++++++++++--- .../NodeStreamingChatHelperFailoverTest.java | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) 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 83840765..0a51d30e 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 @@ -588,10 +588,22 @@ public class NodeStreamingChatHelper { logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount); return lastResult; } - // Any other non-null errored result with a classified type that doStreamCall - // chose NOT to retry (i.e. UNKNOWN, or RATE_LIMIT/SERVER_ERROR past MAX_RETRIES) - // must exit — otherwise we silently spin through attempts and waste seconds - // per turn on unrecoverable errors like DashScope's "url error" / unknown model. + // RATE_LIMIT / SERVER_ERROR past their retry budget are provider-level + // failures: the same model will not recover within this turn, but a + // different provider can. Break to the fallback chain instead of + // returning — recordPrimary(false) runs once at the post-loop provider + // health check below, and if every fallback also fails the chain + // walker re-surfaces this same error to the caller. + if (lastResult.errorType() == ErrorType.RATE_LIMIT + || lastResult.errorType() == ErrorType.SERVER_ERROR) { + log.warn("[{}] Primary exhausted retries (type={}) — handing off to fallback chain", + phase, lastResult.errorType()); + break; + } + // Any other non-null errored result (e.g. UNKNOWN) that doStreamCall + // chose NOT to retry must exit — otherwise we silently spin through + // attempts and waste seconds per turn on unrecoverable errors like + // DashScope's "url error" / unknown model. recordPrimary(false); logPerfSummary(phase, conversationId, callStartMs, llmCallCount, retryCount, failoverCount); return lastResult; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java index 226fa2c6..71723b93 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java @@ -166,6 +166,36 @@ class NodeStreamingChatHelperFailoverTest { verify(fallback, times(1)).stream(any(Prompt.class)); } + // ============================================================ + // C5 regression: RATE_LIMIT (429) must fall back, not surface + // ============================================================ + + /** + * Prior to this fix a rate-limited primary exhausted its 2 same-model + * retries and then {@code return}ed the 429 error result directly, + * skipping the fallback chain entirely — the 429 surfaced as the + * conversation's answer even though other providers were healthy. + * After the fix RATE_LIMIT breaks out to the chain walker, mirroring + * AUTH_ERROR / BILLING. + * + *

Note: this test waits out two real retry backoffs (~3s + ~6s) on + * the primary before the hand-off, so it runs for ~10s by design.

+ */ + @Test + @DisplayName("C5 (regression): primary RATE_LIMIT (429) hands off to the fallback chain") + void rateLimitFallsBack() { + ChatModel primary = errorModel(new RuntimeException("429 Too Many Requests")); + ChatModel fallback = successModel("recovered after rate limit"); + var helper = helper(primary, List.of(new FallbackEntry("dashscope", fallback)), "zhipu-cn"); + + var result = helper.streamCall(primary, smallPrompt(), "conv-c5", "reasoning"); + + assertEquals("recovered after rate limit", result.text(), + "a rate-limited primary must fail over instead of surfacing the 429"); + verify(primary, atLeast(2)).stream(any(Prompt.class)); + verify(fallback, times(1)).stream(any(Prompt.class)); + } + // ============================================================ // Bonus: confirm no infinite loop / regression on success path // ============================================================