From be38fe6ac2f00831ef34e9b898ec03e34663b4d6 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 15 Mar 2026 09:08:13 +0000 Subject: [PATCH] fix(realtime): tolerate missing scoreboard channel layer --- realtime/broadcast.py | 33 ++++++++++++++++++++++++--------- realtime/tests.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/realtime/broadcast.py b/realtime/broadcast.py index 95d1a5d..aa3d11a 100644 --- a/realtime/broadcast.py +++ b/realtime/broadcast.py @@ -1,20 +1,35 @@ from asgiref.sync import async_to_sync +from channels.exceptions import InvalidChannelLayerError from channels.layers import get_channel_layer +from redis.exceptions import ConnectionError as RedisConnectionError async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: """Send a phase event to all WebSocket clients connected to a game session.""" - channel_layer = get_channel_layer() + try: + channel_layer = get_channel_layer() + except InvalidChannelLayerError: + return + + if channel_layer is None: + return + group_name = f"game_{session_code.upper()}" - await channel_layer.group_send( - group_name, - { - "type": "phase_event", - "payload": {"type": event_type, **payload}, - }, - ) + try: + await channel_layer.group_send( + group_name, + { + "type": "phase_event", + "payload": {"type": event_type, **payload}, + }, + ) + except (InvalidChannelLayerError, RedisConnectionError): + return def sync_broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: """Sync wrapper for calling broadcast_phase_event from synchronous Django views.""" - async_to_sync(broadcast_phase_event)(session_code, event_type, payload) + try: + async_to_sync(broadcast_phase_event)(session_code, event_type, payload) + except (InvalidChannelLayerError, RedisConnectionError): + return diff --git a/realtime/tests.py b/realtime/tests.py index 7ce503c..41229eb 100644 --- a/realtime/tests.py +++ b/realtime/tests.py @@ -1,3 +1,27 @@ -from django.test import TestCase +from unittest.mock import Mock, patch -# Create your tests here. +from channels.exceptions import InvalidChannelLayerError +from django.test import SimpleTestCase + +from realtime.broadcast import broadcast_phase_event, sync_broadcast_phase_event + + +class BroadcastPhaseEventTests(SimpleTestCase): + @patch("realtime.broadcast.get_channel_layer", return_value=None) + async def test_broadcast_phase_event_noops_without_channel_layer(self, _mock_get_channel_layer): + await broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) + + @patch("realtime.broadcast.async_to_sync") + def test_sync_broadcast_phase_event_noops_when_channel_layer_is_unavailable(self, mock_async_to_sync): + mock_async_to_sync.return_value.side_effect = InvalidChannelLayerError("missing channel layer") + + sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) + + @patch("realtime.broadcast.async_to_sync") + def test_sync_broadcast_phase_event_still_broadcasts_when_channel_layer_exists(self, mock_async_to_sync): + sender = Mock() + mock_async_to_sync.return_value = sender + + sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"}) + + sender.assert_called_once_with("ABCD", "phase.scoreboard", {"phase": "scoreboard"})