WebSockets¶
Cassetter records and replays WebSocket connections made with the websockets library.
Install¶
Record and replay¶
Add "websockets" to the interceptor list:
import websockets
from cassetter import use_cassette
with use_cassette("cassette.yaml", intercept=["websockets"]):
async with websockets.connect("wss://ws.example.com/stream") as ws:
await ws.send('{"subscribe": "ticker"}')
data = await ws.recv()
Validate what the client sends¶
Add "body" to match_on to replay the conversation strictly:
import websockets
from cassetter import use_cassette
with use_cassette("cassette.yaml", intercept=["websockets"], match_on=["method", "uri", "body"]):
async with websockets.connect("wss://ws.example.com/stream") as ws:
await ws.send('{"subscribe": "ticker"}')
data = await ws.recv()
In strict mode:
- Each
send()must match the next recorded send. A changed or extra send raisesNoMatchError. recv()waits until every send recorded before its frame has been made. A background reader and a sender in separate tasks both keep going, in the recorded order.- Closing the connection with recorded sends still unsent raises
NoMatchError, unless the connection is closing because of another error. - Each connection replays the next unplayed recording for its URI. Reconnecting after the recordings run out raises
NoMatchErrorinstead of starting the conversation over.
Sends are compared after the same write-time filtering as the recording, so a token scrubbed from a recorded frame still matches the live one.
A missing send blocks recv()
A recv() waiting on a send that never comes waits forever, the way a live server that is waiting for your message never answers. Run strict replays under a test timeout.
Transform frames before they are written¶
before_record_ws_frame receives each frame before it is written, and returns the frame to store. Raise SkipRecording to leave a frame out. In strict mode the same hook runs over each live send before the comparison. A normalized recording then still matches traffic that carries fresh random values. The live connection always sends and receives the original frames.
import re
from cassetter import Body, SkipRecording, WsFrame, use_cassette
def normalize(frame: WsFrame) -> WsFrame:
content = frame.body.content
if frame.direction != "send" or not isinstance(content, str):
return frame
if content == "ping":
raise SkipRecording
stable = re.sub(r'"client_id": "[0-9a-f]+"', '"client_id": "<id>"', content)
return WsFrame("send", "text", Body("text", stable), frame.offset_ms)
with use_cassette(
"cassette.yaml",
intercept=["websockets"],
match_on=["method", "uri", "body"],
before_record_ws_frame=normalize,
):
...
Use it to replace random client IDs, truncate large audio payloads, or remove values the built-in filters do not know about. The built-in filters still run after the hook.
Follow replay progress¶
A replayed connection lists the frames recv() has returned in received_frames. Each frame carries its recorded offset_ms. Code that measures time on the wire can read those offsets as a deterministic clock instead of the real one:
from cassetter.websockets import VCRWebSocketReplay
if isinstance(ws, VCRWebSocketReplay):
recorded_at_ms = ws.received_frames[-1].offset_ms
A background reader may call recv() before the rest of your code handles a frame. Track which of received_frames you have handled yourself, so the clock moves when a frame is handled rather than when it is read.
Patch a reference an SDK already imported¶
The websockets interceptor replaces websockets.connect. An SDK that imported connect under its own name keeps the original: google.genai.live calls ws_connect, which the interceptor never touches. Point that name at cassetter.websockets.connect:
import google.genai.live
import pytest
import cassetter.websockets
@pytest.fixture(autouse=True)
def record_gemini_live(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(google.genai.live, "ws_connect", cassetter.websockets.connect)
cassetter.websockets.connect accepts the same arguments as websockets.connect. Inside a cassette it records or replays, whether or not "websockets" is in intercept. Outside a cassette, and for bypassed hosts, it opens a live connection.
The cassette¶
Each frame is recorded with its direction, type, and timing offset:
ws_interactions:
- uri: wss://ws.example.com/stream
headers: {}
frames:
- direction: send
frame_type: text
body:
type: text
content: '{"subscribe": "ticker"}'
offset_ms: 0
- direction: recv
frame_type: text
body:
type: json
content:
price: 42.5
offset_ms: 120
On replay, recv() returns the recorded frames in order, without a real connection. By default send() is a no-op. Both text and binary frames are supported.
The replayed connection behaves like the websockets connection it stands in for:
recv(decode=False)returns a text frame as UTF-8 bytes, andrecv(decode=True)returns a binary frame as text.- Once the recorded close is received,
close_codeandclose_reasonhold its code and reason. They areNonewhile the connection is open. A recording without a close frame ends as a normal closure, code1000. - After
close(),close_codeholds the code you closed with. - Once the connection is closed,
send()andrecv()raiseConnectionClosedOKfor a normal close code (1000,1001, or1005) andConnectionClosedErrorfor any other.
Security filtering¶
The same write time filtering as HTTP applies:
- Sensitive handshake headers (like
authorizationandcookie) are stripped. - Text and JSON frame bodies are scrubbed with the body scrub patterns. An authentication frame like
{"access_token": "..."}is stored with the token replaced by[FILTERED].
Binary frames are stored as is.