fix(auxiliary): retry transient transport error once before fallback (#16587)
A one-off transient transport failure (streaming-close / incomplete chunked read / 5xx / 408) on an auxiliary LLM call escalated straight to provider/model fallback (or, for context compression, dropped the summary and entered cooldown), even when an immediate retry on the same provider would have succeeded. Add a single same-target retry at the top of call_llm() and async_call_llm() — before the existing except-chain — gated on a new _is_transient_transport_error() that reuses the canonical _is_connection_error() detector plus a 5xx/408 status check. A second failure (or any non-transient error: auth, other 4xx, malformed payload) falls through to first_err and the existing fallback handling unchanged. This lives in call_llm so every auxiliary task (compression, memory flush, title generation, session search, vision) shares one transient-retry surface, rather than each caller re-implementing it. The context compressor needs no change — it calls call_llm and inherits the retry; its existing fallback-to-main path (#18458) now composes naturally (retry the aux model once, then fall back to main only if the retry also fails). Co-authored-by: ARegalado1 <alberto.regalado@ymail.com>
This commit is contained in:
committed by
Teknium
co-authored by
ARegalado1
parent
4107076128
commit
02a4d66951
@@ -1794,6 +1794,108 @@ def test_resolve_api_key_provider_skips_unconfigured_anthropic(monkeypatch):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransientTransportRetry:
|
||||
"""call_llm retries ONCE on the same provider for a transient transport
|
||||
blip before escalating to the fallback chain.
|
||||
|
||||
Salvaged from PR #16587 (@ARegalado1). The original fixed only the
|
||||
context-compression caller; this lives in call_llm so every auxiliary
|
||||
task (compression, memory flush, title-gen, session-search, vision)
|
||||
gets the same same-target retry, and the gate reuses the canonical
|
||||
_is_connection_error detector.
|
||||
"""
|
||||
|
||||
def _patches(self, client):
|
||||
return (
|
||||
patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openrouter", "some-model", None, None, None),
|
||||
),
|
||||
patch(
|
||||
"agent.auxiliary_client._get_cached_client",
|
||||
return_value=(client, "some-model"),
|
||||
),
|
||||
patch(
|
||||
"agent.auxiliary_client._validate_llm_response",
|
||||
side_effect=lambda resp, _task: resp,
|
||||
),
|
||||
)
|
||||
|
||||
def test_retries_streaming_close_once_same_provider(self):
|
||||
client = MagicMock()
|
||||
client.base_url = "https://openrouter.ai/api/v1"
|
||||
client.chat.completions.create.side_effect = [
|
||||
Exception(
|
||||
"peer closed connection without sending complete message body "
|
||||
"(incomplete chunked read)"
|
||||
),
|
||||
{"ok": True},
|
||||
]
|
||||
p1, p2, p3 = self._patches(client)
|
||||
with p1, p2, p3:
|
||||
result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
|
||||
assert result == {"ok": True}
|
||||
# Same client called twice — no provider fallback needed.
|
||||
assert client.chat.completions.create.call_count == 2
|
||||
|
||||
def test_retries_5xx_once_same_provider(self):
|
||||
class _Err503(Exception):
|
||||
status_code = 503
|
||||
|
||||
client = MagicMock()
|
||||
client.base_url = "https://openrouter.ai/api/v1"
|
||||
client.chat.completions.create.side_effect = [_Err503("upstream"), {"ok": True}]
|
||||
p1, p2, p3 = self._patches(client)
|
||||
with p1, p2, p3:
|
||||
result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
|
||||
assert result == {"ok": True}
|
||||
assert client.chat.completions.create.call_count == 2
|
||||
|
||||
def test_does_not_retry_non_transient_400(self):
|
||||
class _Err400(Exception):
|
||||
status_code = 400
|
||||
|
||||
client = MagicMock()
|
||||
client.base_url = "https://openrouter.ai/api/v1"
|
||||
client.chat.completions.create.side_effect = _Err400("bad request")
|
||||
p1, p2, p3 = self._patches(client)
|
||||
with p1, p2, p3, pytest.raises(_Err400):
|
||||
call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
|
||||
# Non-transient: single attempt, no same-target retry.
|
||||
assert client.chat.completions.create.call_count == 1
|
||||
|
||||
def test_second_transient_failure_escalates_to_fallback(self):
|
||||
"""Two transient failures in a row exhaust the same-target retry and
|
||||
fall through to the existing connection-error provider fallback."""
|
||||
primary = MagicMock()
|
||||
primary.base_url = "https://openrouter.ai/api/v1"
|
||||
primary.chat.completions.create.side_effect = Exception(
|
||||
"peer closed connection without sending complete message body"
|
||||
)
|
||||
|
||||
fb_client = MagicMock()
|
||||
fb_client.base_url = "https://api.openai.com/v1"
|
||||
fb_client.chat.completions.create.return_value = {"fallback": True}
|
||||
|
||||
p1, p2, p3 = self._patches(primary)
|
||||
with (
|
||||
p1, p2, p3,
|
||||
patch(
|
||||
"agent.auxiliary_client._try_configured_fallback_chain",
|
||||
return_value=(None, None, ""),
|
||||
),
|
||||
patch(
|
||||
"agent.auxiliary_client._try_main_agent_model_fallback",
|
||||
return_value=(fb_client, "fb-model", "openai"),
|
||||
),
|
||||
):
|
||||
result = call_llm(task="compression", messages=[{"role": "user", "content": "hi"}])
|
||||
assert result == {"fallback": True}
|
||||
# Primary tried twice (initial + same-target retry), then fallback.
|
||||
assert primary.chat.completions.create.call_count == 2
|
||||
assert fb_client.chat.completions.create.call_count == 1
|
||||
|
||||
|
||||
class TestIsConnectionError:
|
||||
"""Tests for _is_connection_error detection."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user