Skip to content

Commit e86691e

Browse files
pylaterreurclaude
andcommitted
test: bump vcrpy to 8.5.0 and drop the aiohttp stub patches it fixes
vcrpy 8.5.0 includes kevin1024/vcrpy#1055, which fixes kevin1024/vcrpy#502 and kevin1024/vcrpy#927: a response streamed while recording keeps its body, a replayed response keeps a single aiohttp StreamReader as its content, so google-genai's readline() loop ends, and closing the response no longer poisons that stream. Since #834, conftest.py has skipped its patches for those (problems 1, 3 and 4) on such releases. Require vcrpy 8.5.0 in the test group, lock it, and remove them, along with the shims only older vcrpy needed: the AsyncStreamReaderMixin placeholder, the stream_writer constructor and MockStream.iter_chunked. The gzip decoding in text() and read() stays. Locking 8.5.0 needs uv's exclude-newer cutoff past its upload (2026-10-25T20:07Z), so the cutoff moves to 2026-10-25T20:08:00Z. uv lock changes nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwUvhmzgfkiZ6vo4uGyesN
1 parent dc851fe commit e86691e

3 files changed

Lines changed: 62 additions & 136 deletions

File tree

‎py/pyproject.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ test = [
117117
"pytest==9.1.1",
118118
"pytest-asyncio==1.3.0",
119119
"pytest-vcr==1.0.2",
120+
# conftest.py relies on vcrpy 8.5.0's aiohttp stream fixes (kevin1024/vcrpy#1055).
121+
"vcrpy>=8.5.0",
120122
]
121123

122124
# -- Session-specific auxiliary deps -------------------------------------------
@@ -412,7 +414,7 @@ explicit = true
412414
# ---------------------------------------------------------------------------
413415

414416
[tool.uv]
415-
exclude-newer = "2026-10-20T19:24:36Z"
417+
exclude-newer = "2026-10-25T20:08:00Z"
416418
conflicts = [
417419
# Groups that pin or transitively require conflicting openai versions.
418420
[

‎py/src/braintrust/conftest.py‎

Lines changed: 5 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -33,100 +33,28 @@ def pytest_make_collect_report(collector):
3333

3434

3535
def _patch_vcr_aiohttp_stubs():
36-
"""Patch VCR.py's aiohttp stubs to fix bugs with google-genai >= 1.64.0 and litellm.
37-
38-
Problems fixed:
39-
1. Infinite loop: VCR's MockClientResponse.content is a @property that creates
40-
a new MockStream on every access. google-genai reads streaming responses via
41-
`while True: await response.content.readline()`, creating a fresh stream each
42-
iteration that never reaches EOF. Fix: cache the stream per instance.
43-
44-
2. Gzip decoding: Cassettes store gzip-compressed response bodies (from
45-
Accept-Encoding: gzip). VCR's httpx stubs handle decompression, but the
46-
aiohttp stubs return raw gzip bytes, causing UnicodeDecodeError.
47-
Fix: decompress gzip in text(), read(), and the content stream.
48-
49-
3. set_exception: aiohttp's close() sets a ClientConnectionError on the content
50-
stream, which then raises on subsequent reads. Fix: no-op set_exception on
51-
MockStream.
52-
53-
4. Body consumed during recording: VCR's record_response() calls
54-
`await response.read()` which consumes the real response body. When
55-
litellm's aiohttp transport then tries to stream the response, the body
56-
is empty. Fix: after reading the body for recording, reset the response's
57-
content stream so it can be read again by the caller.
58-
59-
VCR.py releases with kevin1024/vcrpy#1055 fix 1, 3 and 4 themselves, so the
60-
patches for those (including gzip decoding of the content stream) only apply
61-
to older releases. Delete them once uv.lock has such a release.
62-
63-
See: https://github.com/kevin1024/vcrpy/issues/927
36+
"""Patch VCR.py's aiohttp stubs to decompress gzip response bodies.
37+
38+
Cassettes store gzip-compressed response bodies (from Accept-Encoding: gzip).
39+
VCR's httpx stubs handle decompression, but the aiohttp stubs return raw gzip
40+
bytes, causing UnicodeDecodeError. Fix: decompress gzip in text() and read().
6441
"""
6542
try:
66-
from aiohttp import streams
67-
68-
# VCR.py imports this aiohttp internals symbol at module import time, but
69-
# newer aiohttp releases removed it. Provide a no-op compatibility shim
70-
# before importing vcr.stubs.aiohttp_stubs so test collection does not fail.
71-
if not hasattr(streams, "AsyncStreamReaderMixin"):
72-
setattr(streams, "AsyncStreamReaderMixin", object)
73-
7443
from vcr.stubs import aiohttp_stubs
7544
except ImportError:
7645
return
7746

7847
if getattr(aiohttp_stubs.MockClientResponse, "_bt_patched", False):
7948
return
8049

81-
import asyncio
8250
import gzip
83-
import inspect
84-
85-
from aiohttp import ClientConnectionError, ClientResponse, streams
86-
from aiohttp.helpers import TimerNoop
8751

8852
def _decompress_body(body):
8953
"""Decompress gzip body if needed."""
9054
if body and body[:2] == b"\x1f\x8b":
9155
return gzip.decompress(body)
9256
return body
9357

94-
if "stream_writer" in inspect.signature(ClientResponse.__init__).parameters:
95-
# aiohttp 3.13 added a required stream_writer keyword argument to
96-
# ClientResponse.__init__. VCR.py's MockClientResponse still calls the
97-
# older constructor, so patch it before any cassette playback builds one.
98-
class _MockStreamWriter:
99-
output_size = 0
100-
101-
def patched_response_init(self, method, url, request_info=None):
102-
super(aiohttp_stubs.MockClientResponse, self).__init__(
103-
method=method,
104-
url=url,
105-
writer=None,
106-
continue100=None,
107-
timer=TimerNoop(),
108-
request_info=request_info,
109-
traces=[],
110-
loop=asyncio.get_event_loop(),
111-
session=None,
112-
stream_writer=_MockStreamWriter(),
113-
)
114-
115-
aiohttp_stubs.MockClientResponse.__init__ = patched_response_init
116-
117-
if not hasattr(aiohttp_stubs.MockStream, "iter_chunked"):
118-
# Older aiohttp exposed iter_chunked via AsyncStreamReaderMixin. Since
119-
# that mixin is gone, VCR.py's MockStream needs the method directly for
120-
# clients that consume aiohttp responses through chunked iteration.
121-
async def iter_chunked(self, n):
122-
while True:
123-
chunk = await self.read(n)
124-
if not chunk:
125-
break
126-
yield chunk
127-
128-
aiohttp_stubs.MockStream.iter_chunked = iter_chunked
129-
13058
async def patched_text(self, encoding="utf-8", errors="strict"):
13159
return _decompress_body(self._body).decode(encoding, errors=errors)
13260

@@ -138,60 +66,6 @@ async def patched_read(self):
13866
aiohttp_stubs.MockClientResponse.read = patched_read
13967
aiohttp_stubs.MockClientResponse._bt_patched = True
14068

141-
if issubclass(aiohttp_stubs.MockStream, streams.StreamReader):
142-
# VCR.py with kevin1024/vcrpy#1055 (MockStream is then aiohttp's StreamReader)
143-
# fixes 1, 3 and 4 itself, and MockStream() without the body raises there.
144-
return
145-
146-
aiohttp_stubs.MockStream.set_exception = lambda self, exc: None
147-
148-
@property
149-
def cached_content(self):
150-
if not hasattr(self, "_cached_content"):
151-
stream = aiohttp_stubs.MockStream() # pylint: disable=no-value-for-parameter
152-
stream.feed_data(_decompress_body(self._body))
153-
stream.feed_eof()
154-
self._cached_content = stream
155-
return self._cached_content
156-
157-
aiohttp_stubs.MockClientResponse.content = cached_content
158-
159-
# Patch record_response to not consume the response body. VCR's original
160-
# implementation calls `await response.read()` which exhausts the body,
161-
# making it unavailable for the actual caller (e.g. litellm's aiohttp
162-
# transport). We read the body, record it, then reset the content stream
163-
# so downstream consumers can still read it.
164-
_original_record_response = aiohttp_stubs.record_response
165-
166-
async def _patched_record_response(cassette, vcr_request, response):
167-
try:
168-
body_bytes = await response.read()
169-
except ClientConnectionError:
170-
body_bytes = b""
171-
172-
vcr_response = {
173-
"status": {"code": response.status, "message": response.reason},
174-
"headers": aiohttp_stubs._serialize_headers(response.headers),
175-
"body": {"string": body_bytes} if body_bytes else {},
176-
}
177-
cassette.append(vcr_request, vcr_response)
178-
179-
# Reset the response content stream so the caller can still read it.
180-
# aiohttp's ClientResponse stores the payload in response.content which
181-
# is a StreamReader. After read() exhausts it, we replace it with a new
182-
# stream containing the same data.
183-
# Use a limit larger than the captured body so StreamReader.feed_data()
184-
# does not try to pause the original aiohttp parser. Large responses
185-
# (for example image-generation payloads) can otherwise trip an
186-
# assertion inside aiohttp's parser when VCR records the response.
187-
stream_limit = max(2**16, len(body_bytes) + 1)
188-
new_stream = streams.StreamReader(response._protocol, stream_limit, loop=asyncio.get_event_loop())
189-
new_stream.feed_data(body_bytes)
190-
new_stream.feed_eof()
191-
response.content = new_stream
192-
193-
aiohttp_stubs.record_response = _patched_record_response
194-
19569

19670
_patch_vcr_aiohttp_stubs()
19771

0 commit comments

Comments
 (0)