Research: HTTP cassette/replay layer — mechanism PoC (BK-181)¶
Date: 2026-05-14
Status: PoC complete. Verdict: GO with pytest-recording plus an
async transport shim. Feeds the BK-181 implementation.
Scope: A deliberately minimal spike answering the one open question in
BK-181: can pytest-recording/vcrpy record the real Azure
SDK HTTP traffic and replay it at zero cost — sync and async — with
credentials scrubbed? The alternative the backlog names is a custom
azure.core transport adapter.
Related: BK-181, spec 048
(TEST-007/008/009), ADR-0028,
BUG-197, the spike folder bk-181-poc/.
Context. BK-181 demotes live-only Azure HNS contracts to a zero-cost
Stage 1 <backend>_replay fixture: Stage 3 records a cassette, every
default CI run replays it. It is the foundation for turning the open HNS
defects (the BUG-195..203 family, each "surfaced only by the Stage 3
azure_live run") into always-on regression guards. The backlog left the
recording mechanism unchosen and flagged async-pipeline coverage and
scrubbing complexity as the axes to benchmark. This PoC settles it before
the L-effort build starts. Per the repo's PoC convention the spike folder
freezes once this doc lands; BK-181 reimplements at the real tree paths.
Verdict¶
Use pytest-recording (vcrpy). It works for sync and async Azure,
needs zero production-code change, and reuses a maintained library for
--record-mode, cassette management, and network blocking. The custom
azure.core transport adapter — the "build" alternative — is not needed.
One real wrinkle, fully worked around in the PoC: vcrpy 8.1.1's aiohttp
stub cannot stream a response body. The fix is a one-line shim — inject
AsyncioRequestsTransport into the async backend via the existing
client_options kwarg — so async traffic rides vcrpy's proven
requests/urllib3 stub instead. Details below.
All six PoC success criteria pass. The spike has 4 tests (sync + async ×
happy-path + the BUG-197 unhappy case); all 4 record against a real ADLS
Gen2 account and replay green from committed, scrubbed cassettes with
--block-network in 0.6 s.
Build vs reuse¶
Reuse wins decisively. pytest-recording + vcrpy installed clean
(2 packages, no dependency conflicts with the existing dev set) and
deliver, off the shelf:
- the
--record-modeCLI surface (once/rewrite/none/...), - per-test cassette files with a stable path convention,
--block-networkenforcement (the zero-network proof),- the YAML cassette format with request/response filtering hooks.
The only "build" is ~150 lines of conftest glue — scrubbing, the
fixed-name trick, the missing-cassette skip hook. A custom transport
adapter would need all of that anyway, plus the adapter itself, in
sync and async variants, plus a second mechanism for S3 (which has no
azure.core pipeline). Reuse is less code and less surface to maintain.
What was tested¶
Spike folder: sdd/research/bk-181-poc/ — outside
testpaths, run by pointing pytest at it explicitly.
| File | Role |
|---|---|
conftest.py |
vcrpy wiring: scrubbing, record/replay connection-string switch, backend fixtures, missing-cassette → skip |
test_replay_sync.py |
AzureBackend: happy-path round-trip + BUG-197 unhappy case |
test_replay_async.py |
AsyncAzureBackend: same two cases |
cassettes/ |
4 recorded, scrubbed cassettes (committed) |
Each test runs the real Azure SDK code path. The only thing that
changes between record and replay is the transport: recording hits a real
ADLS Gen2 account (<real-account>), replay serves the committed cassette
from a fake connection string with no network.
Success criteria — results¶
1. Interception — partial for async, fully solved¶
| Path | SDK transport | vcrpy stub | Result |
|---|---|---|---|
| Sync | RequestsTransport (urllib3) |
requests/urllib3 | Works flawlessly — record and replay |
| Async | AioHttpTransport (aiohttp) |
aiohttp | Broken — see below |
vcrpy 8.1.1's aiohttp stub cannot carry a streamed response body:
- On record, the cassette captures the body correctly, but the live
caller receives
b""— the body never reaches the SDK. The async download path (azure.core...._aiohttp.pyasync-iterating the response content) is left empty. - On replay, the same path deadlocks. Faulthandler caught it
blocked in
AioHttpTransport.__anext__→asyncio.streams.read, awaiting a stream vcrpy's mock response never feeds and never closes. Responses without a body (the BUG-197 directory read, error responses) replay fine — the deadlock is body-stream-specific.
Workaround (applied in the PoC): inject AsyncioRequestsTransport —
an azure.core async transport that rides requests in a thread pool —
into the async backend via client_options={"transport": ...}. Async
traffic then flows through vcrpy's working urllib3 stub. The backend's own
async code path (aio/backends/_azure.py) is unchanged; only the leaf
transport differs, and injection uses an existing public kwarg, so
production code is untouched.
Fidelity caveat: the async replay fixture exercises every async backend
code path and every azure.core pipeline policy, but its leaf transport
is AsyncioRequestsTransport, not the production-default
AioHttpTransport. A defect living purely in aiohttp-transport
behaviour would not be caught by replay. This is acceptable: the live
Stage 3 azure_live_async fixture keeps the real AioHttpTransport and
remains the source of truth (the spec's design intent), and conformance
exercises the backend, not the transport leaf. If transport-layer
fidelity ever matters, the options are an upstream vcrpy aiohttp fix or a
custom AsyncHttpTransport adapter — neither blocks BK-181.
2. Zero-credential replay — pass¶
Replay builds the backend from a fixed fake connection string
(AccountName=bk181poc, Azurite's public well-known key — valid base64,
not a secret). hatch run pytest sdd/research/bk-181-poc/ --block-network
--allowed-hosts=127.0.0.1,::1,localhost passes all 4 tests in 0.63 s with
no network. (The loopback allowance is required only for the Windows
ProactorEventLoop self-pipe — see Other findings.)
3. HNS-specific behaviour captured — pass¶
Both BUG-197 cases (sync and async) replay green, asserting the current
buggy read_bytes(hns_dir) == b"". The cassettes carry the
x-ms-is-hns-enabled: 'true' account probe and the real HNS
directory-marker traffic — exactly the behaviour Azurite cannot emulate
and the whole BUG-195..203 family depends on. Recording this confirmed
BUG-197 reproduces on the async backend too, against the real account.
4. Scrubbing — works at header level; one body-level gap¶
Verified: grepping all 4 committed cassettes for the real account name,
authorization, x-ms-date, x-ms-client-request-id,
x-ms-request-id, AccountKey, sig= returns nothing. The
before_record_request host rewrite turns <real-account> into
bk181poc everywhere — URLs, headers, and response bodies.
Gap for BK-181 proper: per-run identifiers embedded in error-response
XML bodies survive — e.g. <Message>...RequestId:8fa924c6-...
Time:2026-05-14T... in a ContainerAlreadyExists body. Header scrubbing
does not reach them. BK-181 needs a body-level regex scrub for
RequestId: / Time: fragments. Cosmetic, not a credential leak, but it
makes cassette diffs noisy. (Also cosmetic: User-Agent records the
recording machine's OS/Python version — worth normalising.)
5. Per-run identifier problem — solved¶
The live registry fixtures mint a per-call conformance-<uuid>
filesystem (azure_live.py:77); that uuid would land in every request
URL and break cassette matching. The PoC uses a fixed container name
(bk181poc) plus the before_record_request host rewrite. vcrpy's
default match_on (method/scheme/host/port/path/query) then matches
cleanly: SharedKey auth keeps its signature in the Authorization header
(scrubbed, unmatched), not the query, and x-ms-client-request-id is a
header too. No custom matcher was needed.
6. Missing cassette → skip — solved¶
vcrpy's native behaviour on a missing cassette in record_mode=none is to
raise. TEST-007 wants a skip. A pytest_collection_modifyitems hook
in the conftest checks each vcr-marked test's cassette path at
collection time and adds pytest.mark.skip when it is absent. Verified:
with one cassette removed, that test skips with a clear reason and the
other three pass.
Other findings¶
- Install is clean.
uv pip install pytest-recordingpulledpytest-recording==0.13.4+vcrpy==8.1.1, no conflicts. --block-networkvs Windows asyncio. pytest-recording's network guard blockssocket.socketpair(), which the WindowsProactorEventLoopneeds for its self-pipe — async tests error at loop creation. Fix:--allowed-hosts=127.0.0.1,::1,localhost. Real Azure hosts stay blocked. BK-181's--stagerunner should bake this allowance in.filterwarnings = erroris unforgiving here. A half-created event loop (from the--block-networkissue above) leaks aResourceWarningthat the globalerrorfilter escalates and that then poisons subsequent tests viaPytestUnraisableExceptionWarning. BK-181 must keep async fixture teardown airtight.- Idempotent setup records cleanly. Filesystem- and directory-create
calls are wrapped in
except ResourceExistsError: pass; the request is identical whether the resource pre-exists (409) or not (201), so re-records work against a dirty or clean account alike. - Speed. Full 4-test replay: ~0.6 s. Recording: ~4.5 s (real account).
Recommendations for BK-181 proper¶
- Mechanism:
pytest-recording+vcrpy. Add both to thedevextra inpyproject.toml. - Async: inject
AsyncioRequestsTransportin theazure_replay_asyncfixture's factory viaclient_options. Document the fidelity caveat next to it. Do not wait on an upstream vcrpy aiohttp fix. - Real-tree wiring:
tests/backends/fixtures/azure_replay.py+azure_replay_async.py;[fixture.azure_replay]/[fixture.azure_replay_async]infixtures.tomlwithkind = "replay",stage = 1,container = "none"; register viaregistry.register. Cassettes at the spec locationtests/backends/cassettes/azure/. Model the recording-side factory onazure_live.py, but with a deterministic filesystem name. --recordwiring: map the spec'spytest --stage=3 --recordonto vcrpy's--record-mode=rewriteintests/conftest.py::pytest_addoption, and fold the--allowed-hostsloopback allowance into the runner.- Scrubbing: port the PoC's
vcr_configand add a body-level regex scrub forRequestId:/Time:fragments andUser-Agentnormalisation. - Missing-cassette skip + plugin guard: port the
pytest_collection_modifyitemshook (the cassette-path logic mirrorspytest_recording.plugin) and thepytest_configureplugin-availability guard — the registry fixtures depend onpytest-recording'srecord_modefixture, so a missing plugin should fail fast with a clear message, not an opaquefixture not found. - S3 ("follows"): a separate validation —
s3fsridesaiobotocore/botocore, notazure.core. vcrpy supports botocore, but the streaming-body behaviour must be re-checked the same way this PoC checked aiohttp. Do not assume the Azure result transfers. - Ship the trace (
sdd/traces/bk-181-*.yml) and theTESTING.md/ spec-048 migration-note updates with the work.
Effort: stays L. The PoC removes the mechanism risk, but the
real-tree fixture wiring, the --stage/--record plumbing, scrubbing
completeness, the S3 second mechanism, and the docs/trace ripple are still
the bulk of the work.
Reproduce¶
See bk-181-poc/README.md. In short:
uv pip install --python .venv pytest-recording # one-time
hatch run pytest sdd/research/bk-181-poc/ --block-network --allowed-hosts=127.0.0.1,::1,localhost
The second command replays the committed cassettes with no network and no
credentials. Recording (needs a real ADLS Gen2 account in .env plus
RS_TEST_LIVE_HNS=1) is --record-mode=rewrite.