Refuse websocket upgrades with HTTP statuses, not close codes

A close() before accept() carries the HTTP status of the refused
upgrade, not a WebSocket close code: RSGI (granian) uses the value
verbatim as the response status, so 4401 panicked on the invalid status
(InvalidStatusCode) and — granian being built with panic = "abort" —
aborted the worker and took the whole app down. ASGI servers answer 403
regardless, and browsers report a refused handshake as an abnormal close
(1006), so the 4401/4403/4404 codes never reached clients anyway.

Reject 401/404/403 to mirror GET /api/games/{id}, and classify a failed
first connection in the game page through the REST API so players still
see "session expired" / "game no longer available" instead of a retry
loop. Add a regression test asserting every pre-accept rejection uses a
status in the HTTP range.
This commit is contained in:
2026-09-29 22:31:41 +08:00
parent 21911caf9e
commit 2910fc4600
7 changed files with 162 additions and 35 deletions
+3 -3
View File
@@ -131,7 +131,7 @@ class WebSocketTest(unittest.TestCase):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/no-such-game", ws_client):
pass
self.assertEqual(4404, caught.exception.code)
self.assertEqual(404, caught.exception.code)
@async_test
async def test_non_player_is_closed(self) -> None:
@@ -146,7 +146,7 @@ class WebSocketTest(unittest.TestCase):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(4403, caught.exception.code)
self.assertEqual(403, caught.exception.code)
@async_test
async def test_unauthenticated_is_closed(self) -> None:
@@ -156,7 +156,7 @@ class WebSocketTest(unittest.TestCase):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(4401, caught.exception.code)
self.assertEqual(401, caught.exception.code)
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str: