From 8e08272bfd94dcc43a74f48cef757148e5cae0ae Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 17 May 2026 07:19:30 +0000 Subject: [PATCH] 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. --- tests/_vcr_conftest_common.py | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/_vcr_conftest_common.py b/tests/_vcr_conftest_common.py index eecfc351fc1..23b9564032e 100644 --- a/tests/_vcr_conftest_common.py +++ b/tests/_vcr_conftest_common.py @@ -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(