Environment
vcrpy 8.3.0, Python 3.13, urllib3 2.x, requests. Single-threaded — no ThreadPoolExecutor anywhere, so this isn't #212 or #849.
What's going on
force_reset() is a @contextmanager, so the window is held open by a suspended generator. If the frame that opened it disappears while that generator is still suspended, nothing unwinds it right away — the interpreter closes it whenever the collector gets around to it. By then some other cassette is usually running, and the ExitStack in force_reset (vcr/patch.py:711) unwinds inside it.
When it does, mock puts back the value it saved when the window opened, which is the real ConnectionCls. The cassette that's currently active had replaced that value, so it loses its patch, and the restore it saved for its own exit is stale too.
reset_patchers() (vcr/patch.py:569) doesn't touch _get_conn, so that one stays wrapped. What you end up with is a cassette that's patched on one side and unpatched on the other:
_get_conn=vcr ConnectionCls=real
Nothing corrects this, and it lasts for the rest of the cassette. _patched_get_conn only checks that the connection is an instance of pool.ConnectionCls, and a plain HTTPSConnection now passes that check, so every request in the cassette goes out over a real socket. A write-protected cassette stops raising CannotOverwriteExistingCassetteException entirely.
Reproduction
The gc.disable() and the reference cycle below are only there to make the timing deterministic — they aren't the cause. vcrpy opens these windows on its own in VCRConnection.__init__, connect and getresponse (vcr/stubs/__init__.py:292,335,359).
import gc
import requests
import urllib3.connectionpool as cpool
import vcr
import vcr.patch
import vcr.stubs
def state():
pool = cpool.HTTPSConnectionPool
get_conn = "vcr" if hasattr(pool._get_conn, "__wrapped__") else "real"
conn_cls = "vcr" if issubclass(pool.ConnectionCls, vcr.stubs.VCRConnection) else "real"
return f"_get_conn={get_conn} ConnectionCls={conn_cls}"
# A reset window whose opener is gone. Only the cyclic collector can free it.
gc.disable()
cycle = {"window": vcr.patch.force_reset()}
cycle["self"] = cycle
cycle["window"].__enter__()
del cycle
with vcr.use_cassette("/tmp/empty.yaml", record_mode="none"):
print("cassette entered :", state())
gc.enable()
gc.collect()
print("after collection :", state())
# An empty, write-protected cassette should raise
# CannotOverwriteExistingCassetteException here.
try:
requests.get("https://127.0.0.1:1/", timeout=1)
except Exception as exc:
print("request raised :", type(exc).__name__)
Output:
cassette entered : _get_conn=vcr ConnectionCls=vcr
after collection : _get_conn=vcr ConnectionCls=real
request raised : ConnectionError
That ConnectionError is the tell. The request left the process instead of being played back from the cassette.
How it shows up for us
As a rare CI flake, roughly one job in several hundred. pytest-socket blocks the escaped connection and the test fails with a host it never asked for. The test that fails isn't the one that opened the window, so the traceback points somewhere unhelpful and there's nothing in it to suggest vcrpy is involved.
Environment
vcrpy 8.3.0, Python 3.13, urllib3 2.x, requests. Single-threaded — no
ThreadPoolExecutoranywhere, so this isn't #212 or #849.What's going on
force_reset()is a@contextmanager, so the window is held open by a suspended generator. If the frame that opened it disappears while that generator is still suspended, nothing unwinds it right away — the interpreter closes it whenever the collector gets around to it. By then some other cassette is usually running, and theExitStackinforce_reset(vcr/patch.py:711) unwinds inside it.When it does,
mockputs back the value it saved when the window opened, which is the realConnectionCls. The cassette that's currently active had replaced that value, so it loses its patch, and the restore it saved for its own exit is stale too.reset_patchers()(vcr/patch.py:569) doesn't touch_get_conn, so that one stays wrapped. What you end up with is a cassette that's patched on one side and unpatched on the other:Nothing corrects this, and it lasts for the rest of the cassette.
_patched_get_connonly checks that the connection is an instance ofpool.ConnectionCls, and a plainHTTPSConnectionnow passes that check, so every request in the cassette goes out over a real socket. A write-protected cassette stops raisingCannotOverwriteExistingCassetteExceptionentirely.Reproduction
The
gc.disable()and the reference cycle below are only there to make the timing deterministic — they aren't the cause. vcrpy opens these windows on its own inVCRConnection.__init__,connectandgetresponse(vcr/stubs/__init__.py:292,335,359).Output:
That
ConnectionErroris the tell. The request left the process instead of being played back from the cassette.How it shows up for us
As a rare CI flake, roughly one job in several hundred.
pytest-socketblocks the escaped connection and the test fails with a host it never asked for. The test that fails isn't the one that opened the window, so the traceback points somewhere unhelpful and there's nothing in it to suggest vcrpy is involved.