From 1c51ad13352c6cc1e345c5576ba93fdca933c344 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 17 May 2026 07:44:31 +0000 Subject: [PATCH] fix(tests): clear vcrpy's sticky _was_iter flag so materialized bodies stay bytes Actual root cause of the async image-edit cassette leak. The previous diagnostic run produced this dead giveaway: [vcr-episode-body-hash] ... episode[0]: body type='bytes_iterator' is not bytes/bytearray/str -- cannot hash [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 Both sides of the matcher were ``bytes_iterator`` **after** the materializer had supposedly converted them to bytes. That made no sense until I read vcrpy's ``Request`` class. vcrpy's ``Request`` keeps two private flags that are set in ``__init__`` from the original body's type and **never cleared by the setter**: def __init__(self, method, uri, body, headers): self._was_file = hasattr(body, "read") self._was_iter = _is_nonsequence_iterator(body) ... @property def body(self): if self._was_file: return BytesIO(self._body) if self._was_iter: return iter(self._body) return self._body @body.setter def body(self, value): if isinstance(value, str): value = value.encode("utf-8") self._body = value # <-- does NOT touch _was_iter / _was_file So when httpx's async transport hands vcrpy an iterator body, ``_was_iter`` becomes ``True`` and stays there forever. Even after ``_materialize_iterable_body`` writes plain bytes via ``request.body = out``, the next read of ``.body`` re-wraps the stored bytes in ``iter()`` -- producing a fresh ``bytes_iterator`` that compares unequal to any other ``bytes_iterator`` via object identity. The matcher missed every time, the cassette grew by one episode per run, and the persister saw the same iterator type when trying to hash the body for the diagnostic log. Fix: after writing the materialized bytes, also force ``_was_iter`` and ``_was_file`` to ``False``. vcrpy exposes no public API for this, so we touch the private flags directly -- acknowledged as a pragmatic test-only hack with a clear unit boundary (the only call site is ``_materialize_iterable_body``). Local repro reproduces the exact production setup: ``Request('POST', url, iter(b'multipart-content'), {})`` on two sides, runs the matcher, asserts HIT. Verified the matcher hits on identical content and still raises on differing content. Should be the last fix needed. Existing cassettes that contain oddly-shaped bodies (lists of int chunks, etc. from the previous ``_was_iter=True`` save path) still match because the materializer canonicalises both sides to bytes before comparison -- no fourth re-flush required. --- tests/_vcr_conftest_common.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/_vcr_conftest_common.py b/tests/_vcr_conftest_common.py index 84fd921d76b..78e81a0414e 100644 --- a/tests/_vcr_conftest_common.py +++ b/tests/_vcr_conftest_common.py @@ -677,6 +677,27 @@ def _materialize_iterable_body(request) -> None: except (AttributeError, TypeError): pass + # vcrpy's ``Request`` keeps two internal flags - ``_was_iter`` and + # ``_was_file`` - that are set in ``__init__`` based on the type + # of the original body and never cleared by the setter. Their job + # is to make the ``body`` *getter* re-wrap the stored value in + # ``iter()`` or ``BytesIO()`` on every access, so callers that + # expect a stream still get one even after the body has been + # consumed once. The side effect is that even after we write + # plain ``bytes`` back via ``request.body = out``, the next + # access still returns ``iter(self._body)`` - which gives every + # matcher comparison a fresh ``bytes_iterator`` and makes + # ``body_a == body_b`` an object-identity check that can never + # succeed. Touching the private flags is the only escape hatch; + # vcrpy exposes no public API for resetting them. After this + # point the body really is ``bytes`` from the getter's + # perspective. + for attr in ("_was_iter", "_was_file"): + try: + setattr(request, attr, False) + except (AttributeError, TypeError): + pass + def _key_fingerprint_matcher(r1, r2) -> None: def _fp(req):