Skip to content

Commit 4bab9ca

Browse files
committed
fix(models): preserve the LiteLLM stream error when cleanup also fails
The finally block raised a cleanup failure over any in-flight provider error. The runner classifies retries from the exception that escapes the model stream, and _is_network_like_error only recognises APIConnectionError at the top level, so a masked connection error silently stopped being retryable. Capture the in-flight error and log the cleanup failure instead of raising it when the stream has already failed. GeneratorExit from an explicit aclose() is a close request rather than a stream failure, so it still lets a cleanup error surface.
1 parent 71d8c75 commit 4bab9ca

2 files changed

Lines changed: 41 additions & 1 deletion

File tree

src/agents/extensions/models/litellm_model.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ async def stream_response(
402402
final_response: Response | None = None
403403
close_stream_in_background = False
404404
yielded_terminal_event = False
405+
stream_error: Exception | None = None
405406
try:
406407
async for chunk in ChatCmplStreamHandler.handle_stream(
407408
response, stream, model=self.model
@@ -415,12 +416,21 @@ async def stream_response(
415416
close_stream_in_background = True
416417
self._schedule_async_iterator_close(stream)
417418
raise
419+
except Exception as exc:
420+
stream_error = exc
421+
raise
418422
finally:
419423
if not close_stream_in_background:
420424
try:
421425
await self._maybe_aclose(stream)
422426
except Exception as exc:
423-
if yielded_terminal_event:
427+
if stream_error is not None:
428+
log_model_action_debug(
429+
logger,
430+
"Ignoring stream cleanup error after stream failure",
431+
exc,
432+
)
433+
elif yielded_terminal_event:
424434
log_model_action_debug(
425435
logger,
426436
"Ignoring stream cleanup error after terminal event",

tests/models/test_litellm_chatcompletions_stream.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from collections.abc import AsyncIterator
33
from typing import Any, cast
44

5+
import httpx
56
import pytest
7+
from openai import APIConnectionError
68
from openai.types.chat.chat_completion_chunk import (
79
ChatCompletionChunk,
810
Choice,
@@ -928,6 +930,34 @@ async def test_stream_response_ignores_close_error_after_terminal_event(monkeypa
928930
assert output_events[-1].type == "response.completed"
929931

930932

933+
@pytest.mark.allow_call_model_methods
934+
@pytest.mark.asyncio
935+
async def test_stream_response_preserves_stream_error_when_close_also_fails(monkeypatch) -> None:
936+
"""A failing cleanup must not replace the in-flight provider error.
937+
938+
The runner classifies retries from the exception that escapes the model stream, so a
939+
retryable provider error has to survive a cleanup failure.
940+
"""
941+
942+
class _FailingStream(_ClosableChatStream):
943+
async def __anext__(self) -> ChatCompletionChunk:
944+
raise APIConnectionError(request=httpx.Request("POST", "https://example.test/v1"))
945+
946+
async def aclose(self) -> None:
947+
self.aclose_calls += 1
948+
raise RuntimeError("provider close failed")
949+
950+
provider_stream = _FailingStream([])
951+
_patch_fetch_response(monkeypatch, provider_stream)
952+
model = LitellmProvider().get_model("gpt-4")
953+
954+
with pytest.raises(APIConnectionError):
955+
async for _event in _stream_response(model):
956+
pass
957+
958+
assert provider_stream.aclose_calls == 1
959+
960+
931961
@pytest.mark.allow_call_model_methods
932962
@pytest.mark.asyncio
933963
async def test_stream_response_surfaces_close_error_before_terminal_event(monkeypatch) -> None:

0 commit comments

Comments
 (0)