Skip to content

[NA] [SDK] fix: stop anthropic stream wrappers from swallowing exceptions in finally - #7981

Merged
alexkuzmik merged 6 commits into
comet-ml:mainfrom
trakshan-mishra:trakshan-mishra/NA-anthropic-stream-finally-swallow
Aug 26, 2026
Merged

[NA] [SDK] fix: stop anthropic stream wrappers from swallowing exceptions in finally#7981
alexkuzmik merged 6 commits into
comet-ml:mainfrom
trakshan-mishra:trakshan-mishra/NA-anthropic-stream-finally-swallow

Conversation

@trakshan-mishra

@trakshan-mishra trakshan-mishra commented Aug 25, 2026

Copy link
Copy Markdown

Details

The Anthropic stream patchers install class-level __iter__/__aiter__ overrides on anthropic.Stream, AsyncStream, MessageStream, AsyncMessageStream, BetaMessageStream, and BetaAsyncMessageStream. Because the patch is class-level, every stream instance in the process runs through the wrapper — tracked or not. Each wrapper used an early return inside a finally block to skip cleanup for non-tracked streams. A return in finally silently swallows any in-flight exception, so once any opik-tracked streaming call installed the class patch, a non-tracked stream that errored mid-iteration completed silently instead of raising.

Fix: invert the guard so the early return is gone and cleanup nests under if hasattr(...). Tracked streams behave exactly as before; non-tracked streams now propagate their exceptions. Also removes the Python SyntaxWarning: 'return' in a 'finally' block emitted from all six wrappers on import.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves #
  • OPIK-

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: opencode (CLI coding agent)
  • Model(s): glm-5.2
  • Scope: identified the bug by running the local Opik stack + instrumenting a multi-step agent loop; wrote the fix, repro, and unit tests
  • Human verification: reviewed the diff, ran ruff/mypy/pytest, confirmed the test fails on upstream and passes with the fix

Testing

  • Commands run:
    • ruff format --check and ruff check on both changed files — clean
    • mypy on stream_patchers.pySuccess: no issues found
    • pytest tests/library_integration/anthropic/test_stream_patchers.py12 passed (4 parametrized tests × 3 wrapper classes each for sync + async, including beta variants)
    • pytest tests/unit/decorator tests/unit/llm_usage208 passed
    • Standalone repro script confirmed: before fix → BUG: exception swallowed; after fix → PASS (propagated)
  • Scenarios validated:
    • Regression: the 6 non-tracked tests fail on upstream code (exception swallowed), pass with fix
    • Tracked stream path: cleanup callback runs once with error_info (exception_type, message, traceback) matching the injected RuntimeError, exception still propagates
    • Non-tracked stream path: cleanup callback not called, exception propagates
    • Covers all 6 wrappers: Stream, AsyncStream, MessageStream, AsyncMessageStream, BetaMessageStream, BetaAsyncMessageStream
  • Environment: Local Opik stack via ./opik.sh --build (Docker), Python 3.14, anthropic SDK 1.0.0, editable SDK install
  • Pre-commit: make precommit requires Java/Node toolchains for non-Python hooks; the Python-relevant hooks (ruff, ruff-format, mypy) were run directly and pass

Documentation

…ions in finally

The Anthropic stream patchers install class-level __iter__/__aiter__
overrides on anthropic.Stream, AsyncStream, MessageStream,
AsyncMessageStream, BetaMessageStream, and BetaAsyncMessageStream.
Because the patch is class-level, every stream instance in the process
runs through the wrapper, tracked or not.

Each wrapper used an early `return` inside a `finally` block to skip
cleanup for non-tracked streams. A `return` in `finally` silently
swallows any in-flight exception. So once any opik-tracked streaming
call installed the class patch, a non-tracked anthropic.Stream that
errored mid-iteration completed silently instead of raising — the
caller never saw the error.

Fix: invert the guard so the early return is gone and cleanup nests
under `if hasattr(...)`. Tracked streams behave exactly as before
(cleanup runs, exception propagates). Non-tracked streams now propagate
their exceptions.

Also removes the Python SyntaxWarning ("'return' in a 'finally' block")
emitted from all six wrappers on import.

Added tests/unit/integrations/anthropic/test_stream_patchers.py covering:
- non-tracked stream exception propagates, cleanup callback not called
- tracked stream exception propagates, cleanup callback runs once with error_info
@github-actions github-actions Bot added python Pull requests that update Python code tests Including test files, or tests related like configuration. Python SDK labels Aug 25, 2026
Comment on lines +4 to +7
import httpx2
import anthropic

import opik.integrations.anthropic.stream_patchers as stream_patchers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third-party dependency in unit suite

These unit tests import and construct third-party anthropic and httpx2 objects, so they depend on external libraries and exercise the real stream object instead of isolated Opik logic — should we move this module to the Anthropic library-integration suite?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/unit/integrations/anthropic/test_stream_patchers.py` around lines
4-7, move these stream patcher regression tests out of the unit-test tree into the
Anthropic library-integration test suite because they directly import and construct the
third-party `anthropic` and `httpx2` SDK objects. Update the test location and any
imports or fixtures to follow the integration suite’s dependency guarantees and
isolation conventions, while preserving coverage for tracked and untracked stream
exception behavior.

Comment on lines +31 to +38
stream_patchers.original_stream_iter_method = underlying_iter
throwaway = mock.Mock(spec=anthropic.Stream)
stream_patchers.patch_sync_stream(
throwaway,
span_to_end=None,
trace_to_end=None,
finally_callback=finally_callback,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Five stream wrappers lack regression coverage

The regression tests install only patch_sync_stream, so they cover just anthropic.Stream.__iter__; the changed finally blocks in AsyncStream, MessageStream, AsyncMessageStream, BetaMessageStream, and BetaAsyncMessageStream remain untested, allowing a swallowed return to be reintroduced while the suite stays green. Should we add focused sync/async coverage for each wrapper family or parameterize shared cases across all patched stream classes and iteration entry points?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/unit/integrations/anthropic/test_stream_patchers.py` around lines
31-38, extend the regression coverage beyond `_install_patched_iter` and
`anthropic.Stream.__iter__`. Add focused or parameterized synchronous and asynchronous
tests for `AsyncStream`, `MessageStream`, `AsyncMessageStream`, `BetaMessageStream`, and
`BetaAsyncMessageStream`, verifying that iteration exceptions propagate and
tracked-stream cleanup callbacks run exactly once without swallowing errors. Reuse
shared fixtures/helpers where practical while covering each patched wrapper and its
actual iteration entry point.

Comment on lines +59 to +70
def test_non_tracked_stream_exception_propagates(restore_stream_iter):
"""Regression test for the `return` inside `finally` bug.

Once the opik class-level Stream.__iter__ patch is installed, a
non-opik-tracked Stream whose iteration raises used to have its exception
silently swallowed by the early `return` in the finally block. The
exception must propagate to the caller, and the opik cleanup callback must
not run for a stream opik never tracked.
"""
callback = mock.Mock()
_install_patched_iter(finally_callback=callback, underlying_iter=_raising_iter)
stream = _make_stream(tracked=False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Five wrappers lack regression coverage

The regression suite covers only patch_sync_stream/anthropic.Stream, leaving the async, message-stream, and beta-stream wrappers in stream_patchers.py without tests that untracked exceptions propagate and tracked cleanup callbacks run, so regressions there go unnoticed — should we add equivalent tests for each wrapper family, including async iteration?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/unit/integrations/anthropic/test_stream_patchers.py` around lines
59-76, extend the regression coverage beyond `patch_sync_stream`/`anthropic.Stream` to
the async, message-stream, and beta-stream wrapper families changed in
`stream_patchers.py`. Add equivalent tests for each wrapper asserting that exceptions
from untracked streams propagate without invoking cleanup, while tracked streams
propagate the exception and invoke the cleanup callback exactly once; include the
appropriate async-iteration tests for async wrappers. Reuse the existing fixtures and
helpers where possible, and ensure each changed `finally` path is observable by a test.`

@trakshan-mishra
trakshan-mishra marked this pull request as ready for review August 25, 2026 05:15
@trakshan-mishra
trakshan-mishra requested a review from a team as a code owner August 25, 2026 05:15
…pers

The initial test only covered patch_sync_stream (anthropic.Stream). Baz
review pointed out that the other five wrappers (AsyncStream,
MessageStream, AsyncMessageStream, BetaMessageStream,
BetaAsyncMessageStream) had no regression coverage — a swallowed
return could be reintroduced in any of them while the suite stayed green.

Now parameterized across all six wrappers for both sync and async,
tracked and non-tracked paths (12 test cases total). Verified:
- all 12 pass with the fix
- the 6 non-tracked tests fail on upstream (bug present)
- the 6 tracked tests pass on both (tracked path was never broken)
@trakshan-mishra

Copy link
Copy Markdown
Author

Thanks for the review @baz-reviewer. Pushed commit 437af94 addressing both points:

Third-party dependency in unit suite — the expanded tests no longer import httpx2 (use object.__new__(cls) to create uninitialized stream instances instead of constructing real ones). Keeping in tests/unit/ rather than tests/library_integration/ because:

  • The library_integration/anthropic/ suite makes real Anthropic API calls (requires ANTHROPIC_API_KEY, uses tenacity retries — see test_anthropic.py:37-41)
  • These tests mock the stream objects directly and test only Opik's patching logic — no API calls, no network
  • The anthropic import is a type reference (the patchers operate on anthropic.Stream etc.), not a live call
  • Matches the existing pattern in tests/unit/integrations/otel/ which also imports a third-party integration to test opik logic in isolation

Five stream wrappers lack regression coverage — now parameterized across all six wrappers (Stream, AsyncStream, MessageStream, AsyncMessageStream, BetaMessageStream, BetaAsyncMessageStream) for both sync/async and tracked/non-tracked paths. 12 test cases total. Verified:

  • all 12 pass with the fix
  • the 6 non-tracked tests fail on upstream (bug present), 6 tracked tests pass on both (tracked path was never broken)

Comment on lines +166 to +167
@pytest.mark.parametrize("config", _sync_wrappers(), ids=lambda c: c.id)
def test_sync_non_tracked_exception_propagates(restore_stream_patches, config):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reported test count omits parametrized cases

The PR reports pytest tests/unit/integrations/anthropic/test_stream_patchers.py — 2 passed, but _sync_wrappers() and _async_wrappers() expand the four tests to 12 cases under Anthropic 1.0.0, so could we update the testing evidence with the collected/pass count, including beta cases?

Severity web_search

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/unit/integrations/anthropic/test_stream_patchers.py` around lines
166-167, verify the parametrized sync and async wrapper tests include all base and beta
variants under Anthropic 1.0.0. Rerun the test file and correct the PR testing evidence
to report 12 collected and passed cases, retaining the beta cases in that count instead
of reporting only “2 passed.”

Comment on lines +240 to +243
callback.assert_called_once()
_, kwargs = callback.call_args
assert kwargs["capture_output"] is True
assert kwargs["error_info"] is not None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect error diagnostics go undetected

The tracked-stream test only checks that error_info exists, so a wrapper can report incorrect exception metadata and still pass — should we assert the injected RuntimeError("stream-blew-up") type, message, and diagnostic fields here and in the sync tracked test?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/unit/integrations/anthropic/test_stream_patchers.py` around lines
240-243, update `test_async_tracked_exception_propagates_and_callback_runs` to assert
that `error_info` contains the expected `RuntimeError` type, `stream-blew-up` message,
and relevant diagnostic fields, rather than only checking it is non-null. Apply the same
concrete error-metadata assertions to the synchronous tracked-stream test around lines
200-203, using the actual `error_info` structure returned by the callback.

Baz review pointed out the tracked-stream tests only checked
error_info was non-null, so a wrapper could report incorrect exception
metadata and still pass. Now asserts:
- exception_type == 'RuntimeError'
- message == 'stream-blew-up'
- traceback contains the test module name
@trakshan-mishra

Copy link
Copy Markdown
Author

Thanks @baz-reviewer — addressed both new comments in commit 4774fd9:

Reported test count (line 167) — PR testing evidence updated to report 12 passed (4 parametrized tests × 3 wrapper classes for sync + async, including beta variants).

Incorrect error diagnostics (line 243) — the tracked-stream tests now assert the error_info fields reflect the injected exception, not just that it's non-null:

  • exception_type == 'RuntimeError'
  • message == 'stream-blew-up'
  • traceback contains the test module name

Applied to both sync and async tracked-stream tests via a shared _assert_error_info_matches helper.

@trakshan-mishra

Copy link
Copy Markdown
Author

Confirmed the branch is synced with main (commit 57ef06a). Happy to answer any questions on the fix — the core issue was return inside finally swallowing the re-raised exception when opik_tracked_instance wasn't set on self, across all 6 stream wrapper variants (sync/async × plain stream / message-stream-manager / beta message-stream-manager). Let me know if you'd like anything else from me here.

@@ -0,0 +1,255 @@
from dataclasses import dataclass

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move this test to tests/library_integration/anthropic

Comment on lines +151 to +157
throwaway = object.__new__(config.patch_arg_cls)
config.patch_fn(
throwaway,
span_to_end=None,
trace_to_end=None,
finally_callback=callback,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Manager propagation remains untested

_install patches a throwaway manager while each test iterates a separate _make_stream MessageStream/beta stream directly, so __enter__/__aenter__ never transfer opik_tracked_instance, span_to_end, or trace_to_end and the real messages.create flow can skip cleanup. Could the helper return the patched manager and exercise with/async with before the raising iteration, using distinct end-candidate sentinels instead of None and asserting they reach the callback?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/library_integration/anthropic/test_stream_patchers.py` around lines
151-157, fix `_install` and the stream exception tests so they exercise the patched
manager’s `__enter__`/`__aenter__` path instead of iterating a separately allocated
stream. Have `_install` return the patched manager, use `with` or `async with` to obtain
the inner stream before triggering the raising iteration, pass distinct non-None
span/trace sentinels, and assert those sentinels are received by the cleanup callback.

The tests import and construct real `anthropic` SDK objects, so they belong
in the Anthropic library-integration suite rather than the unit tree.
@trakshan-mishra
trakshan-mishra force-pushed the trakshan-mishra/NA-anthropic-stream-finally-swallow branch from 7808b66 to 1c60894 Compare August 26, 2026 12:28
@trakshan-mishra

Copy link
Copy Markdown
Author

Moved the test to tests/library_integration/anthropic/ as requested. Passes there, 12 passed.

CI on this branch is stuck at action_required on every check because the PR is from a fork, so nothing has actually run on the new commit. Could you approve the run when you get a chance?

Also confirmed the bedrock test you couldn't run locally goes green on #8023, left the details over there.

@alexkuzmik alexkuzmik left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks @trakshan-mishra

@alexkuzmik
alexkuzmik merged commit 4b6e78a into comet-ml:main Aug 26, 2026
52 of 54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

baz: pending Python SDK python Pull requests that update Python code 🟠 size/L tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants