mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 09:25:14 +03:00
fix(anthropic): stop leaked VCR httpx patch from failing live tests
vcrpy skips its httpx un-patch step when saving a cassette raises during teardown, leaving httpx.HTTPTransport patched for the rest of the worker session. Later live integration tests then replay against the wrong cassette and fail with CannotOverwriteExistingCassetteException / APIConnectionError. Add an autouse fixture that restores pristine httpx transports around non-VCR tests so a leak can't cascade. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
1 parent
98216c0c1d
commit
440c0765b3
2 files changed
+111
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user