diff --git a/libs/partners/anthropic/tests/conftest.py b/libs/partners/anthropic/tests/conftest.py index 26d854f671..0a7e2b83a7 100644 --- a/libs/partners/anthropic/tests/conftest.py +++ b/libs/partners/anthropic/tests/conftest.py @@ -1,9 +1,54 @@ +from collections.abc import Iterator from typing import Any +import httpx import pytest from langchain_tests.conftest import CustomPersister, CustomSerializer, base_vcr_config from vcr import VCR # type: ignore[import-untyped] +# Pristine, unpatched httpx transport handlers captured at import time (before +# any cassette is installed). VCR monkeypatches these while a cassette is +# active and restores them on teardown -- unless saving the cassette raises, +# in which case `vcr.cassette.CassetteContextDecorator.__exit__` skips its +# un-patch step and leaves the transport patched. That leak then intercepts +# every later request in the same worker (e.g. live integration tests get a +# stale cassette and fail with confusing connection errors). +_PRISTINE_HTTPX_TRANSPORTS = { + (httpx.HTTPTransport, "handle_request"): httpx.HTTPTransport.handle_request, + ( + httpx.AsyncHTTPTransport, + "handle_async_request", + ): httpx.AsyncHTTPTransport.handle_async_request, +} + + +def _restore_httpx_transports() -> None: + """Undo any VCR monkeypatching left behind on httpx transports.""" + for (cls, attribute), original in _PRISTINE_HTTPX_TRANSPORTS.items(): + if getattr(cls, attribute) is not original: + setattr(cls, attribute, original) + + +@pytest.fixture(autouse=True) +def _guard_httpx_transport_patches( + request: pytest.FixtureRequest, +) -> Iterator[None]: + """Stop leaked VCR cassette patches from breaking live tests. + + A `@pytest.mark.vcr` test whose cassette fails to save can leave httpx + patched for the rest of the worker session. Tests that hit the network + (those without a `vcr` marker) then replay against the wrong cassette and + fail. Restoring the pristine transports around every non-VCR test keeps + such a leak from cascading, without interfering with VCR's own patching + during cassette-backed tests. + """ + uses_vcr = request.node.get_closest_marker("vcr") is not None + if not uses_vcr: + _restore_httpx_transports() + yield + if not uses_vcr: + _restore_httpx_transports() + def remove_request_headers(request: Any) -> Any: for k in request.headers: diff --git a/libs/partners/anthropic/tests/unit_tests/test_vcr_patch_leak.py b/libs/partners/anthropic/tests/unit_tests/test_vcr_patch_leak.py new file mode 100644 index 0000000000..4922f3a1b3 --- /dev/null +++ b/libs/partners/anthropic/tests/unit_tests/test_vcr_patch_leak.py @@ -0,0 +1,66 @@ +"""Regression tests for the httpx transport guard in `tests/conftest.py`. + +vcrpy leaves httpx patched if saving a cassette raises during teardown, which +otherwise leaks the patch into unrelated (live) tests running on the same +worker. The `_restore_httpx_transports` helper and its autouse fixture undo +that leak so it can't cascade. +""" + +from pathlib import Path + +import httpx +import vcr # type: ignore[import-untyped] +from vcr.persisters.filesystem import ( # type: ignore[import-untyped] + CassetteNotFoundError, +) + +from tests.conftest import _restore_httpx_transports + + +class _BoomPersister: + """Persister that fails to load and errors while saving a cassette.""" + + @staticmethod + def load_cassette(*_args: object, **_kwargs: object) -> tuple[list, list]: + raise CassetteNotFoundError + + @staticmethod + def save_cassette(*_args: object, **_kwargs: object) -> None: + msg = "serialization boom" + raise RuntimeError(msg) + + +def _ok_response(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="ok") + + +def test_restore_httpx_transports_is_a_noop_when_unpatched() -> None: + original = httpx.HTTPTransport.handle_request + _restore_httpx_transports() + assert httpx.HTTPTransport.handle_request is original + + +def test_restore_httpx_transports_reverts_leaked_vcr_patch(tmp_path: Path) -> None: + original_sync = httpx.HTTPTransport.handle_request + original_async = httpx.AsyncHTTPTransport.handle_async_request + + my_vcr = vcr.VCR(record_mode="once") + my_vcr.register_persister(_BoomPersister()) + + # Recording a new interaction makes the cassette dirty; the failing save + # then trips the vcrpy bug that skips un-patching httpx on teardown. + try: + with my_vcr.use_cassette(str(tmp_path / "leak.yaml")): + transport = httpx.MockTransport(_ok_response) + with httpx.Client(transport=transport) as client: + client.get("http://example.test/") + except RuntimeError: + pass + + # The leak is real: httpx is still patched by VCR. + assert httpx.HTTPTransport.handle_request is not original_sync + + _restore_httpx_transports() + + assert httpx.HTTPTransport.handle_request is original_sync + assert httpx.AsyncHTTPTransport.handle_async_request is original_async