fix(tests): coalesce iterable request bodies before matching/recording

Root cause of the residual async image-edit cassette leak. The
diagnostic run for ``ba3915d9`` printed:

  [vcr-safe-body-matcher] request body mismatch
    body[a]: type='list_iterator' length=unknown sha256=N/A
    body[b]: type='list_iterator' length=unknown sha256=N/A

httpx's async transport hands vcrpy a ``request.body`` that is a
``list_iterator`` over multipart chunks rather than a contiguous
``bytes`` blob. Two consequences:

1. ``_safe_body_matcher`` compares the two iterator objects with
   ``==``, which is identity comparison for arbitrary iterators -
   semantically identical multipart bodies never compare equal, and
   ``record_mode="new_episodes"`` appends a new episode on every CI
   run until the cassette crosses ``MAX_EPISODES_PER_CASSETTE`` and
   the persister refuses to save (this is exactly what the OVERFLOW
   warning has been catching).
2. ``_normalize_multipart_boundary`` short-circuits its
   ``else: return`` branch because the body is neither bytes nor
   str, so any residual random boundary characters in the body bytes
   are never rewritten.

Sync requests do not hit this code path: httpx's sync transport
hands vcrpy a single ``bytes`` body, so ``==`` works and the
boundary normalizer runs as intended. That is why
``test_openai_image_edit_litellm_sdk[True]`` records to ``entries=1``
and replays cleanly while ``[False]`` (async) kept growing by one
episode per run.

Fix: add ``_materialize_iterable_body`` which coalesces an iterable
``request.body`` into ``bytes`` in-place. Call it from two places:

* The top of ``_before_record_request``, so the boundary normalizer
  and the cassette serializer both see bytes from then on.
* The top of ``_safe_body_matcher``, as defense in depth in case a
  future vcrpy code path invokes the matcher without first going
  through ``_before_record_request``.

The vcrpy ``Request`` is a wrapper used for matching and recording;
the underlying httpx transport sends its own request body
separately, so replacing the iterator on the vcrpy wrapper does
not starve the live HTTP send.

After this lands the async parametrizes should flip from
``[VCR MISS:RECORDED] entries=N+1`` to ``[VCR HIT] entries=N`` on
the next CI run, matching the sync side and dropping the residual
~$3/day to $0.
This commit is contained in:
Mateo Wang 2026-05-17 07:19:30 +00:00
parent 85430bc01f
commit 8e08272bfd
No known key found for this signature in database

View file

@ -323,6 +323,13 @@ def _safe_body_matcher(r1, r2) -> None:
object (e.g. an httpx ``MultipartStream`` for async requests) look
indistinguishable from genuine content drift.
"""
# Defense in depth: ``_before_record_request`` already coalesces
# iterable bodies, but if vcrpy invokes the matcher on a request
# that did not flow through that hook (or if a future code path
# bypasses it), do it again here so iterator==iterator never
# silently fails.
_materialize_iterable_body(r1)
_materialize_iterable_body(r2)
body1 = getattr(r1, "body", None)
body2 = getattr(r2, "body", None)
if body1 == body2:
@ -586,6 +593,7 @@ def _before_record_request(request):
headers = getattr(request, "headers", None)
if headers is None:
return request
_materialize_iterable_body(request)
if not any(_iter_header_values(headers, KEY_FINGERPRINT_HEADER)):
fingerprint = _compute_key_fingerprint(request)
try:
@ -597,6 +605,55 @@ def _before_record_request(request):
return request
def _materialize_iterable_body(request) -> None:
"""Coalesce an iterable / generator request body into ``bytes`` in-place.
httpx's async transport hands vcrpy a ``request.body`` that is a
``list_iterator`` (or generator) over the multipart chunks rather
than a contiguous ``bytes`` object. Two consequences fall out:
1. ``_safe_body_matcher`` compares the two iterator objects with
``==``, which is identity comparison for arbitrary iterators -
so two semantically identical bodies never match and
``record_mode="new_episodes"`` appends a fresh episode every
run until the cassette hits ``MAX_EPISODES_PER_CASSETTE`` and
the persister refuses to save.
2. ``_normalize_multipart_boundary`` falls through its
``else: return`` branch because the body is not bytes/str, so
the random multipart boundary in the body is never rewritten.
Materializing the iterator once - and writing the result back to
``request.body`` so downstream uses see bytes - fixes both bugs.
The vcrpy ``Request`` is a wrapper that vcrpy uses for matching
and recording; the underlying httpx transport sends its own
request body separately, so replacing the iterator here does not
starve the live HTTP send.
"""
body = getattr(request, "body", None)
if body is None or isinstance(body, (bytes, bytearray, str)):
return
if not hasattr(body, "__iter__"):
return
try:
chunks = list(body)
except TypeError:
return
out = bytearray()
for chunk in chunks:
if isinstance(chunk, (bytes, bytearray)):
out.extend(chunk)
elif isinstance(chunk, str):
out.extend(chunk.encode("utf-8"))
else:
# Heterogeneous, non-text/binary chunk - bail rather than
# silently corrupt the body.
return
try:
request.body = bytes(out)
except (AttributeError, TypeError):
pass
def _key_fingerprint_matcher(r1, r2) -> None:
def _fp(req):
for value in _iter_header_values(