From 58033baba282ef133b219731eb9d0dd01f0558fe Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:04:58 +0530 Subject: [PATCH] fix(plugins): classify stale-call circuit breaker as failover, not retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-turn stale-call circuit breaker (_check_stale_giveup in agent/chat_completion_helpers.py) raises a RuntimeError when the provider has been unresponsive for N consecutive stale attempts. This error was classified as FailoverReason.unknown (retryable=True, should_fallback=False), causing the retry loop to burn all max_retries against the same dead provider — each retry hitting the circuit breaker instantly with zero network overhead — before fallback was attempted. Add a classification rule (section 7b in classify_api_error) that recognizes the stale-breaker RuntimeError by its signature phrases and classifies it as FailoverReason.timeout with retryable=False, should_fallback=True. This makes the retry loop skip retries and go straight to fallback provider activation on the first hit. Test: test_stale_breaker_runtime_error_triggers_fallback_not_retry --- agent/error_classifier.py | 26 ++++++++++++++++++++++++-- tests/agent/test_error_classifier.py | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 5eb4c09e0f10..6afdf4e076e0 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -840,12 +840,34 @@ def classify_api_error( ) return _result(FailoverReason.timeout, retryable=True) - # ── 7. Transport / timeout heuristics ─────────────────────────── + # ── 7b. Stale-call circuit breaker → failover immediately ────── + # _check_stale_giveup() in agent/chat_completion_helpers.py raises a + # RuntimeError when the provider has been unresponsive for N + # consecutive stale attempts (default 5). The error is NOT a transport + # timeout — the circuit breaker fires *before* any network call to avoid + # an indefinite stall. Without this classification the RuntimeError + # falls through to FailoverReason.unknown (retryable=True), which burns + # all max_retries against the same dead provider (each retry hitting the + # circuit breaker instantly with zero network overhead) before fallback + # is attempted. Classify as non-retryable + should_fallback so the + # retry loop activates the next fallback provider on the first hit. + if ( + error_type == "RuntimeError" + and "consecutive stale attempts" in error_msg + and "aborting this call" in error_msg + ): + return _result( + FailoverReason.timeout, + retryable=False, + should_fallback=True, + ) + + # ── 8. Transport / timeout heuristics ─────────────────────────── if error_type in _TRANSPORT_ERROR_TYPES or isinstance(error, (TimeoutError, ConnectionError, OSError)): return _result(FailoverReason.timeout, retryable=True) - # ── 8. Fallback: unknown ──────────────────────────────────────── + # ── 9. Fallback: unknown ──────────────────────────────────────── return _result(FailoverReason.unknown, retryable=True) diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 7623ac24bf7b..616628967ecb 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -2067,3 +2067,26 @@ class Test408RequestTimeout: assert result.retryable is True assert result.should_compress is False + def test_stale_breaker_runtime_error_triggers_fallback_not_retry(self): + # The cross-turn stale-call circuit breaker (_check_stale_giveup in + # chat_completion_helpers.py) raises a RuntimeError when the provider + # has been unresponsive for N consecutive stale attempts. This must + # be classified as non-retryable + should_fallback so the retry loop + # activates the fallback provider immediately instead of burning all + # max_retries against the same dead provider (each retry hitting the + # circuit breaker instantly with zero network overhead). + e = RuntimeError( + "Provider has been unresponsive (no response received) for " + "6 consecutive stale attempts — aborting this call to " + "avoid an indefinite stall. Switch models or start a new " + "session, then retry." + ) + result = classify_api_error( + e, provider="openrouter", model="anthropic/claude-fable-5", + approx_tokens=126327, context_length=200000, num_messages=274, + ) + assert result.reason == FailoverReason.timeout + assert result.retryable is False + assert result.should_fallback is True + assert result.should_compress is False +