fix(agent): fail over to backup providers when the primary is rate-limited

This commit is contained in:
matevip 2026-05-19 20:06:21 +08:00
parent 95ce1bae24
commit c1b878b7e1
2 changed files with 46 additions and 4 deletions

View File

@ -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;

View File

@ -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.
*
* <p>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.</p>
*/
@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
// ============================================================