Skip to content

Commit e32d336

Browse files
committed
Clean up and accurately report WebSocket receive-loop termination (#102)
A recv() failure after the initial handshake now emits one receiveMessageError and completes the connection wrapper without re-raising through setup error handling, so it is no longer relabeled as createConnectionError. Every receive-loop termination (receive failure, cancellation, or intentional closure) marks the client not ready and cancels its heartbeat task; intentional closure emits no receive error. createConnectionError remains reserved for connection establishment and the initial handshake.
1 parent f2645c4 commit e32d336

2 files changed

Lines changed: 159 additions & 19 deletions

File tree

‎ringcentral/websocket/web_socket_client.py‎

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def __init__(self, platform):
1515
self._web_socket = None
1616
self._done = False
1717
self._is_ready = False
18+
self._heartbeat_task = None
1819
self._send_attempt_counter = 0
1920

2021
async def create_new_connection(self):
@@ -81,38 +82,50 @@ async def open_connection(self, ws_uri, ws_access_token):
8182
- Triggers the connectionCreated event upon successful connection establishment.
8283
- Listens for incoming messages and triggers the receiveMessage event for each received message.
8384
- Isolates receive-message handler failures so a handler that raises does not stop delivery to the remaining handlers or reception of future messages; each failure triggers the receiveMessageError event with the original exception.
84-
- Triggers the createConnectionError event if an error occurs during the connection process and raises the exception.
85+
- Triggers the createConnectionError event if an error occurs while establishing the connection or during the initial handshake, and raises the exception.
86+
- A failure from receiving messages after the initial handshake triggers the receiveMessageError event once; it is not reported as a connection-creation error and does not propagate to the connection-setup or recovery wrappers.
87+
- When the receive loop terminates (receive failure, cancellation, or intentional closure), the client is marked not ready and its heartbeat task is cancelled.
88+
- An intentional closure does not trigger the receiveMessageError event.
8589
"""
8690
try:
8791
websocket = await websockets.connect(
8892
f"{ws_uri}?access_token={ws_access_token}"
8993
)
9094
connectionMessage = await websocket.recv()
91-
connection_info = {}
92-
connection_info["connection"] = websocket
93-
connection_info["connection_details"] = connectionMessage
94-
self._web_socket = connection_info
95-
self._is_ready = True
96-
self.trigger(WebSocketEvents.connectionCreated, self)
97-
98-
# heartbeat every 10 minutes
99-
async def timer_function():
100-
while True:
101-
if self._done:
102-
timer.cancel()
103-
break
104-
await asyncio.sleep(600)
105-
await self.send_message([{"type": "Heartbeat", "messageId": str(uuid.uuid4())}])
106-
timer = asyncio.create_task(timer_function())
95+
except Exception as e:
96+
self.trigger(WebSocketEvents.createConnectionError, e)
97+
raise
98+
99+
connection_info = {}
100+
connection_info["connection"] = websocket
101+
connection_info["connection_details"] = connectionMessage
102+
self._web_socket = connection_info
103+
self._is_ready = True
104+
self.trigger(WebSocketEvents.connectionCreated, self)
105+
106+
# heartbeat every 10 minutes
107+
async def timer_function():
108+
while True:
109+
if self._done:
110+
timer.cancel()
111+
break
112+
await asyncio.sleep(600)
113+
await self.send_message([{"type": "Heartbeat", "messageId": str(uuid.uuid4())}])
114+
timer = asyncio.create_task(timer_function())
115+
self._heartbeat_task = timer
107116

117+
try:
108118
await asyncio.sleep(0)
109119
while True:
110120
message = await websocket.recv()
111121
self.trigger(WebSocketEvents.receiveMessage, message)
112122
await asyncio.sleep(0)
113123
except Exception as e:
114-
self.trigger(WebSocketEvents.createConnectionError, e)
115-
raise
124+
if not self._done:
125+
self.trigger(WebSocketEvents.receiveMessageError, e)
126+
finally:
127+
self._is_ready = False
128+
timer.cancel()
116129

117130
def trigger(self, event, *args, **kw):
118131
"""

‎ringcentral/websocket/web_socket_client_test.py‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,27 @@ def __init__(self):
3030
self.sent = []
3131
self.received = []
3232
self.closed = False
33+
self._fail_next_recv = False
34+
self._receive_error = None
3335
self._frames = asyncio.Queue()
3436

37+
def fail_next_receive(self, error):
38+
self._fail_next_recv = True
39+
self._receive_error = error
40+
3541
def push(self, frame):
3642
self._frames.put_nowait(frame)
3743

3844
def has_pending_frames(self):
3945
return self._frames.qsize() > 0
4046

4147
async def recv(self):
48+
if self._fail_next_recv:
49+
self._fail_next_recv = False
50+
raise self._receive_error
4251
frame = await self._frames.get()
52+
if frame is None:
53+
raise ConnectionResetError("connection closed")
4354
self.received.append(frame)
4455
return frame
4556

@@ -48,6 +59,7 @@ async def send(self, message):
4859

4960
async def close(self):
5061
self.closed = True
62+
self._frames.put_nowait(None)
5163

5264

5365
class RecordingHandler:
@@ -294,6 +306,121 @@ async def test_unrelated_event_dispatch_semantics_remain_unchanged(self):
294306
self.assertEqual(failing.calls, [(self.client,)])
295307
self.assertEqual(healthy.calls, [])
296308

309+
async def test_recv_failure_after_handshake_reports_receive_error_and_cleans_up(self):
310+
receive_error = RuntimeError("receive failure")
311+
receive_errors = RecordingHandler()
312+
connection_errors = RecordingHandler()
313+
self.client.on(WebSocketEvents.receiveMessageError, receive_errors)
314+
self.client.on(WebSocketEvents.createConnectionError, connection_errors)
315+
316+
sockets = []
317+
318+
def on_connected(*_):
319+
sockets[0].fail_next_receive(receive_error)
320+
321+
self.client.on(WebSocketEvents.connectionCreated, on_connected)
322+
323+
fake_socket, connection_task = await self.start_receive_loop()
324+
fake_socket.push("[/heartbeat]")
325+
sockets.append(fake_socket)
326+
327+
result = await connection_task
328+
329+
self.assertIsNone(result)
330+
self.assertEqual(len(receive_errors.calls), 1)
331+
self.assertEqual(len(receive_errors.calls[0]), 1)
332+
self.assertIs(receive_errors.calls[0][0], receive_error)
333+
self.assertEqual(connection_errors.calls, [])
334+
self.assertFalse(self.client._is_ready)
335+
await self.wait_until(lambda: self.client._heartbeat_task.done())
336+
self.assertTrue(self.client._heartbeat_task.cancelled())
337+
338+
async def test_receive_loop_cancellation_marks_client_not_ready_and_cancels_heartbeat(self):
339+
receive_errors = RecordingHandler()
340+
connection_errors = RecordingHandler()
341+
self.client.on(WebSocketEvents.receiveMessageError, receive_errors)
342+
self.client.on(WebSocketEvents.createConnectionError, connection_errors)
343+
344+
fake_socket, connection_task = await self.start_receive_loop()
345+
fake_socket.push("[/heartbeat]")
346+
await self.wait_until(lambda: self.client._is_ready)
347+
self.assertTrue(self.client._is_ready)
348+
349+
await self.stop_receive_loop(connection_task)
350+
351+
self.assertFalse(self.client._is_ready)
352+
await self.wait_until(lambda: self.client._heartbeat_task.done())
353+
self.assertTrue(self.client._heartbeat_task.cancelled())
354+
self.assertEqual(receive_errors.calls, [])
355+
self.assertEqual(connection_errors.calls, [])
356+
357+
async def test_intentional_close_performs_cleanup_without_receive_error(self):
358+
receive_errors = RecordingHandler()
359+
connection_errors = RecordingHandler()
360+
close_errors = RecordingHandler()
361+
self.client.on(WebSocketEvents.receiveMessageError, receive_errors)
362+
self.client.on(WebSocketEvents.createConnectionError, connection_errors)
363+
self.client.on(WebSocketEvents.closeConnectionError, close_errors)
364+
365+
fake_socket, connection_task = await self.start_receive_loop()
366+
fake_socket.push("[/heartbeat]")
367+
await self.wait_until(lambda: self.client._is_ready)
368+
369+
await self.client.close_connection()
370+
result = await connection_task
371+
372+
self.assertIsNone(result)
373+
self.assertTrue(fake_socket.closed)
374+
self.assertFalse(self.client._is_ready)
375+
await self.wait_until(lambda: self.client._heartbeat_task.done())
376+
self.assertTrue(self.client._heartbeat_task.cancelled())
377+
self.assertEqual(receive_errors.calls, [])
378+
self.assertEqual(connection_errors.calls, [])
379+
self.assertEqual(close_errors.calls, [])
380+
381+
async def test_connection_establishment_failure_retains_create_connection_error(self):
382+
connect_error = RuntimeError("connect failure")
383+
receive_errors = RecordingHandler()
384+
connection_errors = RecordingHandler()
385+
self.client.on(WebSocketEvents.receiveMessageError, receive_errors)
386+
self.client.on(WebSocketEvents.createConnectionError, connection_errors)
387+
388+
async def connect(uri):
389+
raise connect_error
390+
391+
with mock.patch("websockets.connect", connect):
392+
with self.assertRaises(RuntimeError):
393+
await self.client.create_new_connection()
394+
395+
self.assertTrue(connection_errors.calls)
396+
for call in connection_errors.calls:
397+
self.assertIs(call[0], connect_error)
398+
self.assertEqual(receive_errors.calls, [])
399+
400+
async def test_initial_handshake_failure_retains_create_connection_error(self):
401+
handshake_error = RuntimeError("handshake failure")
402+
receive_errors = RecordingHandler()
403+
connection_errors = RecordingHandler()
404+
self.client.on(WebSocketEvents.receiveMessageError, receive_errors)
405+
self.client.on(WebSocketEvents.createConnectionError, connection_errors)
406+
407+
fake_socket = FakeWebSocket()
408+
fake_socket.fail_next_receive(handshake_error)
409+
410+
async def connect(uri):
411+
return fake_socket
412+
413+
with mock.patch("websockets.connect", connect):
414+
with self.assertRaises(RuntimeError):
415+
await self.client.create_new_connection()
416+
417+
self.assertTrue(connection_errors.calls)
418+
for call in connection_errors.calls:
419+
self.assertIs(call[0], handshake_error)
420+
self.assertEqual(receive_errors.calls, [])
421+
self.assertFalse(self.client._is_ready)
422+
self.assertIsNone(self.client._heartbeat_task)
423+
297424

298425
if __name__ == "__main__":
299426
unittest.main()

0 commit comments

Comments
 (0)