From 17a1c02e93460ba764eb94beaa56b4f539d99af4 Mon Sep 17 00:00:00 2001 From: AP Date: Mon, 12 Oct 2026 09:58:33 +0530 Subject: [PATCH 1/2] fix(huggingface_hub, cohere): stop swallowing stream iteration errors The streaming iterators wrapped the whole chunk loop in capture_internal_exceptions(), which catches everything including provider errors raised mid-iteration, so a truncated answer looked complete and nothing was reported. Run iteration/yield outside it (only span bookkeeping stays inside), record the provider error, finish the span with error status, and re-raise. Fixes getsentry/sentry-python#7889. --- sentry_sdk/integrations/cohere.py | 28 +++-- sentry_sdk/integrations/huggingface_hub.py | 117 ++++++++++-------- tests/integrations/cohere/test_cohere.py | 43 +++++++ .../huggingface_hub/test_huggingface_hub.py | 79 ++++++++++++ 4 files changed, 209 insertions(+), 58 deletions(-) diff --git a/sentry_sdk/integrations/cohere.py b/sentry_sdk/integrations/cohere.py index 0b1d1ebef4..6ccbe515c1 100644 --- a/sentry_sdk/integrations/cohere.py +++ b/sentry_sdk/integrations/cohere.py @@ -253,17 +253,27 @@ def new_chat(*args: "Any", **kwargs: "Any") -> "Any": old_iterator = res def new_iterator() -> "Iterator[StreamedChatResponse]": - with capture_internal_exceptions(): + try: for x in old_iterator: - if isinstance(x, ChatStreamEndEvent) or isinstance( - x, StreamEndStreamedChatResponse - ): - collect_chat_response_fields( - span, - x.response, - include_pii=_should_record(integration, "outputs"), - ) + with capture_internal_exceptions(): + if isinstance(x, ChatStreamEndEvent) or isinstance( + x, StreamEndStreamedChatResponse + ): + collect_chat_response_fields( + span, + x.response, + include_pii=_should_record( + integration, "outputs" + ), + ) yield x + except Exception as e: + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + span.__exit__(*exc_info) + reraise(*exc_info) + _end_span(span) return new_iterator() diff --git a/sentry_sdk/integrations/huggingface_hub.py b/sentry_sdk/integrations/huggingface_hub.py index b2e6b3ef71..2155c78849 100644 --- a/sentry_sdk/integrations/huggingface_hub.py +++ b/sentry_sdk/integrations/huggingface_hub.py @@ -297,29 +297,37 @@ def new_details_iterator() -> "Iterable[Any]": response_text_buffer: "list[str]" = [] tokens_used = 0 - with capture_internal_exceptions(): + try: for chunk in res: - if ( - hasattr(chunk, "token") - and hasattr(chunk.token, "text") - and chunk.token.text is not None - ): - response_text_buffer.append(chunk.token.text) + with capture_internal_exceptions(): + if ( + hasattr(chunk, "token") + and hasattr(chunk.token, "text") + and chunk.token.text is not None + ): + response_text_buffer.append(chunk.token.text) - if hasattr(chunk, "details") and hasattr( - chunk.details, "finish_reason" - ): - finish_reason = chunk.details.finish_reason + if hasattr(chunk, "details") and hasattr( + chunk.details, "finish_reason" + ): + finish_reason = chunk.details.finish_reason - if ( - hasattr(chunk, "details") - and hasattr(chunk.details, "generated_tokens") - and chunk.details.generated_tokens is not None - ): - tokens_used = chunk.details.generated_tokens + if ( + hasattr(chunk, "details") + and hasattr(chunk.details, "generated_tokens") + and chunk.details.generated_tokens is not None + ): + tokens_used = chunk.details.generated_tokens yield chunk + except Exception as e: + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + span.__exit__(*exc_info) + reraise(*exc_info) + with capture_internal_exceptions(): if finish_reason is not None: set_data_normalized( span, @@ -360,44 +368,55 @@ def new_iterator() -> "Iterable[ChatCompletionStreamOutput]": tool_calls = None usage = None - with capture_internal_exceptions(): + try: for chunk in cast("Iterable[ChatCompletionStreamOutput]", res): - if hasattr(chunk, "model") and chunk.model is not None: - response_model = chunk.model - - if hasattr(chunk, "usage") and chunk.usage is not None: - usage = chunk.usage - - if isinstance(chunk, str): - if chunk is not None: - response_text_buffer.append(chunk) - - if hasattr(chunk, "choices") and chunk.choices is not None: - for choice in chunk.choices: - if ( - hasattr(choice, "delta") - and hasattr(choice.delta, "content") - and choice.delta.content is not None - ): - response_text_buffer.append( - choice.delta.content - ) + with capture_internal_exceptions(): + if hasattr(chunk, "model") and chunk.model is not None: + response_model = chunk.model - if ( - hasattr(choice, "finish_reason") - and choice.finish_reason is not None - ): - finish_reason = choice.finish_reason + if hasattr(chunk, "usage") and chunk.usage is not None: + usage = chunk.usage - if ( - hasattr(choice, "delta") - and hasattr(choice.delta, "tool_calls") - and choice.delta.tool_calls is not None - ): - tool_calls = choice.delta.tool_calls + if isinstance(chunk, str): + if chunk is not None: + response_text_buffer.append(chunk) + + if ( + hasattr(chunk, "choices") + and chunk.choices is not None + ): + for choice in chunk.choices: + if ( + hasattr(choice, "delta") + and hasattr(choice.delta, "content") + and choice.delta.content is not None + ): + response_text_buffer.append( + choice.delta.content + ) + + if ( + hasattr(choice, "finish_reason") + and choice.finish_reason is not None + ): + finish_reason = choice.finish_reason + + if ( + hasattr(choice, "delta") + and hasattr(choice.delta, "tool_calls") + and choice.delta.tool_calls is not None + ): + tool_calls = choice.delta.tool_calls yield chunk + except Exception as e: + exc_info = sys.exc_info() + with capture_internal_exceptions(): + _capture_exception(e) + span.__exit__(*exc_info) + reraise(*exc_info) + with capture_internal_exceptions(): if response_model is not None: _set_span_data_attribute( span, SPANDATA.GEN_AI_RESPONSE_MODEL, response_model diff --git a/tests/integrations/cohere/test_cohere.py b/tests/integrations/cohere/test_cohere.py index 9cb9fac75d..10f8817527 100644 --- a/tests/integrations/cohere/test_cohere.py +++ b/tests/integrations/cohere/test_cohere.py @@ -866,3 +866,46 @@ def test_embed_data_collection( assert "hello" in str(attributes[SPANDATA.AI_INPUT_MESSAGES]) else: assert SPANDATA.AI_INPUT_MESSAGES not in attributes + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_streaming_chat_iteration_error_propagates( + sentry_init, capture_events, capture_items, span_streaming +): + """Mid-stream provider errors must reach the caller, not be swallowed. + + Regression test for https://github.com/getsentry/sentry-python/issues/7889 + """ + from sentry_sdk.integrations.cohere import _wrap_chat + + sentry_init( + integrations=[CohereIntegration()], + traces_sample_rate=1.0, + trace_lifecycle="stream" if span_streaming else "static", + ) + events = capture_events() + items = capture_items("span") if span_streaming else None + + def failing_stream(*args, **kwargs): + yield object() + raise httpx.RemoteProtocolError("connection dropped", request=None) + + wrapped = _wrap_chat(failing_stream, streaming=True) + + with start_transaction(name="test"): + with pytest.raises(httpx.RemoteProtocolError): + list(wrapped(message="hello")) + + sentry_sdk.flush() + + # the error was recorded on Sentry instead of vanishing + errors = [e for e in events if e.get("level") == "error"] + assert len(errors) == 1 + + if span_streaming: + assert len(items) == 1 + assert items[0].payload["status"] == "error" + else: + transactions = [e for e in events if e.get("type") == "transaction"] + assert len(transactions) == 1 + assert transactions[0]["spans"][0]["status"] == "internal_error" diff --git a/tests/integrations/huggingface_hub/test_huggingface_hub.py b/tests/integrations/huggingface_hub/test_huggingface_hub.py index 5242e40fdb..0239545aa0 100644 --- a/tests/integrations/huggingface_hub/test_huggingface_hub.py +++ b/tests/integrations/huggingface_hub/test_huggingface_hub.py @@ -1,4 +1,5 @@ import re +from types import SimpleNamespace from typing import TYPE_CHECKING import pytest @@ -2712,3 +2713,81 @@ def test_chat_completion_streaming_data_collection_tools( assert span_data[SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 183 assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 14 assert span_data[SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 197 + + +def test_text_generation_streaming_iteration_error_propagates( + sentry_init: "Any", capture_events: "Any" +) -> None: + """Mid-stream provider errors must reach the caller, not be swallowed. + + Regression test for https://github.com/getsentry/sentry-python/issues/7889 + """ + from sentry_sdk.integrations.huggingface_hub import _wrap_huggingface_task + + sentry_init( + integrations=[HuggingfaceHubIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + def failing_stream(*args: "Any", **kwargs: "Any") -> "Any": + yield SimpleNamespace( + token=SimpleNamespace(text="the mocked "), + details=SimpleNamespace(finish_reason=None, generated_tokens=None), + ) + raise RuntimeError("mid-stream failure") + + wrapped = _wrap_huggingface_task(failing_stream, "gen_ai.text_completion") + + with sentry_sdk.start_transaction(name="test"): + with pytest.raises(RuntimeError, match="mid-stream failure"): + list( + wrapped( + SimpleNamespace(model="test-model"), + prompt="Hello", + details=True, + ) + ) + + sentry_sdk.flush() + + # the error was recorded on Sentry instead of vanishing + errors = [e for e in events if e.get("level") == "error"] + assert len(errors) == 1 + + +def test_chat_completion_streaming_iteration_error_propagates( + sentry_init: "Any", capture_events: "Any" +) -> None: + """Mid-stream provider errors must reach the caller, not be swallowed. + + Regression test for https://github.com/getsentry/sentry-python/issues/7889 + """ + from sentry_sdk.integrations.huggingface_hub import _wrap_huggingface_task + + sentry_init( + integrations=[HuggingfaceHubIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + def failing_stream(*args: "Any", **kwargs: "Any") -> "Any": + yield SimpleNamespace(model="test-model", usage=None, choices=None) + raise RuntimeError("mid-stream failure") + + wrapped = _wrap_huggingface_task(failing_stream, "gen_ai.chat_completions") + + with sentry_sdk.start_transaction(name="test"): + with pytest.raises(RuntimeError, match="mid-stream failure"): + list( + wrapped( + SimpleNamespace(model="test-model"), + messages=[{"role": "user", "content": "Hello"}], + ) + ) + + sentry_sdk.flush() + + # the error was recorded on Sentry instead of vanishing + errors = [e for e in events if e.get("level") == "error"] + assert len(errors) == 1 From b6c22ec8577705d0b567da2875b0823e67cef626 Mon Sep 17 00:00:00 2001 From: AP Date: Mon, 12 Oct 2026 14:03:10 +0530 Subject: [PATCH 2/2] fix: stop capturing mid-stream iteration errors as Sentry events Per review: iterator exceptions should just bubble out. The span still finishes with error status and the exception re-raises, but we no longer send the provider error as a Sentry event -- that was behavior change versus running without the SDK. Same change in both huggingface_hub streaming iterators for consistency; the three regression tests now assert propagation + span status only. --- sentry_sdk/integrations/cohere.py | 3 +-- sentry_sdk/integrations/huggingface_hub.py | 6 ++---- tests/integrations/cohere/test_cohere.py | 4 ---- .../huggingface_hub/test_huggingface_hub.py | 10 ---------- 4 files changed, 3 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/integrations/cohere.py b/sentry_sdk/integrations/cohere.py index 6ccbe515c1..1e9a4ae011 100644 --- a/sentry_sdk/integrations/cohere.py +++ b/sentry_sdk/integrations/cohere.py @@ -267,10 +267,9 @@ def new_iterator() -> "Iterator[StreamedChatResponse]": ), ) yield x - except Exception as e: + except Exception: exc_info = sys.exc_info() with capture_internal_exceptions(): - _capture_exception(e) span.__exit__(*exc_info) reraise(*exc_info) diff --git a/sentry_sdk/integrations/huggingface_hub.py b/sentry_sdk/integrations/huggingface_hub.py index 2155c78849..8231749930 100644 --- a/sentry_sdk/integrations/huggingface_hub.py +++ b/sentry_sdk/integrations/huggingface_hub.py @@ -320,10 +320,9 @@ def new_details_iterator() -> "Iterable[Any]": tokens_used = chunk.details.generated_tokens yield chunk - except Exception as e: + except Exception: exc_info = sys.exc_info() with capture_internal_exceptions(): - _capture_exception(e) span.__exit__(*exc_info) reraise(*exc_info) @@ -409,10 +408,9 @@ def new_iterator() -> "Iterable[ChatCompletionStreamOutput]": tool_calls = choice.delta.tool_calls yield chunk - except Exception as e: + except Exception: exc_info = sys.exc_info() with capture_internal_exceptions(): - _capture_exception(e) span.__exit__(*exc_info) reraise(*exc_info) diff --git a/tests/integrations/cohere/test_cohere.py b/tests/integrations/cohere/test_cohere.py index 10f8817527..89eb26c1d2 100644 --- a/tests/integrations/cohere/test_cohere.py +++ b/tests/integrations/cohere/test_cohere.py @@ -898,10 +898,6 @@ def failing_stream(*args, **kwargs): sentry_sdk.flush() - # the error was recorded on Sentry instead of vanishing - errors = [e for e in events if e.get("level") == "error"] - assert len(errors) == 1 - if span_streaming: assert len(items) == 1 assert items[0].payload["status"] == "error" diff --git a/tests/integrations/huggingface_hub/test_huggingface_hub.py b/tests/integrations/huggingface_hub/test_huggingface_hub.py index 0239545aa0..7f01bc28bd 100644 --- a/tests/integrations/huggingface_hub/test_huggingface_hub.py +++ b/tests/integrations/huggingface_hub/test_huggingface_hub.py @@ -2728,7 +2728,6 @@ def test_text_generation_streaming_iteration_error_propagates( integrations=[HuggingfaceHubIntegration()], traces_sample_rate=1.0, ) - events = capture_events() def failing_stream(*args: "Any", **kwargs: "Any") -> "Any": yield SimpleNamespace( @@ -2751,10 +2750,6 @@ def failing_stream(*args: "Any", **kwargs: "Any") -> "Any": sentry_sdk.flush() - # the error was recorded on Sentry instead of vanishing - errors = [e for e in events if e.get("level") == "error"] - assert len(errors) == 1 - def test_chat_completion_streaming_iteration_error_propagates( sentry_init: "Any", capture_events: "Any" @@ -2769,7 +2764,6 @@ def test_chat_completion_streaming_iteration_error_propagates( integrations=[HuggingfaceHubIntegration()], traces_sample_rate=1.0, ) - events = capture_events() def failing_stream(*args: "Any", **kwargs: "Any") -> "Any": yield SimpleNamespace(model="test-model", usage=None, choices=None) @@ -2787,7 +2781,3 @@ def failing_stream(*args: "Any", **kwargs: "Any") -> "Any": ) sentry_sdk.flush() - - # the error was recorded on Sentry instead of vanishing - errors = [e for e in events if e.get("level") == "error"] - assert len(errors) == 1