Skip to content

[NA] [SDK] fix: stop bedrock and mistral stream wrappers from swallowing exceptions in finally - #8023

Open
alexkuzmik wants to merge 1 commit into
mainfrom
aliaksandrk/NA-fix-return-in-finally-swallowing-errors
Open

[NA] [SDK] fix: stop bedrock and mistral stream wrappers from swallowing exceptions in finally#8023
alexkuzmik wants to merge 1 commit into
mainfrom
aliaksandrk/NA-fix-return-in-finally-swallowing-errors

Conversation

@alexkuzmik

@alexkuzmik alexkuzmik commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Details

The bedrock and mistral stream wrappers install class-level patches and used an early return inside a finally block to skip cleanup for non-tracked streams. A return in finally discards any in-flight exception, so once a tracked call installed the patch, non-tracked streams that errored mid-iteration completed silently. Fix: invert the guard so cleanup nests under if hasattr(...) and no return remains in any finally — tracked streams behave exactly as before.

  • Bedrock is worse than a lost exception — it silently corrupts data. The patch target is botocore.response.StreamingBody.read, botocore's shared class used by every boto3 response body, and the try block does return result. A return None in finally overrides the returned value, so after one traced invoke_model call every non-tracked read() in the process — an S3 object, a Lambda payload — handed back None instead of its payload:
    === BEFORE opik patches anything ===
    read() -> b'hello-s3-object'
    === AFTER opik traced ONE bedrock call ===
    tracked read()   -> b'{"completion": "hi"}'
    unrelated read() -> None          <<< payload silently replaced with None
    
  • Mistral patches __iter__/__aiter__ on the stream's class; these are generators with no return value to clobber, so the impact is exception swallowing only.

This is the same bug and the same fix that #7981 applies to the anthropic integration — credit to @trakshan-mishra for finding the pattern there. I found these two while reviewing that PR. The two PRs touch disjoint files, so they can merge in either order. After both land, an AST scan over sdks/python/src/opik finds no return/break/continue in any finally block.

Change checklist

  • User facing
  • Documentation update

Issues

  • Resolves #

AI-WATERMARK

AI-WATERMARK: yes

Testing

  • Commands run:
    • pytest tests/library_integration/mistral/18 passed (15 existing + 3 new), real Mistral API
    • pre-commit run --files <4 changed files> → ruff, ruff-format, mypy, whitespace hooks all Passed
  • Regression proof (mistral): with the fix reverted and the tests unchanged, the two untracked_stream_fails_after_class_patched tests fail (exception swallowed) and pass again with the fix. The tracked_stream_fails_mid_iteration test passes both ways by design — it guards the tracked path against regressions in the refactor.
  • Bedrock data-loss bug reproduced directly against real botocore.response.StreamingBody objects (output above); verified fixed by the same script.
  • Test placement: both tests extend the existing library-integration suites. The mistral ones make real streaming calls, then inject a deterministic mid-stream failure by swapping EventStream.generator (which is what __next__ pulls from) on a genuine stream object — a real dropped-connection stands in without monkeypatching the patched class.
  • Not verified locally: test_bedrock_invoke_model__untracked_client_read_after_tracked_call__payload_returned needs real AWS Bedrock credentials, which I don't have — the AWS_PROFILE here fails with PartialCredentialsError. It collects cleanly and is modelled on the existing verified ..._anthropic___happyflow test in the same file (same client, model, and request body); the mechanism it asserts is the one reproduced above. Please confirm it goes green in CI.

Documentation

No documentation changes needed — this is an internal bug fix with no public API or behaviour change for correctly-tracked streams.

…ing exceptions in finally

The bedrock and mistral stream wrappers install class-level patches and used
an early `return` inside a `finally` block to skip cleanup for non-tracked
streams. A `return` in `finally` discards any in-flight exception, so once a
tracked call installed the patch, non-tracked streams that errored completed
silently.

Bedrock is worse than a lost exception. The patch target is
`botocore.response.StreamingBody.read` - botocore's shared class, used by
every boto3 response body - and the try block does `return result`, so the
`finally`'s `return None` overrides the returned value. After one traced
invoke_model call, every non-tracked `read()` in the process (an S3 object, a
Lambda payload) handed back None instead of its payload.

Fix: invert the guard so cleanup nests under `if hasattr(...)` and no `return`
remains in any `finally`. Tracked streams behave exactly as before.

This is the same bug and the same fix that #7981 applies to the anthropic
integration; credit to @trakshan-mishra for finding the pattern there. The two
PRs touch disjoint files.

Co-Authored-By: trakshan-mishra <43599000+trakshan-mishra@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alexkuzmik
alexkuzmik requested a review from a team as a code owner August 26, 2026 11:59
@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 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📋 PR Linter Failed

Missing Section. The description is missing the ## Documentation section.

@github-actions

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🐍 mypy — python sdk Static type check 1.20s
🐍 fix end of files — python sdk Ensure files end in a newline 0.03s
🐍 trim trailing whitespace — python sdk Strip trailing whitespace 0.03s
🐍 ruff-format — python sdk Format Python code (ruff) 0.01s
🐍 ruff — python sdk Lint + autofix Python (ruff) 0.01s
Total (5 ran) 1.28s
⏭️ 39 skipped (no matching files changed)
Hook Description Result
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
⚓ helm-docs Regenerate Helm chart README ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
☕ spotless — java backend Format Java code ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
🌐 eslint — frontend Lint + autofix JS/TS ⏭️
🌐 typecheck — frontend Whole-project tsc type check ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows ⏭️
🛡️ semgrep — java backend sql Block SQL injection-prone string formatting ⏭️

@CometActions

Copy link
Copy Markdown
Collaborator

Already covered by a test in this PR.

You shipped the regression tests with the fix, and they are the right ones: the bedrock test asserts an untracked StreamingBody.read() returns its payload (the old return None in finally overrode the try's return), and the two mistral tests assert an untracked stream that fails mid-iteration still raises (the old return discarded the in-flight exception). All three fail against the pre-fix code, and both suites run in CI with real keys via lib-bedrock-tests.yml / lib-mistral-tests.yml. Nothing for the Playwright estate to add on top — the blast radius is in the user's own process, and the tracked path is byte-identical before and after, so no trace or span renders differently in Opik. One half you may want to cover while you are here: on bedrock, the old return None also swallowed exceptions raised by an untracked read(), not just the return value — your mistral tests cover the swallowed-exception half, the bedrock one only covers the payload half.

also touches Python SDK

Run

Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review.

Comment on lines +256 to +258
def failing_generator():
yield from ()
raise RuntimeError("stream-blew-up")

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.

Partial stream failure remains untested

The sync and async failing_generator() helpers yield no event, so the first pull raises before any chunk can be aggregated or delivered and the tests miss partial aggregation and post-delivery cleanup — should we yield a representative event before raising and assert that the caller receives it?

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/mistral/test_mistral.py` around lines 256-258 and
317-320, update the synchronous and asynchronous `failing_generator` helpers used by the
stream regression tests so each yields one representative event before raising
`RuntimeError`. Modify the corresponding tracked and untracked stream assertions around
lines 274-332 to record and verify that the event was received before the failure,
ensuring partial aggregation, delivery, and post-delivery cleanup are exercised.

Comment on lines +263 to +274
def test_mistral_chat_stream__untracked_stream_fails_after_class_patched__error_propagates(
fake_backend,
):
"""Regression test for the `return` inside `finally`.

opik patches ``__iter__`` on mistralai's stream class, so once any tracked
stream has been consumed every stream in the process runs through the
wrapper - including streams from untracked clients. A `return` in `finally`
swallowed the in-flight exception, so an untracked stream that failed
mid-iteration finished silently instead of raising.
"""
tracked_client = track_mistral(

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.

Live API calls make failure tests flaky

The wrapper failure tests make live Mistral requests before mutating the SDK stream’s private generator, so they require credentials and provider latency and can fail before exercising the assertion. Could we use a deterministic fake stream/event source for these paths and keep one minimal integration test for patch wiring?

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/mistral/test_mistral.py around lines 263-286,
refactor the untracked-stream failure test and the related async and tracked tests below
it to avoid real Mistral network requests and mutation of the SDK stream’s private
`generator`. Use small deterministic fake synchronous and asynchronous stream/event
sources that raise mid-iteration, while preserving assertions for exception propagation,
tracking behavior, and logged error information. Keep only one minimal integration test,
if needed, to verify the wrapper is installed on the real Mistral stream class.

Comment on lines +675 to +684
tracked_response = tracked_client.invoke_model(
modelId=ANTHROPIC_MODEL,
body=json.dumps(request_body),
contentType="application/json",
accept="application/json",
)
assert json.loads(tracked_response["body"].read())

untracked_client = boto3.client("bedrock-runtime", region_name="us-east-1")
untracked_response = untracked_client.invoke_model(

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.

Network dependency slows and destabilizes CI

This regression test makes two live Bedrock invoke_model calls to verify StreamingBody.read() after patching, so it depends on AWS credentials and service availability and cannot run locally; should we use local StreamingBody/response objects or a deterministic mock, keeping only a minimal wiring check if needed?

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/bedrock/test_invoke_model.py` around lines
675-684, refactor
`test_bedrock_invoke_model__untracked_client_read_after_tracked_call__payload_returned`
so it does not issue live Bedrock `invoke_model` requests. Construct a local
`StreamingBody` and mocked response objects, invoke the patched `read` after exercising
the tracked-client setup, and assert that the untracked payload is returned while only
the tracked call is logged; retain a minimal mocked wiring check if needed.

@trakshan-mishra

Copy link
Copy Markdown
Contributor

Confirmed green. The bedrock test you couldn't run locally passed on all five jobs (3.10–3.14):

test_invoke_model.py::test_bedrock_invoke_model__untracked_client_read_after_tracked_call__payload_returned PASSED [100%]
============================= 25 passed in 32.05s ==============================

Mistral job passed too.

On the AST scan at the end of your description: ruff already ships this as B012, but sdks/python/.ruff.toml selects only ["E4", "E7", "E9", "F"], so bugbear never runs. I pointed it at my branch (anthropic already fixed there) and it finds exactly the three sites this PR fixes, nothing else:

src/opik/integrations/bedrock/invoke_model/stream_wrappers.py:48:17: B012
src/opik/integrations/mistral/stream_patchers.py:55:17: B012
src/opik/integrations/mistral/stream_patchers.py:111:17: B012

So B012 would go clean the moment this merges, and the next one gets caught in pre-commit rather than in someone's S3 payload. Happy to open a small separate PR adding it if you think it's worth having — didn't want to bolt it onto either of these.

Two failures here that aren't your code, in case they're useful: PR Linter wants a ## Documentation section in the description, and E2E Lib Integration Python 3.11 died at "Run latest Opik server" with opik-clickhouse-init-1 exiting, which looks like infra rather than anything you touched.

@petrotiurin petrotiurin left a comment

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.

Looks good!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Python SDK python Pull requests that update Python code 🟡 size/M tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants