From 24166e92e8273cd59313db45a51d9eaa27adadf8 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 26 Feb 2026 00:43:32 -0800 Subject: [PATCH] Add continue-on-failure attempts for when contact loading fails. Might help remedy #27, but there's still an issue (maybe radio lag?) --- app/routers/contacts.py | 37 +++++++++++++----- tests/test_repeater_routes.py | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 9 deletions(-) diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 79f0bed..8e4317b 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -30,6 +30,7 @@ from app.repository import ( MessageRepository, RepeaterAdvertPathRepository, ) +from app.websocket import broadcast_error if TYPE_CHECKING: from meshcore.events import Event @@ -160,12 +161,18 @@ async def prepare_repeater_connection(mc, contact: Contact, password: str) -> No Raises: HTTPException: If login fails """ - # Add contact to radio with path from DB + # Add contact to radio with path from DB (non-fatal — contact may already be loaded) logger.info("Adding repeater %s to radio", contact.public_key[:12]) add_result = await mc.commands.add_contact(contact.to_radio_dict()) if add_result is not None and add_result.type == EventType.ERROR: - raise HTTPException( - status_code=500, detail=f"Failed to add repeater contact: {add_result.payload}" + logger.warning( + "Failed to add repeater %s to radio: %s — continuing anyway", + contact.public_key[:12], + add_result.payload, + ) + broadcast_error( + "Failed to add repeater contact to radio, attempting to continue", + str(add_result.payload), ) # Send login with password @@ -603,12 +610,18 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com pause_polling=True, suspend_auto_fetch=True, ) as mc: - # Add contact to radio with path from DB + # Add contact to radio with path from DB (non-fatal — contact may already be loaded) logger.info("Adding repeater %s to radio", contact.public_key[:12]) add_result = await mc.commands.add_contact(contact.to_radio_dict()) if add_result is not None and add_result.type == EventType.ERROR: - raise HTTPException( - status_code=500, detail=f"Failed to add repeater contact: {add_result.payload}" + logger.warning( + "Failed to add repeater %s to radio: %s — continuing anyway", + contact.public_key[:12], + add_result.payload, + ) + broadcast_error( + "Failed to add repeater contact to radio, attempting to continue", + str(add_result.payload), ) # Send the command @@ -669,11 +682,17 @@ async def request_trace(public_key: str) -> TraceResponse: # Trace does not need auto-fetch suspension: response arrives as TRACE_DATA # from the reader loop, not via get_msg(). async with radio_manager.radio_operation("request_trace", pause_polling=True) as mc: - # Ensure contact is on radio so the trace can reach them + # Ensure contact is on radio so the trace can reach them (non-fatal) add_result = await mc.commands.add_contact(contact.to_radio_dict()) if add_result is not None and add_result.type == EventType.ERROR: - raise HTTPException( - status_code=500, detail=f"Failed to add contact for trace: {add_result.payload}" + logger.warning( + "Failed to add contact %s to radio for trace: %s — continuing anyway", + contact.public_key[:12], + add_result.payload, + ) + broadcast_error( + "Failed to add contact to radio for trace, attempting to continue", + str(add_result.payload), ) logger.info( diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index bbaa3c7..fa26b98 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -12,6 +12,7 @@ from app.radio import radio_manager from app.repository import ContactRepository from app.routers.contacts import ( _fetch_repeater_response, + prepare_repeater_connection, request_telemetry, request_trace, send_repeater_command, @@ -497,6 +498,78 @@ class TestTelemetryRoute: assert response.clock_output == "12:00" +class TestAddContactNonFatal: + """add_contact failure should warn and continue, not abort the operation.""" + + @pytest.mark.asyncio + async def test_prepare_repeater_connection_continues_on_add_contact_error(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.add_contact = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"reason": "no_event_received"}) + ) + mc.commands.send_login = AsyncMock(return_value=_radio_result(EventType.OK)) + contact = await ContactRepository.get_by_key(KEY_A) + + with patch("app.routers.contacts.broadcast_error") as mock_broadcast: + await prepare_repeater_connection(mc, contact, "pw") + + # Login was still attempted despite add_contact failure + mc.commands.send_login.assert_awaited_once() + mock_broadcast.assert_called_once() + assert "attempting to continue" in mock_broadcast.call_args[0][0].lower() + + @pytest.mark.asyncio + async def test_command_continues_on_add_contact_error(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Repeater", contact_type=2) + mc.commands.add_contact = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"reason": "no_event_received"}) + ) + mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.commands.get_msg = AsyncMock( + return_value=_radio_result( + EventType.CONTACT_MSG_RECV, + {"pubkey_prefix": KEY_A[:12], "text": "ver 1.0", "txt_type": 1}, + ) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch("app.routers.contacts.broadcast_error") as mock_broadcast, + patch(_MONOTONIC, side_effect=_advancing_clock()), + ): + response = await send_repeater_command(KEY_A, CommandRequest(command="ver")) + + assert response.response == "ver 1.0" + mock_broadcast.assert_called_once() + + @pytest.mark.asyncio + async def test_trace_continues_on_add_contact_error(self, test_db): + mc = _mock_mc() + await _insert_contact(KEY_A, name="Client", contact_type=1) + mc.commands.add_contact = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"reason": "no_event_received"}) + ) + mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.wait_for_event = AsyncMock( + return_value=MagicMock(payload={"path": [{"snr": 5.5}], "path_len": 1}) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch.object(radio_manager, "_meshcore", mc), + patch("app.routers.contacts.random.randint", return_value=1234), + patch("app.routers.contacts.broadcast_error") as mock_broadcast, + ): + response = await request_trace(KEY_A) + + assert response.remote_snr == 5.5 + assert response.path_len == 1 + mock_broadcast.assert_called_once() + + class TestRepeaterCommandRoute: @pytest.mark.asyncio async def test_send_cmd_error_raises_and_restores_auto_fetch(self, test_db):