Skip to content

Commit 7dce2d2

Browse files
fix: handle malformed JSON in WebSocket handler gracefully (fixes #257) (#259)
* fix: handle malformed JSON in WebSocket handler gracefully Wrap json.loads() in chatbot_stream with a try/except for json.JSONDecodeError. On malformed input, the handler now: 1. Logs a warning with the session ID 2. Sends a JSON error back: {"error": "Invalid JSON format."} 3. Continues listening for the next message Previously, a single malformed message would propagate to the outer except Exception block, terminating the entire WebSocket connection. Fixes #257 * test: add WebSocket unit tests for malformed JSON handling --------- Co-authored-by: Bervianto Leo Pratama <[email protected]>
1 parent 7797ae9 commit 7dce2d2

3 files changed

Lines changed: 110 additions & 1 deletion

File tree

chatbot-core/api/routes/chatbot.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,18 @@ async def chatbot_stream(websocket: WebSocket, session_id: str):
105105
try:
106106
while True:
107107
data = await websocket.receive_text()
108-
message_data = json.loads(data)
108+
109+
try:
110+
message_data = json.loads(data)
111+
except json.JSONDecodeError:
112+
logger.warning(
113+
"Malformed JSON from session %s", session_id
114+
)
115+
await websocket.send_text(
116+
json.dumps({"error": "Invalid JSON format."})
117+
)
118+
continue
119+
109120
user_message = message_data.get("message", "")
110121

111122
if not user_message:

chatbot-core/tests/unit/mocks/test_env.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ def mock_get_chatbot_reply(mocker):
5757
"""Mock the get_chatbot_reply function."""
5858
return mocker.patch("api.routes.chatbot.get_chatbot_reply")
5959

60+
@pytest.fixture
61+
def mock_get_chatbot_reply_stream(mocker):
62+
"""Mock the get_chatbot_reply_stream function."""
63+
return mocker.patch("api.routes.chatbot.get_chatbot_reply_stream")
64+
65+
6066
@pytest.fixture
6167
def mock_process_uploaded_file(mocker):
6268
"""Mock the process_uploaded_file function."""

chatbot-core/tests/unit/routes/test_chatbot.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,95 @@ def test_delete_chat_not_found(client, mock_delete_session):
6464

6565
assert response.status_code == 404
6666
assert response.json() == {"detail": "Session not found."}
67+
68+
69+
# =========================
70+
# WebSocket Tests
71+
# =========================
72+
73+
def test_websocket_malformed_json_returns_error_and_stays_alive(
74+
client, mock_session_exists, mock_get_chatbot_reply_stream
75+
):
76+
"""Malformed JSON must return an error message without closing the connection.
77+
78+
Before fix: a single bad message like 'hello' would propagate to the
79+
outer except block and terminate the WebSocket connection entirely.
80+
After fix: the handler catches json.JSONDecodeError, sends an error
81+
JSON, and continues listening.
82+
"""
83+
mock_session_exists.return_value = True
84+
85+
async def fake_stream(_session_id, _message):
86+
yield "reply-token"
87+
88+
mock_get_chatbot_reply_stream.side_effect = fake_stream
89+
90+
with client.websocket_connect("/sessions/test-session-id/stream") as ws:
91+
# Send malformed JSON
92+
ws.send_text("not valid json")
93+
error_response = ws.receive_json()
94+
assert error_response == {"error": "Invalid JSON format."}
95+
96+
# Connection is still alive — send a valid message
97+
ws.send_json({"message": "Hello"})
98+
token_response = ws.receive_json()
99+
assert "token" in token_response
100+
end_response = ws.receive_json()
101+
assert end_response == {"end": True}
102+
103+
104+
def test_websocket_valid_json_streams_response(
105+
client, mock_session_exists, mock_get_chatbot_reply_stream
106+
):
107+
"""A valid JSON message must produce streaming tokens followed by end marker."""
108+
mock_session_exists.return_value = True
109+
110+
async def fake_stream(_session_id, _message):
111+
yield "Hello"
112+
yield " world"
113+
114+
mock_get_chatbot_reply_stream.side_effect = fake_stream
115+
116+
with client.websocket_connect("/sessions/test-session-id/stream") as ws:
117+
ws.send_json({"message": "What is Jenkins?"})
118+
119+
token1 = ws.receive_json()
120+
assert token1 == {"token": "Hello"}
121+
token2 = ws.receive_json()
122+
assert token2 == {"token": " world"}
123+
end = ws.receive_json()
124+
assert end == {"end": True}
125+
126+
127+
def test_websocket_empty_message_is_skipped(
128+
client, mock_session_exists, mock_get_chatbot_reply_stream
129+
):
130+
"""An empty message field must be silently skipped without error."""
131+
mock_session_exists.return_value = True
132+
133+
async def fake_stream(_session_id, _message):
134+
yield "response"
135+
136+
mock_get_chatbot_reply_stream.side_effect = fake_stream
137+
138+
with client.websocket_connect("/sessions/test-session-id/stream") as ws:
139+
# Send valid JSON with empty message — should be skipped
140+
ws.send_json({"message": ""})
141+
142+
# Send a real message to prove connection is still alive
143+
ws.send_json({"message": "Real question"})
144+
token = ws.receive_json()
145+
assert token == {"token": "response"}
146+
end = ws.receive_json()
147+
assert end == {"end": True}
148+
149+
150+
def test_websocket_invalid_session_returns_error_and_closes(
151+
client, mock_session_exists
152+
):
153+
"""Connecting with a non-existent session ID must return error and close."""
154+
mock_session_exists.return_value = False
155+
156+
with client.websocket_connect("/sessions/bad-session/stream") as ws:
157+
error = ws.receive_json()
158+
assert error == {"error": "Session not found"}

0 commit comments

Comments
 (0)