Skip to content

Commit 64c68ca

Browse files
Python: Skip get_final_response in OTel _finalize_stream when stream errored (#5232)
* Python: Skip get_final_response in OTel _finalize_stream when stream errored When a streaming error occurs, _finalize_stream (a cleanup hook registered by AgentTelemetryLayer) was unconditionally calling get_final_response(), which triggers all registered result hooks including after_run context providers. This caused providers to fire incorrectly on error paths. Guard against this by checking result_stream._consumed: True only after StopAsyncIteration (normal completion), False when an exception was raised. The fix applies to both the chat client and agent telemetry layers. Closes #5231 * Python: Expose consumed/stream_error on ResponseStream and capture error in OTel span Address Copilot review feedback on #5232: - Add `_stream_error: Exception | None` to ResponseStream, set in __anext__'s except branch so cleanup hooks can inspect the failure. - Expose public `consumed` and `stream_error` properties to avoid coupling observability.py to private stream internals. - Update both _finalize_stream closures (chat and agent layers) to use the public properties and call capture_exception() with the stream error before returning early, ensuring the OTel span records the failure rather than closing silently. * Python: Address Copilot review feedback on stream error handling - Use stream_error is not None as the guard in _finalize_stream instead of not consumed, so the early-return path is keyed precisely to actual errors rather than any non-normal completion state. - Clear _stream_error after _run_cleanup_hooks() completes to avoid retaining the exception traceback (and any large object graphs it references) on the stream instance beyond the cleanup phase. * Python: Remove consumed/stream_error properties, use private attrs directly Per review feedback: since observability.py and _types.py are in the same package, accessing _stream_error directly is fine and the public properties are unnecessary. * Python: Fix Pyright reportPrivateUsage via inline ignore comments Keep _stream_error private (consistent with rest of ResponseStream), and suppress reportPrivateUsage at the call sites in observability.py with inline pyright: ignore comments — access is intentional within the package.
1 parent 98e1776 commit 64c68ca

File tree

2 files changed

+19
-2
lines changed

2 files changed

+19
-2
lines changed

python/packages/core/agent_framework/_types.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2816,6 +2816,7 @@ def __init__(
28162816
cleanup_hooks if cleanup_hooks is not None else []
28172817
)
28182818
self._cleanup_run: bool = False
2819+
self._stream_error: Exception | None = None
28192820
self._inner_stream: ResponseStream[Any, Any] | None = None
28202821
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
28212822
self._wrap_inner: bool = False
@@ -2948,8 +2949,12 @@ async def __anext__(self) -> UpdateT:
29482949
await self._run_cleanup_hooks()
29492950
await self.get_final_response()
29502951
raise
2951-
except Exception:
2952-
await self._run_cleanup_hooks()
2952+
except Exception as exc:
2953+
self._stream_error = exc
2954+
try:
2955+
await self._run_cleanup_hooks()
2956+
finally:
2957+
self._stream_error = None
29532958
raise
29542959
if self._map_update is not None:
29552960
update = self._map_update(update) # type: ignore[assignment]

python/packages/core/agent_framework/observability.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,6 +1323,12 @@ async def _finalize_stream() -> None:
13231323
from ._types import ChatResponse
13241324

13251325
try:
1326+
if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage]
1327+
# Stream errored; skip get_final_response() to avoid firing
1328+
# result hooks such as after_run context providers on error
1329+
# paths. Capture the error on the span before returning.
1330+
capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage]
1331+
return
13261332
response: ChatResponse[Any] = await result_stream.get_final_response()
13271333
duration = duration_state.get("duration")
13281334
response_attributes = _get_response_attributes(attributes, response)
@@ -1579,6 +1585,12 @@ async def _finalize_stream() -> None:
15791585
from ._types import AgentResponse
15801586

15811587
try:
1588+
if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage]
1589+
# Stream errored; skip get_final_response() to avoid firing
1590+
# result hooks such as after_run context providers on error
1591+
# paths. Capture the error on the span before returning.
1592+
capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage]
1593+
return
15821594
response: AgentResponse[Any] = await result_stream.get_final_response()
15831595
duration = duration_state.get("duration")
15841596
response_attributes = _get_response_attributes(

0 commit comments

Comments
 (0)