Skip to content

Commit 6076ac8

Browse files
[SPT-1644] Log WebSocket server errors instead of dropping them (#1039)
The server's status messages on the WebSocket connection were logged at DEBUG and then dropped, so problems were silent: rejected subscriptions (e.g. "Subscription limit reached for feed"), and the reason for a disconnect (`max_connections`, `slow_consumer`, `force_disconnect`, etc.), which the server sends right before closing. Any status other than `connected`, `auth_success` or `success` is now logged at WARNING on the `WebSocketClient` logger, one line per burst: ``` WebSocketClient WARNING: server error: Subscription limit reached for feed. ... (x1000) WebSocketClient WARNING: server max_connections: Maximum number of websocket connections exceeded. ... ``` No other behavior changes. Tests are included. [SPT-1644](https://linear.app/massive-com/issue/SPT-1644/ws-options-feed-999-channel-per-connection-limit-undocumented-sdk)
1 parent c6db4b2 commit 6076ac8

3 files changed

Lines changed: 168 additions & 1 deletion

File tree

‎massive/websocket/__init__.py‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import os
2+
from collections import Counter
23
from enum import Enum
34
from typing import Optional, Union, List, Set, Callable, Awaitable, Any
45
import logging
56
import json
67
import asyncio
78
import ssl
9+
import time
810
import certifi
911
from .models import *
1012
from websockets.asyncio.client import connect, ClientConnection
@@ -16,6 +18,10 @@
1618
env_key = "MASSIVE_API_KEY"
1719
logger = get_logger("WebSocketClient")
1820

21+
# Status messages that mean things are working. Any other status (e.g. "error",
22+
# "max_connections", "slow_consumer", "force_disconnect") is logged as a warning.
23+
OK_STATUSES = {"connected", "auth_success", "success"}
24+
1925

2026
class WebSocketClient:
2127
def __init__(
@@ -70,6 +76,8 @@ def __init__(
7076
subscriptions = []
7177
self.scheduled_subs: Set[str] = set(subscriptions)
7278
self.schedule_resub = True
79+
self._server_errors: Counter = Counter()
80+
self._server_errors_since = 0.0
7381
if custom_json:
7482
self.json = custom_json
7583
else:
@@ -112,6 +120,7 @@ async def connect(
112120
try:
113121
msg = await s.recv()
114122
logger.debug("connected: %s", msg)
123+
self._log_server_statuses(self.json.loads(msg))
115124
logger.debug("authing...")
116125
await s.send(
117126
self.json.dumps({"action": "auth", "params": self.api_key})
@@ -121,6 +130,9 @@ async def connect(
121130
logger.debug("authed: %s", auth_msg)
122131
if auth_msg_parsed[0]["status"] == "auth_failed":
123132
raise AuthError(auth_msg_parsed[0]["message"])
133+
# e.g. max_connections, which the server sends before closing
134+
self._log_server_statuses(auth_msg_parsed)
135+
self._flush_server_errors(force=True)
124136
while True:
125137
if self.schedule_resub:
126138
logger.debug(
@@ -139,6 +151,7 @@ async def connect(
139151
await asyncio.wait_for(s.recv(), timeout=1)
140152
)
141153
except asyncio.TimeoutError:
154+
self._flush_server_errors()
142155
continue
143156

144157
if not self.raw:
@@ -147,17 +160,21 @@ async def connect(
147160
for m in msgJson:
148161
if m["ev"] == "status":
149162
logger.debug("status: %s", m["message"])
150-
continue
163+
self._log_server_statuses(msgJson)
151164
cmsg = parse(msgJson, logger, self.market)
165+
self._flush_server_errors()
152166

153167
if len(cmsg) > 0:
154168
await processor(cmsg) # type: ignore
155169
except ConnectionClosedOK as e:
156170
logger.debug("connection closed (OK): %s", e)
171+
# the server sends a status (e.g. slow_consumer) before closing
172+
self._flush_server_errors(force=True)
157173
return
158174
except ConnectionClosedError as e:
159175
last_exc = e
160176
logger.debug("connection closed (ERR): %s", e)
177+
self._flush_server_errors(force=True)
161178
reconnects += 1
162179
self.scheduled_subs = set(self.subs)
163180
self.subs = set()
@@ -195,6 +212,29 @@ async def handle_msg_wrapper(msgs):
195212

196213
asyncio.run(self.connect(handle_msg_wrapper, close_timeout, **kwargs))
197214

215+
def _log_server_statuses(self, msgs: List[Any]):
216+
# status messages are dropped by parse(), so problems (e.g. subscriptions
217+
# the server rejected, or why it disconnected) are logged here. The server
218+
# can send one error per channel, so identical ones are counted and logged
219+
# once a second.
220+
for m in msgs:
221+
if m.get("ev") != "status" or m.get("status") in OK_STATUSES:
222+
continue
223+
if not self._server_errors:
224+
self._server_errors_since = time.monotonic()
225+
self._server_errors[(m.get("status"), m.get("message"))] += 1
226+
227+
def _flush_server_errors(self, force: bool = False):
228+
if not self._server_errors:
229+
return
230+
if not force and time.monotonic() - self._server_errors_since < 1:
231+
return
232+
for (status, message), count in self._server_errors.items():
233+
logger.warning(
234+
"server %s: %s%s", status, message, f" (x{count})" if count > 1 else ""
235+
)
236+
self._server_errors = Counter()
237+
198238
async def _subscribe(self, topics: Union[List[str], Set[str]]):
199239
if self.websocket is None or len(topics) == 0:
200240
return
@@ -276,6 +316,7 @@ async def close(self):
276316
Close the websocket connection.
277317
"""
278318
logger.debug("closing")
319+
self._flush_server_errors(force=True)
279320

280321
if self.websocket:
281322
await self.websocket.close()

‎test_websocket/mock_server.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,41 @@ async def mock_server(websocket):
1414
message = json.loads(message)
1515
if "action" not in message:
1616
continue
17+
if message["action"] == "auth" and message["params"] == "MAX-CONNECTIONS":
18+
# emulate the server refusing a connection over the account's limit
19+
await websocket.send(
20+
'[{"ev":"status","status":"max_connections","message":"Maximum number of websocket connections exceeded."}]'
21+
)
22+
await websocket.close(code=1008)
23+
return
1724
if message["action"] == "auth":
1825
await websocket.send(
1926
'[{"ev":"status","status":"auth_success","message":"authenticated"}]'
2027
)
2128
elif message["action"] == "subscribe":
29+
if message["params"] == "SLOW.X":
30+
# emulate the server disconnecting a slow consumer
31+
await websocket.send(
32+
'[{"ev":"status","status":"slow_consumer","message":"Disconnected slow consumer."}]'
33+
)
34+
await websocket.close(code=1000)
35+
return
2236
for p in message["params"].split(","):
37+
if p.startswith("ERR."):
38+
# emulate the server rejecting a subscription
39+
await websocket.send(
40+
json.dumps(
41+
[
42+
{
43+
"ev": "status",
44+
"status": "error",
45+
"message": "Subscription limit reached for feed.",
46+
}
47+
]
48+
* 2
49+
)
50+
)
51+
continue
2352
subs.add(p)
2453
await websocket.send(
2554
json.dumps(

‎test_websocket/test_status.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from massive import WebSocketClient
2+
from massive.websocket import logger
3+
from base_ws import BaseTest
4+
from mock_server import port
5+
from websockets.asyncio.client import connect
6+
import asyncio
7+
8+
9+
class StatusTest(BaseTest):
10+
async def asyncSetUp(self):
11+
await super().asyncSetUp()
12+
# wait until the mock server is accepting connections
13+
for _ in range(100):
14+
try:
15+
async with connect(f"ws://localhost:{port}"):
16+
return
17+
except OSError:
18+
await asyncio.sleep(0.05)
19+
self.fail("mock server did not start")
20+
21+
async def asyncTearDown(self):
22+
# wait for the server to shut down so the next test can bind the port
23+
self.task.cancel()
24+
try:
25+
await self.task
26+
except asyncio.CancelledError:
27+
pass
28+
29+
async def run_client(self, *subscriptions):
30+
c = WebSocketClient(api_key="", feed=f"localhost:{port}", secure=False)
31+
c.subscribe(*subscriptions)
32+
task = asyncio.create_task(c.connect(lambda msgs: asyncio.sleep(0)))
33+
await asyncio.sleep(0.5)
34+
await c.close()
35+
await task
36+
37+
async def test_server_errors_are_logged(self):
38+
with self.assertLogs("WebSocketClient", level="WARNING") as logs:
39+
await self.run_client("ERR.X")
40+
self.assertEqual(
41+
logs.output,
42+
[
43+
"WARNING:WebSocketClient:server error: "
44+
"Subscription limit reached for feed. (x2)"
45+
],
46+
)
47+
48+
async def test_server_errors_are_grouped(self):
49+
# one subscription with many rejected channels logs a single line
50+
with self.assertLogs("WebSocketClient", level="WARNING") as logs:
51+
await self.run_client(*[f"ERR.X{i}" for i in range(50)])
52+
self.assertEqual(
53+
logs.output,
54+
[
55+
"WARNING:WebSocketClient:server error: "
56+
"Subscription limit reached for feed. (x100)"
57+
],
58+
)
59+
60+
async def test_success_statuses_are_not_logged(self):
61+
# assertNoLogs needs Python 3.10, so log a sentinel and check it's the only line
62+
with self.assertLogs("WebSocketClient", level="WARNING") as logs:
63+
await self.run_client("T.AAPL")
64+
logger.warning("sentinel")
65+
self.assertEqual(logs.output, ["WARNING:WebSocketClient:sentinel"])
66+
67+
async def test_disconnect_reason_is_logged(self):
68+
# the server sends a status and then closes the connection normally
69+
with self.assertLogs("WebSocketClient", level="WARNING") as logs:
70+
c = WebSocketClient(api_key="", feed=f"localhost:{port}", secure=False)
71+
c.subscribe("SLOW.X")
72+
await asyncio.wait_for(c.connect(lambda msgs: asyncio.sleep(0)), 5)
73+
self.assertEqual(
74+
logs.output,
75+
[
76+
"WARNING:WebSocketClient:server slow_consumer: Disconnected slow consumer."
77+
],
78+
)
79+
80+
async def test_auth_reply_status_is_logged(self):
81+
# e.g. max_connections arrives as the reply to the auth message
82+
c = WebSocketClient(
83+
api_key="MAX-CONNECTIONS",
84+
feed=f"localhost:{port}",
85+
secure=False,
86+
max_reconnects=0,
87+
)
88+
with self.assertLogs("WebSocketClient", level="WARNING") as logs:
89+
with self.assertRaises(Exception):
90+
await asyncio.wait_for(c.connect(lambda msgs: asyncio.sleep(0)), 5)
91+
self.assertEqual(
92+
logs.output,
93+
[
94+
"WARNING:WebSocketClient:server max_connections: "
95+
"Maximum number of websocket connections exceeded."
96+
],
97+
)

0 commit comments

Comments
 (0)