fix(providers): allow retry and fallback on stream stalled timeout

When a stream stalls mid-response, both the retry layer and
FallbackProvider blocked recovery because content had already been
emitted via on_content_delta. This left users with truncated replies
and no automatic recovery.

For error_kind="timeout" specifically:
- _run_with_retry now suppresses delta callbacks and retries the same
  model instead of returning immediately
- FallbackProvider now allows failover to a different model with
  delta callbacks suppressed

Non-timeout errors retain the original "skip retry/failover after
streamed content" behavior to avoid duplicate output.
This commit is contained in:
aiguozhi123456
2026-06-10 18:10:44 +08:00
committed by Xubin Ren
parent dadb35af49
commit 2c5a4e0703
4 changed files with 97 additions and 11 deletions
+31 -3
View File
@@ -287,7 +287,7 @@ class TestFallbackOnPrimaryError:
class TestNoFallbackWhenContentStreamed:
@pytest.mark.asyncio
async def test(self) -> None:
async def test_non_timeout_error_skips_failover(self) -> None:
primary = _FakeProvider("primary", _error_response())
factory = MagicMock()
fb = FallbackProvider(
@@ -303,12 +303,40 @@ class TestNoFallbackWhenContentStreamed:
messages=[{"role": "user", "content": "hi"}],
on_content_delta=_delta,
)
# Primary returns error but content was "streamed" (FakeProvider calls delta)
# so failover should be skipped
assert result.finish_reason == "error"
factory.assert_not_called()
class TestFallbackOnStreamStalledAfterContent:
@pytest.mark.asyncio
async def test_timeout_with_streamed_content_falls_back(self) -> None:
primary = _FakeProvider(
"primary",
_make_response("stream stalled", finish_reason="error", error_kind="timeout"),
)
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
factory = MagicMock(return_value=fallback)
fb = FallbackProvider(
primary=primary,
fallback_presets=[_fallback("fallback-a")],
provider_factory=factory,
)
streamed: list[str] = []
async def _delta(text: str) -> None:
streamed.append(text)
result = await fb.chat_stream(
messages=[{"role": "user", "content": "hi"}],
on_content_delta=_delta,
)
assert result.finish_reason == "stop"
assert result.content == "fallback ok"
factory.assert_called_once_with(_fallback("fallback-a"))
assert "stream stalled" in streamed
class TestFailoverOnTransientError:
@pytest.mark.asyncio
async def test_rate_limit(self) -> None: