Skip to content

Shrinking a 65MB vcrpy cassette in place: three hook semantics the docs don't spell out

A pytest end-to-end test recording an LLM news pipeline had grown a 65.4MB cassette with 394 interactions. Composition, measured: 36MB of full article HTML (the app only ever reads resp.text[:500_000], but vcrpy records the whole body, and the same pages get re-fetched for link-liveness checks), 9.8MB of base64 image data from an image-generation endpoint, and 10.7MB of multipart upload request bodies serialized as !!python/object/new:_io.BytesIO tags. Only ~9MB was text any assertion actually reads. Re-recording was not an option: the test pins assertions to a fixed date, so a fresh record would fetch today's news and invalidate them. Everything had to be an in-place rewrite of the recorded YAML plus record-time hooks so future re-records stay small.

Deduplication is the obvious first idea and it is wrong. 394 interactions collapsed to far fewer unique (method, uri, request-body) groups, and dropping the duplicates looks free. It is not: vcrpy's default match_on is ('method', 'scheme', 'host', 'port', 'path', 'query') — the body is absent. Every request to a given URL matches every recorded interaction for that URL, and with allow_playback_repeats=False vcrpy hands out the first unplayed match, i.e. responses replay in strict recorded order. In this cassette 13 duplicate groups had differing responses in sequence (LLM retries with identical prompts, repeated GETs whose content changed). Dropping duplicates or enabling allow_playback_repeats would silently replay the wrong response mid-sequence. See vcrpy 8.3.0 pytest nondeterministically fails replay with concurrent DSPy LLM calls after adding new signature for the nastier version of this failure mode under concurrency. The corollary that makes shrinking tractable: since bodies are never matched, you can blank a request body to zero length and replay is completely unaffected. All shrinking must be per-interaction body transforms; the interaction count and order stay fixed.

before_record_request also runs during replay, despite the name. In vcrpy 8.x, Cassette.can_play_response_for and Cassette.play_response both pass the outgoing request through self._before_record_request before matching. So a hook written as a record-time filter executes on every request in record_mode='none' too. Two consequences. First, a hook that returns None (vcrpy's "ignore this request" contract) makes the request unplayable during replay, not just unrecorded — that is usually what you want for storage/localhost traffic, but it is a live code path in every test run, not dormant. Second, any mutation the hook performs must be stable: it runs against the live request at replay time and against the recorded request at record time, and if those diverge the URL match still succeeds (bodies aren't matched) but you have introduced a difference that only shows up if you later add a body matcher.

pytest-recording merges @pytest.mark.vcr(**kwargs) over the vcr_config fixture dict, and the marker wins per key — it replaces, it does not compose. This is the trap when you opt individual tests into extra hooks. A session-scoped vcr_config fixture commonly carries a before_record_request that filters out object-storage and localhost hosts. The moment one test adds its own before_record_request via the marker, the fixture's filter is gone for that test, silently — no error, and it may not even fail, since ignored hosts often also appear in ignore_hosts. The fix is to hoist the fixture's filter to a module-level function and have the per-test hook call it last, so the opt-in hook is strictly additive:

def _filter_storage_request(request):
    for host in _ignore_hosts():
        if host in request.uri:
            return None
    return request

def shrink_vcr_request(request):
    if 'googleapis.com/upload/' in request.uri:
        request.body = b''          # bodies are never matched
    return _filter_storage_request(request)   # compose, don't clobber

Keep Content-Length in sync when you mutate a recorded response body. vcrpy does this itself: vcr.filters.decode_response decompresses the body, deletes content-encoding, and then sets headers['content-length'] = [str(len(new_body))]. If your own before_record_response truncates a body and leaves the recorded Content-Length at the original value, the stored length no longer describes the stored bytes — and on replay the response is served through urllib3, which defaults enforce_content_length=True. Mirror what decode_response does, and compute the length in bytes, not characters: truncating a str body and writing len(text) is wrong for any non-ASCII page.

def _set_content_length(headers, body):
    length = len(body if isinstance(body, bytes) else body.encode('utf-8'))
    for key, val in headers.items():
        if key.lower() == 'content-length':
            headers[key] = [str(length)] if isinstance(val, list) else str(length)

Rewriting the existing YAML. Load and dump through vcr.serializers.yamlserializer rather than yaml.safe_load. vcrpy serializes with yaml.dump(..., Dumper=CDumper) and reads back with yaml.load(..., Loader=CLoader) — the full constructor, not the safe one. That is why file-like request bodies land in the cassette as !!python/object/new:_io.BytesIO tags, which yaml.safe_load refuses outright. Round-tripping through vcrpy's own serializer constructs those objects transparently; blanking the upload bodies then removes the unsafe tags as a side effect, leaving pure safe YAML.

Outcome: 65.4MB to 22.5MB with all 394 interactions preserved in order, a second cassette 4.4MB to 1.7MB, replay green locally and in CI with record_mode='none'. Truncating HTML at 200_000 characters was the one genuinely risky transform — the pipeline scans page text for numeric figures and for deletion markers, either of which could have sat past the cut — so pick a limit, run the suite, and be ready to raise it. Record-time hooks cannot be exercised without spending real API calls, so the meaningful proof is that the same functions rewrote the committed cassettes and the suite replays green against the result.

No signals yet