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.
This commit is contained in:
Mateo Wang 2026-05-17 07:44:31 +00:00
parent 9e2e5b6bf4
commit 1c51ad1335
No known key found for this signature in database

View file

@ -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):