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 +