From 9e2e5b6bf4ce895f78af16a2ab5f2f59a3c6cedd Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 17 May 2026 07:29:36 +0000 Subject: [PATCH] fix(tests): handle bytes_iterator + never leave an exhausted body Follow-up to 8e08272b. The previous attempt at coalescing iterable request bodies bailed out (``return`` without writing ``request.body``) whenever it could not classify the chunk type. That was the wrong failure mode for one critical case: vcrpy sometimes presents the body as ``iter(some_bytes)``, whose Python type is ``bytes_iterator`` and which yields ``int`` byte values (0-255), not byte chunks. The old code saw an ``int`` chunk, hit the ``else: return`` branch, and left ``request.body`` pointing at the now-exhausted iterator. The post-fix diagnostic run made this loud: [vcr-safe-body-matcher] request body mismatch body[a]: type='bytes_iterator' length=unknown sha256=N/A body[b]: type='bytes_iterator' length=unknown sha256=N/A Every async image-edit test then ballooned from entries=2 to entries=10 in that single CI run -- the exhausted iterator meant the live multipart upload went out as an empty body, OpenAI returned 400, the SDK + flaky retries fired, each retry got a fresh iterator that my hook exhausted again, and ``new_episodes`` recorded each failed attempt as a new cassette episode. This patch: * Recognizes ``bytes_iterator`` (chunks are ``int``) and reconstructs the buffer via ``bytes(chunks)``. * Keeps the existing ``list_iterator``-over-bytes-chunks handling via ``b"".join(...)``. * **Always writes a bytes value back to ``request.body`` after consuming the iterator.** If the chunk shape is unrecognized, ``request.body`` is set to ``b""`` rather than left as an exhausted iterator. That is wrong in the sense of "we lost the body" but right in the sense of "the failure mode is now visible (live API call sends empty body and fails fast) instead of invisible (corrupt cassette grows silently)". Combined with the matcher diagnostic, any future regression in this code path will surface in the CI log immediately. Local verification covers ``bytes_iterator``, ``list_iterator`` over bytes chunks, generator over bytes chunks, empty iterator, already-bytes (idempotent), identical-content iterator equality in the matcher (now matches), and differing-content iterator inequality (still raises). --- tests/_vcr_conftest_common.py | 46 ++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/tests/_vcr_conftest_common.py b/tests/_vcr_conftest_common.py index 23b9564032e..84fd921d76b 100644 --- a/tests/_vcr_conftest_common.py +++ b/tests/_vcr_conftest_common.py @@ -638,18 +638,42 @@ def _materialize_iterable_body(request) -> None: 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 + + # IMPORTANT: ``list(body)`` has already exhausted the original + # iterator. From this point we MUST write something bytes-shaped + # back to ``request.body`` -- bailing out and leaving the body as + # an exhausted iterator makes the next access (cassette + # serialization, retry replay, or the actual httpx send) see an + # empty stream. In a previous attempt at this fix the bail path + # was taken for ``bytes_iterator`` bodies (chunks were ints) and + # the live send ended up with an empty multipart upload, which + # the SDK retried until the cassette ballooned to ~10 episodes + # per test. Fall through to ``out = b""`` rather than ``return`` + # so an unrecognized chunk shape still leaves a stable body. + out = b"" + if chunks: + first = chunks[0] + if isinstance(first, int): + # ``iter(b"...")`` yields integer byte values (its type + # name is ``bytes_iterator``). ``bytes(list_of_ints)`` is + # the inverse and reconstructs the original buffer. + try: + out = bytes(chunks) + except (TypeError, ValueError): + out = b"" + elif isinstance(first, (bytes, bytearray)): + try: + out = b"".join(c if isinstance(c, bytes) else bytes(c) for c in chunks) + except (TypeError, ValueError): + out = b"" + elif isinstance(first, str): + try: + out = "".join(chunks).encode("utf-8") + except (TypeError, ValueError): + out = b"" + try: - request.body = bytes(out) + request.body = out except (AttributeError, TypeError): pass