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:
teknium1
2026-06-08 01:05:45 -07:00
committed by Teknium
co-authored by ARegalado1
parent 4107076128
commit 02a4d66951
3 changed files with 160 additions and 4 deletions
+102
View File
@@ -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."""