Skip to content

Commit 55eb063

Browse files
alexliluzeternalcuriouslearnerlmolkova
authored
fix(anthropic): preserve caller errors in streaming responses (#397)
* fix(anthropic): preserve caller errors in streaming responses * chore(anthropic): add changelog fragment for streaming errors * fix(anthropic): preserve streaming exception semantics * fix(anthropic): preserve response context manager types Forward caller failures through the SDK response context managers while retaining the native SDK types and idempotent stream finalization. Add sync and async regression coverage. Assisted-by: ChatGPT 5.2 * test(anthropic): cover instrumentation round trips Add sync and async regression coverage for preserving the SDK streaming response context manager across instrument and uninstrument cycles.\n\nAssisted-by: ChatGPT 5.2 * test(anthropic): cover raw response caller failures Assisted-by: ChatGPT 5.2 * docs(anthropic): clarify async response wrapper Assisted-by: ChatGPT 5.2 * docs(anthropic): fix async_messages_create docstring --------- Co-authored-by: Surya <suryateja9108@gmail.com> Co-authored-by: Liudmila Molkova <neskazu@gmail.com>
1 parent e34e73b commit 55eb063

6 files changed

Lines changed: 362 additions & 27 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
preserve caller exceptions when closing Anthropic streaming responses

instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@
5252
from .patch import (
5353
async_messages_create,
5454
async_messages_stream,
55+
async_response_context_manager_exit,
5556
messages_create,
5657
messages_stream,
58+
response_context_manager_exit,
5759
)
5860

5961

@@ -134,6 +136,16 @@ def _instrument(self, **kwargs: Any) -> None:
134136
"AsyncMessages.stream",
135137
async_messages_stream(handler),
136138
)
139+
wrap_function_wrapper(
140+
"anthropic._response",
141+
"ResponseContextManager.__exit__",
142+
response_context_manager_exit,
143+
)
144+
wrap_function_wrapper(
145+
"anthropic._response",
146+
"AsyncResponseContextManager.__aexit__",
147+
async_response_context_manager_exit,
148+
)
137149

138150
# parse() wraps create() internally in the Anthropic SDK and returns a
139151
# parsed message whose telemetry-relevant fields match Message, so the
@@ -174,6 +186,13 @@ def _uninstrument(self, **kwargs: Any) -> None:
174186
anthropic.resources.messages.AsyncMessages, # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownArgumentType]
175187
"stream",
176188
)
189+
from anthropic._response import ( # pylint: disable=import-outside-toplevel
190+
AsyncResponseContextManager,
191+
ResponseContextManager,
192+
)
193+
194+
unwrap(ResponseContextManager, "__exit__")
195+
unwrap(AsyncResponseContextManager, "__aexit__")
177196
if self._parse_supported:
178197
unwrap(
179198
anthropic.resources.messages.Messages, # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownArgumentType]

instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/_raw_response.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ def __init__(
108108
self._self_stream_wrapper: Any = None
109109
self._install_hooks(raw_response)
110110

111+
def _fail(self, error: BaseException) -> None:
112+
"""Finalize this response with a caller-side failure, once."""
113+
if self._self_stream_wrapper is not None:
114+
self._self_stream_wrapper._finalize_failure(error)
115+
return
116+
if self._self_span_open:
117+
self._self_span_open = False
118+
self._self_invocation.fail(error)
119+
111120
def _install_hooks(self, raw_response: Any) -> None:
112121
http_response = getattr(raw_response, "http_response", None)
113122
if http_response is None:

instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,52 @@
4949
ANTHROPIC = "anthropic"
5050

5151

52+
def response_context_manager_exit(
53+
wrapped: Callable[..., Any],
54+
instance: Any,
55+
args: tuple[Any, ...],
56+
kwargs: dict[str, Any],
57+
) -> Any:
58+
"""Forward caller failures before Anthropic closes a raw response."""
59+
error = args[1] if len(args) > 1 else kwargs.get("exc")
60+
if isinstance(error, BaseException):
61+
_fail_context_manager_response(instance, error)
62+
return wrapped(*args, **kwargs)
63+
64+
65+
async def async_response_context_manager_exit(
66+
wrapped: Callable[..., Any],
67+
instance: Any,
68+
args: tuple[Any, ...],
69+
kwargs: dict[str, Any],
70+
) -> Any:
71+
"""Async counterpart to :func:`response_context_manager_exit`."""
72+
error = args[1] if len(args) > 1 else kwargs.get("exc")
73+
if isinstance(error, BaseException):
74+
_fail_context_manager_response(instance, error)
75+
return await wrapped(*args, **kwargs)
76+
77+
78+
def _fail_context_manager_response(
79+
instance: Any, error: BaseException
80+
) -> None:
81+
response = getattr(instance, "_ResponseContextManager__response", None)
82+
if response is None:
83+
response = getattr(
84+
instance, "_AsyncResponseContextManager__response", None
85+
)
86+
fail = getattr(response, "_fail", None)
87+
if not callable(fail):
88+
return
89+
try:
90+
fail(error)
91+
except Exception: # pylint: disable=broad-exception-caught
92+
_logger.debug(
93+
"Failed to record an exception from a streaming response",
94+
exc_info=True,
95+
)
96+
97+
5298
def _is_raw_response(result: object) -> bool:
5399
"""Whether ``result`` is a raw-response object to route through the proxy.
54100

instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py

Lines changed: 164 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
except ImportError:
1414
import httpx as _http_lib
1515
from anthropic import APIConnectionError, AsyncAnthropic, NotFoundError
16-
from anthropic._response import AsyncAPIResponse
16+
from anthropic._response import AsyncAPIResponse, AsyncResponseContextManager
1717

1818
try:
1919
from anthropic._legacy_response import LegacyAPIResponse
@@ -23,7 +23,10 @@
2323
from anthropic.resources.messages import AsyncMessages as _AsyncMessages
2424
from anthropic.types import Message
2525

26-
from opentelemetry.instrumentation.genai.anthropic import _raw_response
26+
from opentelemetry.instrumentation.genai.anthropic import (
27+
AnthropicInstrumentor,
28+
_raw_response,
29+
)
2730
from opentelemetry.instrumentation.genai.anthropic._raw_response import (
2831
RawResponseProxy,
2932
)
@@ -1542,14 +1545,6 @@ async def test_async_messages_raw_response_parse_after_exit(
15421545
assert message.model == model
15431546

15441547

1545-
@pytest.mark.skip(
1546-
reason="Known gap, tracked in #389: the SDK's "
1547-
"AsyncResponseContextManager.__aexit__ discards the caller's exception "
1548-
"before closing the response, so the proxy never sees it and the span is "
1549-
"finalized as a success. Fixing it means instrumenting "
1550-
"AsyncMessagesWithStreamingResponse.create and wrapping the context "
1551-
"manager itself, which is a new patch target and out of scope here."
1552-
)
15531548
@pytest.mark.cassette("test_async_messages_create_streaming_with_raw_response")
15541549
@pytest.mark.asyncio
15551550
@pytest.mark.vcr()
@@ -1585,6 +1580,108 @@ async def test_async_messages_with_streaming_response_user_exception(
15851580
assert span.attributes[ErrorAttributes.ERROR_TYPE] == "ValueError"
15861581

15871582

1583+
@pytest.mark.cassette("test_async_messages_create_streaming_with_raw_response")
1584+
@pytest.mark.asyncio
1585+
@pytest.mark.vcr()
1586+
async def test_async_messages_with_streaming_response_user_exception_before_parse(
1587+
span_exporter, async_anthropic_client, instrument_no_content
1588+
):
1589+
"""A caller error before parsing still fails the raw-response span once."""
1590+
with pytest.raises(ValueError, match="User raised exception"):
1591+
async with (
1592+
async_anthropic_client.messages.with_streaming_response.create(
1593+
model="claude-sonnet-4-20250514",
1594+
max_tokens=100,
1595+
messages=[
1596+
{"role": "user", "content": "Say hello in one word."}
1597+
],
1598+
stream=True,
1599+
)
1600+
):
1601+
raise ValueError("User raised exception")
1602+
1603+
spans = span_exporter.get_finished_spans()
1604+
assert len(spans) == 1
1605+
assert spans[0].attributes[ErrorAttributes.ERROR_TYPE] == "ValueError"
1606+
1607+
1608+
@pytest.mark.cassette("test_async_messages_create_streaming_with_raw_response")
1609+
@pytest.mark.asyncio
1610+
@pytest.mark.vcr()
1611+
async def test_async_messages_with_streaming_response_user_exception_after_drain(
1612+
span_exporter, async_anthropic_client, instrument_no_content
1613+
):
1614+
"""A caller error after draining does not finalize the span twice."""
1615+
with pytest.raises(ValueError, match="User raised exception"):
1616+
async with (
1617+
async_anthropic_client.messages.with_streaming_response.create(
1618+
model="claude-sonnet-4-20250514",
1619+
max_tokens=100,
1620+
messages=[
1621+
{"role": "user", "content": "Say hello in one word."}
1622+
],
1623+
stream=True,
1624+
)
1625+
) as raw_response:
1626+
async for _ in await raw_response.parse():
1627+
pass
1628+
raise ValueError("User raised exception")
1629+
1630+
spans = span_exporter.get_finished_spans()
1631+
assert len(spans) == 1
1632+
assert ErrorAttributes.ERROR_TYPE not in spans[0].attributes
1633+
1634+
1635+
@pytest.mark.cassette("test_async_messages_create_with_raw_response")
1636+
@pytest.mark.asyncio
1637+
@pytest.mark.vcr()
1638+
async def test_async_messages_with_streaming_response_nonstreaming_user_exception(
1639+
span_exporter, async_anthropic_client, instrument_no_content
1640+
):
1641+
"""A caller error in a non-streaming async response context fails the span."""
1642+
model = "claude-sonnet-4-20250514"
1643+
1644+
with pytest.raises(ValueError, match="User raised exception"):
1645+
async with (
1646+
async_anthropic_client.messages.with_streaming_response.create(
1647+
model=model,
1648+
max_tokens=100,
1649+
messages=[
1650+
{"role": "user", "content": "Say hello in one word."}
1651+
],
1652+
)
1653+
):
1654+
raise ValueError("User raised exception")
1655+
1656+
spans = span_exporter.get_finished_spans()
1657+
assert len(spans) == 1
1658+
assert spans[0].attributes[ErrorAttributes.ERROR_TYPE] == "ValueError"
1659+
1660+
1661+
@pytest.mark.cassette("test_async_messages_create_with_raw_response")
1662+
@pytest.mark.asyncio
1663+
@pytest.mark.vcr()
1664+
async def test_async_messages_with_raw_response_caller_exception(
1665+
span_exporter, async_anthropic_client, instrument_no_content
1666+
):
1667+
"""A caller error while handling a raw response is separate from the call."""
1668+
raw_response = (
1669+
await async_anthropic_client.messages.with_raw_response.create(
1670+
model="claude-sonnet-4-20250514",
1671+
max_tokens=100,
1672+
messages=[{"role": "user", "content": "Say hello in one word."}],
1673+
)
1674+
)
1675+
1676+
with pytest.raises(ValueError, match="User raised exception"):
1677+
raise ValueError("User raised exception")
1678+
1679+
spans = span_exporter.get_finished_spans()
1680+
assert len(spans) == 1
1681+
assert ErrorAttributes.ERROR_TYPE not in spans[0].attributes
1682+
assert raw_response.headers is not None
1683+
1684+
15881685
@pytest.mark.cassette("test_async_messages_create_api_error")
15891686
@pytest.mark.asyncio
15901687
@pytest.mark.vcr()
@@ -1608,6 +1705,52 @@ async def test_async_messages_with_raw_response_api_error(
16081705
assert "NotFoundError" in span.attributes[ErrorAttributes.ERROR_TYPE]
16091706

16101707

1708+
@pytest.mark.asyncio
1709+
async def test_async_streaming_response_type_survives_instrumentation_round_trip(
1710+
tracer_provider, logger_provider, meter_provider
1711+
):
1712+
"""Async instrumentation round-trips keep the SDK manager type intact."""
1713+
instrumentor = AnthropicInstrumentor()
1714+
client = AsyncAnthropic()
1715+
1716+
def create_context_manager():
1717+
context_manager = client.messages.with_streaming_response.create(
1718+
model="claude-sonnet-4-20250514",
1719+
max_tokens=100,
1720+
messages=[{"role": "user", "content": "Hello"}],
1721+
stream=True,
1722+
)
1723+
request = getattr(context_manager, "_api_request", None)
1724+
if inspect.iscoroutine(request):
1725+
request.close()
1726+
return context_manager
1727+
1728+
instrumentor.instrument(
1729+
tracer_provider=tracer_provider,
1730+
logger_provider=logger_provider,
1731+
meter_provider=meter_provider,
1732+
)
1733+
try:
1734+
assert (
1735+
create_context_manager().__class__ is AsyncResponseContextManager
1736+
)
1737+
instrumentor.uninstrument()
1738+
assert (
1739+
create_context_manager().__class__ is AsyncResponseContextManager
1740+
)
1741+
instrumentor.instrument(
1742+
tracer_provider=tracer_provider,
1743+
logger_provider=logger_provider,
1744+
meter_provider=meter_provider,
1745+
)
1746+
assert (
1747+
create_context_manager().__class__ is AsyncResponseContextManager
1748+
)
1749+
finally:
1750+
instrumentor.uninstrument()
1751+
await client.close()
1752+
1753+
16111754
@pytest.mark.cassette("test_async_messages_create_with_raw_response")
16121755
@pytest.mark.asyncio
16131756
@pytest.mark.vcr()
@@ -1634,12 +1777,17 @@ async def test_async_messages_streaming_response_is_transparent(
16341777
span_exporter, async_anthropic_client, instrument_no_content
16351778
):
16361779
"""The streaming proxy must also keep the SDK's type and its parsed stream."""
1637-
async with async_anthropic_client.messages.with_streaming_response.create(
1638-
model="claude-sonnet-4-20250514",
1639-
max_tokens=100,
1640-
messages=[{"role": "user", "content": "Say hello in one word."}],
1641-
stream=True,
1642-
) as raw_response:
1780+
context_manager = (
1781+
async_anthropic_client.messages.with_streaming_response.create(
1782+
model="claude-sonnet-4-20250514",
1783+
max_tokens=100,
1784+
messages=[{"role": "user", "content": "Say hello in one word."}],
1785+
stream=True,
1786+
)
1787+
)
1788+
assert isinstance(context_manager, AsyncResponseContextManager)
1789+
assert context_manager.__class__ is AsyncResponseContextManager
1790+
async with context_manager as raw_response:
16431791
assert isinstance(raw_response, AsyncAPIResponse)
16441792
assert raw_response.__class__ is AsyncAPIResponse
16451793
stream = await raw_response.parse()

0 commit comments

Comments
 (0)