From bdf79857a8b4d94ca0ab3250af17c0345d639ffc Mon Sep 17 00:00:00 2001 From: qbit-mirror-bot Date: Wed, 1 Jul 2026 07:29:01 +0000 Subject: [PATCH] feat(agent): make overload (503/529) backoff configurable --- agent/agent_init.py | 22 +++ agent/conversation_loop.py | 16 +- cli-config.yaml.example | 13 ++ tests/agent/test_overload_backoff_config.py | 159 ++++++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 tests/agent/test_overload_backoff_config.py diff --git a/agent/agent_init.py b/agent/agent_init.py index dcfb1082d4c5..bd7b9def0992 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1358,6 +1358,28 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: _api_retries = 3 agent._api_max_retries = _api_retries + # Overload (503/529) retry policy — configurable per #55540. + try: + _overload_retries = int(_agent_section.get("overload_max_retries", 2)) + _overload_retries = max(_overload_retries, 0) # 0 = immediate fallback + except (TypeError, ValueError): + _overload_retries = 2 + agent._overload_max_retries = _overload_retries + + try: + _overload_base = float(_agent_section.get("overload_base_delay", 2.0)) + _overload_base = max(_overload_base, 0.1) + except (TypeError, ValueError): + _overload_base = 2.0 + agent._overload_base_delay = _overload_base + + try: + _overload_max = float(_agent_section.get("overload_max_delay", 60.0)) + _overload_max = max(_overload_max, 1.0) + except (TypeError, ValueError): + _overload_max = 60.0 + agent._overload_max_delay = _overload_max + # Initialize context compressor for automatic context management # Compresses conversation when approaching model's context limit # Configuration via config.yaml (compression section) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 7a5919807af1..c8b482a73aaa 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -2939,7 +2939,10 @@ def _perform_api_call(next_api_kwargs): } _should_fallback = ( is_rate_limited - or (_is_transport_failure and retry_count >= 2) + or (classified.reason == FailoverReason.overloaded + and retry_count >= agent._overload_max_retries) + or (classified.reason == FailoverReason.timeout + and retry_count >= 2) ) if _should_fallback and agent._fallback_index < len(agent._fallback_chain): # Don't eagerly fallback if credential pool rotation may @@ -3822,7 +3825,16 @@ def _perform_api_call(next_api_kwargs): _retry_after = min(float(_ra_raw), 600) except (TypeError, ValueError): pass - wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0) + if _retry_after: + wait_time = _retry_after + elif classified.reason == FailoverReason.overloaded: + wait_time = jittered_backoff( + retry_count, + base_delay=agent._overload_base_delay, + max_delay=agent._overload_max_delay, + ) + else: + wait_time = jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0) _backoff_policy = None if is_rate_limited and not _retry_after: wait_time, _backoff_policy = adaptive_rate_limit_backoff( diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 504d1a08fe08..d28c210c9073 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -638,6 +638,19 @@ agent: # underneath this wrapper — this is the Hermes-level loop. # api_max_retries: 3 + # Overload (503/529) retry policy. Controls how many retries to attempt + # before falling back (if fallback configured) or surfacing the error. + # Set to 0 for immediate fallback on first overload signal. Must be <= + # api_max_retries to take effect (the outer loop is the hard ceiling). + # overload_max_retries: 2 + + # Base delay (seconds) for the first overload retry. Subsequent retries + # use jittered exponential backoff: ~base * 2^(attempt-1), capped at max. + # overload_base_delay: 2.0 + + # Maximum delay cap (seconds) for overload retry backoff. + # overload_max_delay: 60.0 + # After the agent edits code without fresh passing verification, nudge it to # verify before finishing. The default "auto" enables it on interactive # coding surfaces (CLI, TUI, desktop) and programmatic callers, and disables diff --git a/tests/agent/test_overload_backoff_config.py b/tests/agent/test_overload_backoff_config.py new file mode 100644 index 000000000000..80d9d959ba7c --- /dev/null +++ b/tests/agent/test_overload_backoff_config.py @@ -0,0 +1,159 @@ +"""Tests for configurable overload (503/529) backoff — #55540.""" + +import pytest +from unittest.mock import MagicMock, patch + + +# ── Test: config parsing in agent_init ──────────────────────────────── + + +def _make_agent_section(**overrides): + """Build a minimal _agent_section dict for the overload config block.""" + return overrides + + +def _apply_overload_config(agent, agent_section): + """Replicate the config-reading logic from agent_init.py.""" + try: + _overload_retries = int(agent_section.get("overload_max_retries", 2)) + _overload_retries = max(_overload_retries, 0) + except (TypeError, ValueError): + _overload_retries = 2 + agent._overload_max_retries = _overload_retries + + try: + _overload_base = float(agent_section.get("overload_base_delay", 2.0)) + _overload_base = max(_overload_base, 0.1) + except (TypeError, ValueError): + _overload_base = 2.0 + agent._overload_base_delay = _overload_base + + try: + _overload_max = float(agent_section.get("overload_max_delay", 60.0)) + _overload_max = max(_overload_max, 1.0) + except (TypeError, ValueError): + _overload_max = 60.0 + agent._overload_max_delay = _overload_max + + +class TestOverloadConfigDefaults: + """When no overload keys are present, defaults match prior hardcoded values.""" + + def test_defaults_when_absent(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section()) + assert agent._overload_max_retries == 2 + assert agent._overload_base_delay == 2.0 + assert agent._overload_max_delay == 60.0 + + +class TestOverloadConfigCustom: + """User-supplied values are respected.""" + + def test_custom_values(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section( + overload_max_retries=5, + overload_base_delay=10.0, + overload_max_delay=120.0, + )) + assert agent._overload_max_retries == 5 + assert agent._overload_base_delay == 10.0 + assert agent._overload_max_delay == 120.0 + + def test_zero_retries_means_immediate_fallback(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section(overload_max_retries=0)) + assert agent._overload_max_retries == 0 + + def test_string_values_are_cast(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section( + overload_max_retries="4", + overload_base_delay="5.5", + overload_max_delay="90", + )) + assert agent._overload_max_retries == 4 + assert agent._overload_base_delay == 5.5 + assert agent._overload_max_delay == 90.0 + + +class TestOverloadConfigClamping: + """Floor clamping prevents nonsensical values.""" + + def test_negative_retries_clamped_to_zero(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section(overload_max_retries=-1)) + assert agent._overload_max_retries == 0 + + def test_base_delay_floor(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section(overload_base_delay=0.01)) + assert agent._overload_base_delay == 0.1 + + def test_max_delay_floor(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section(overload_max_delay=0.5)) + assert agent._overload_max_delay == 1.0 + + +class TestOverloadConfigInvalid: + """Garbage input falls back to defaults.""" + + def test_invalid_retries(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section(overload_max_retries="abc")) + assert agent._overload_max_retries == 2 + + def test_invalid_base_delay(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section(overload_base_delay="not_a_number")) + assert agent._overload_base_delay == 2.0 + + def test_invalid_max_delay(self): + agent = MagicMock() + _apply_overload_config(agent, _make_agent_section(overload_max_delay=None)) + assert agent._overload_max_delay == 60.0 + + +# ── Test: fallback threshold logic ──────────────────────────────────── + + +class TestOverloadFallbackThreshold: + """The overload retry threshold uses the configured value.""" + + def _should_fallback_overloaded(self, retry_count, overload_max_retries): + """Replicate the _should_fallback condition for overloaded.""" + return retry_count >= overload_max_retries + + def test_default_threshold_triggers_at_2(self): + assert not self._should_fallback_overloaded(0, 2) + assert not self._should_fallback_overloaded(1, 2) + assert self._should_fallback_overloaded(2, 2) + assert self._should_fallback_overloaded(3, 2) + + def test_zero_threshold_triggers_immediately(self): + assert self._should_fallback_overloaded(0, 0) + + def test_high_threshold_delays_fallback(self): + assert not self._should_fallback_overloaded(4, 5) + assert self._should_fallback_overloaded(5, 5) + + +# ── Test: backoff delay uses configured values ──────────────────────── + + +class TestOverloadBackoffDelay: + """jittered_backoff is called with configured base/max for overloaded errors.""" + + def test_custom_delays_passed_to_jittered_backoff(self): + from agent.retry_utils import jittered_backoff + + delay = jittered_backoff(1, base_delay=10.0, max_delay=30.0) + assert 10.0 <= delay <= 10.0 * 1.5 # base + up to jitter_ratio * base + + def test_max_delay_caps_high_attempts(self): + from agent.retry_utils import jittered_backoff + + delay = jittered_backoff(10, base_delay=2.0, max_delay=30.0) + assert delay <= 30.0 * 1.5 # max + jitter