From 9f6c0f12c535b4a4ae579aad772a90fc95a1e039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rkan?= <93771679+Bjorkan@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:58:59 +0200 Subject: [PATCH 1/3] Don't include .codex file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f986d90..77a09f4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ references/ # ancillary LLM files .claude/ +.codex # local Docker compose files docker-compose.yml From f710a1f2d99fa0816c77270ad9923e6dd759cdf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rkan?= <93771679+Bjorkan@users.noreply.github.com> Date: Thu, 30 Apr 2026 23:03:08 +0200 Subject: [PATCH 2/3] Change failed trace from using 504 to instead use 422 --- app/routers/contacts.py | 2 +- app/routers/radio.py | 4 ++-- tests/test_radio_router.py | 4 ++-- tests/test_repeater_routes.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 00d332e..0fe51f0 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -462,7 +462,7 @@ async def request_trace(public_key: str) -> TraceResponse: ) if event is None: - raise HTTPException(status_code=504, detail="No trace response heard") + raise HTTPException(status_code=422, detail="No trace response heard") trace = event.payload path = trace.get("path", []) diff --git a/app/routers/radio.py b/app/routers/radio.py index aad08f7..71fe7ee 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -561,7 +561,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse: try: event = await asyncio.wait_for(response_task, timeout=timeout_seconds) except TimeoutError as exc: - raise HTTPException(status_code=504, detail="No trace response heard") from exc + raise HTTPException(status_code=422, detail="No trace response heard") from exc finally: if not response_task.done(): response_task.cancel() @@ -569,7 +569,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse: await response_task if event is None: - raise HTTPException(status_code=504, detail="No trace response heard") + raise HTTPException(status_code=422, detail="No trace response heard") payload = event.payload if isinstance(event.payload, dict) else {} path_len = payload.get("path_len") diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index 55c981f..7fa20b3 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -699,7 +699,7 @@ class TestTracePath: assert "not a repeater" in exc.value.detail @pytest.mark.asyncio - async def test_returns_504_when_no_trace_response_is_heard(self): + async def test_returns_422_when_no_trace_response_is_heard(self): mc = _mock_meshcore_with_info() repeater = Contact( public_key="44" * 32, @@ -741,7 +741,7 @@ class TestTracePath: ) ) - assert exc.value.status_code == 504 + assert exc.value.status_code == 422 assert "No trace response heard" in exc.value.detail @pytest.mark.asyncio diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index d482b89..3a47afd 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -510,7 +510,7 @@ class TestTraceRoute: ) @pytest.mark.asyncio - async def test_wait_timeout_returns_504(self, test_db): + async def test_wait_timeout_returns_422(self, test_db): mc = _mock_mc() await _insert_contact(KEY_A, name="Client", contact_type=1) mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK)) @@ -524,7 +524,7 @@ class TestTraceRoute: with pytest.raises(HTTPException) as exc: await request_trace(KEY_A) - assert exc.value.status_code == 504 + assert exc.value.status_code == 422 mc.commands.send_trace.assert_awaited_once_with( path=KEY_A[:8], tag=1234, From dbf14259dc60afaef5da2b1925cfe929821e72b4 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 30 Apr 2026 18:47:35 -0700 Subject: [PATCH 3/3] Do full rewrite of 5xx => 4xx --- app/main.py | 4 ++-- app/routers/contacts.py | 12 +++++----- app/routers/push.py | 10 ++++----- app/routers/radio.py | 30 ++++++++++++------------- app/routers/repeaters.py | 4 ++-- app/routers/rooms.py | 4 ++-- app/routers/server_control.py | 2 +- app/services/message_send.py | 32 +++++++++++++-------------- app/services/radio_runtime.py | 6 ++--- tests/test_api.py | 34 ++++++++++++++--------------- tests/test_bot.py | 2 +- tests/test_contacts_router.py | 4 ++-- tests/test_radio_operation.py | 12 +++++----- tests/test_radio_router.py | 22 +++++++++---------- tests/test_radio_runtime_service.py | 4 ++-- tests/test_repeater_routes.py | 26 +++++++++++----------- tests/test_send_messages.py | 20 ++++++++--------- 17 files changed, 114 insertions(+), 114 deletions(-) diff --git a/app/main.py b/app/main.py index 833821c..3083b2c 100644 --- a/app/main.py +++ b/app/main.py @@ -176,8 +176,8 @@ app.add_middleware( @app.exception_handler(RadioDisconnectedError) async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedError): - """Return 503 when a radio disconnect race occurs during an operation.""" - return JSONResponse(status_code=503, content={"detail": "Radio not connected"}) + """Return 423 when a radio disconnect race occurs during an operation.""" + return JSONResponse(status_code=423, content={"detail": "Radio not connected"}) @app.middleware("http") diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 0fe51f0..6a94502 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -66,11 +66,11 @@ async def _resolve_contact_or_404( async def _ensure_on_radio(mc, contact: Contact) -> None: - """Add a contact to the radio for routing, raising 500 on failure.""" + """Add a contact to the radio for routing, raising 422 on failure.""" 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 to radio: {add_result.payload}" + status_code=422, detail=f"Failed to add contact to radio: {add_result.payload}" ) @@ -452,7 +452,7 @@ async def request_trace(public_key: str) -> TraceResponse: ) if result.type == EventType.ERROR: - raise HTTPException(status_code=500, detail=f"Failed to send trace: {result.payload}") + raise HTTPException(status_code=422, detail=f"Failed to send trace: {result.payload}") # Wait for the matching TRACE_DATA event event = await mc.wait_for_event( @@ -462,7 +462,7 @@ async def request_trace(public_key: str) -> TraceResponse: ) if event is None: - raise HTTPException(status_code=422, detail="No trace response heard") + raise HTTPException(status_code=408, detail="No trace response heard") trace = event.payload path = trace.get("path", []) @@ -506,7 +506,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse: result = await mc.commands.send_path_discovery(contact.public_key) if result.type == EventType.ERROR: raise HTTPException( - status_code=500, + status_code=422, detail=f"Failed to send path discovery: {result.payload}", ) @@ -518,7 +518,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse: await response_task if event is None: - raise HTTPException(status_code=504, detail="No path discovery response heard") + raise HTTPException(status_code=408, detail="No path discovery response heard") payload = event.payload forward_path = str(payload.get("out_path") or "") diff --git a/app/routers/push.py b/app/routers/push.py index 942976c..dc12675 100644 --- a/app/routers/push.py +++ b/app/routers/push.py @@ -48,7 +48,7 @@ async def vapid_public_key() -> VapidPublicKeyResponse: """Return the VAPID public key for browser PushManager.subscribe().""" key = get_vapid_public_key() if not key: - raise HTTPException(status_code=503, detail="VAPID keys not initialized") + raise HTTPException(status_code=423, detail="VAPID keys not initialized") return VapidPublicKeyResponse(public_key=key) @@ -103,7 +103,7 @@ async def test_push(subscription_id: str) -> dict: vapid_key = get_vapid_private_key() if not vapid_key: - raise HTTPException(status_code=503, detail="VAPID keys not initialized") + raise HTTPException(status_code=423, detail="VAPID keys not initialized") payload = json.dumps( { @@ -127,7 +127,7 @@ async def test_push(subscription_id: str) -> dict: ) return {"status": "sent"} except TimeoutError: - raise HTTPException(status_code=504, detail="Push delivery timed out") from None + raise HTTPException(status_code=408, detail="Push delivery timed out") from None except WebPushException as e: status_code = getattr(getattr(e, "response", None), "status_code", 0) if status_code in (403, 404, 410): @@ -143,10 +143,10 @@ async def test_push(subscription_id: str) -> dict: "Re-enable push from a conversation header.", ) from None logger.warning("Test push failed: %s", e) - raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None + raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None except Exception as e: logger.warning("Test push failed: %s", e) - raise HTTPException(status_code=502, detail=f"Push delivery failed: {e}") from None + raise HTTPException(status_code=422, detail=f"Push delivery failed: {e}") from None # ── Global push conversation management ────────────────────────────────── diff --git a/app/routers/radio.py b/app/routers/radio.py index 71fe7ee..02fb4b2 100644 --- a/app/routers/radio.py +++ b/app/routers/radio.py @@ -338,7 +338,7 @@ async def get_radio_config() -> RadioConfigResponse: info = mc.self_info if not info: - raise HTTPException(status_code=503, detail="Radio info not available") + raise HTTPException(status_code=423, detail="Radio info not available") adv_loc_policy = info.get("adv_loc_policy", 1) advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current" @@ -380,7 +380,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse: except PathHashModeUnsupportedError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except RadioCommandRejectedError as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc + raise HTTPException(status_code=422, detail=str(exc)) from exc return await get_radio_config() @@ -430,7 +430,7 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict: export_and_store_private_key_fn=export_and_store_private_key, ) except (RadioCommandRejectedError, KeystoreRefreshError) as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc + raise HTTPException(status_code=422, detail=str(exc)) from exc return {"status": "ok"} @@ -454,7 +454,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di success = await do_send_advertisement(mc, force=True, mode=mode) if not success: - raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement") + raise HTTPException(status_code=422, detail=f"Failed to send {mode} advertisement") return {"status": "ok"} @@ -486,7 +486,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons tag=tag, ) if send_result is None or send_result.type == EventType.ERROR: - raise HTTPException(status_code=500, detail="Failed to start mesh discovery") + raise HTTPException(status_code=422, detail="Failed to start mesh discovery") deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS results_by_key: dict[str, RadioDiscoveryResult] = {} @@ -538,7 +538,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse: async with radio_manager.radio_operation("radio_trace", pause_polling=True) as mc: local_public_key = str((mc.self_info or {}).get("public_key") or "").lower() if len(local_public_key) != 64: - raise HTTPException(status_code=503, detail="Local radio public key is unavailable") + raise HTTPException(status_code=423, detail="Local radio public key is unavailable") local_name = (mc.self_info or {}).get("name") response_task = asyncio.create_task( @@ -555,13 +555,13 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse: flags=trace_flags, ) if send_result is None or send_result.type == EventType.ERROR: - raise HTTPException(status_code=500, detail="Failed to send trace") + raise HTTPException(status_code=422, detail="Failed to send trace") timeout_seconds = _trace_timeout_seconds(send_result) try: event = await asyncio.wait_for(response_task, timeout=timeout_seconds) except TimeoutError as exc: - raise HTTPException(status_code=422, detail="No trace response heard") from exc + raise HTTPException(status_code=408, detail="No trace response heard") from exc finally: if not response_task.done(): response_task.cancel() @@ -569,12 +569,12 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse: await response_task if event is None: - raise HTTPException(status_code=422, detail="No trace response heard") + raise HTTPException(status_code=408, detail="No trace response heard") payload = event.payload if isinstance(event.payload, dict) else {} path_len = payload.get("path_len") if not isinstance(path_len, int): - raise HTTPException(status_code=500, detail="Trace response was malformed") + raise HTTPException(status_code=422, detail="Trace response was malformed") raw_path = payload.get("path") path_nodes = raw_path if isinstance(raw_path, list) else [] @@ -588,7 +588,7 @@ async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse: hashed_nodes = path_nodes[:-1] if final_local_node is not None else path_nodes if len(hashed_nodes) < len(trace_nodes): - raise HTTPException(status_code=500, detail="Trace response was incomplete") + raise HTTPException(status_code=422, detail="Trace response was incomplete") nodes: list[RadioTraceNode] = [] for index, trace_node in enumerate(trace_nodes): @@ -641,13 +641,13 @@ async def _attempt_reconnect() -> dict: except Exception as e: logger.exception("Post-connect setup failed after reconnect") raise HTTPException( - status_code=503, + status_code=423, detail=f"Radio connected but setup failed: {e}", ) from e if not success: raise HTTPException( - status_code=503, detail="Failed to reconnect. Check radio connection and power." + status_code=423, detail="Failed to reconnect. Check radio connection and power." ) return {"status": "ok", "message": "Reconnected successfully", "connected": True} @@ -702,14 +702,14 @@ async def reconnect_radio() -> dict: logger.info("Radio connected but setup incomplete, retrying setup") try: if not await _prepare_connected(broadcast_on_success=True): - raise HTTPException(status_code=503, detail="Radio connection is paused") + raise HTTPException(status_code=423, detail="Radio connection is paused") return {"status": "ok", "message": "Setup completed", "connected": True} except HTTPException: raise except Exception as e: logger.exception("Post-connect setup failed") raise HTTPException( - status_code=503, + status_code=423, detail=f"Radio connected but setup failed: {e}", ) from e diff --git a/app/routers/repeaters.py b/app/routers/repeaters.py index a1e1031..7317b84 100644 --- a/app/routers/repeaters.py +++ b/app/routers/repeaters.py @@ -113,7 +113,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse: logger.debug("LPP sensor fetch failed for %s (non-fatal): %s", public_key[:12], e) if status is None: - raise HTTPException(status_code=504, detail="No status response from repeater") + raise HTTPException(status_code=408, detail="No status response from repeater") response = RepeaterStatusResponse( battery_volts=status.get("bat", 0) / 1000.0, @@ -222,7 +222,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons ) if telemetry is None: - raise HTTPException(status_code=504, detail="No telemetry response from repeater") + raise HTTPException(status_code=408, detail="No telemetry response from repeater") sensors: list[LppSensor] = [] for entry in telemetry: diff --git a/app/routers/rooms.py b/app/routers/rooms.py index 745ae51..1295363 100644 --- a/app/routers/rooms.py +++ b/app/routers/rooms.py @@ -58,7 +58,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse: status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5) if status is None: - raise HTTPException(status_code=504, detail="No status response from room server") + raise HTTPException(status_code=408, detail="No status response from room server") return RepeaterStatusResponse( battery_volts=status.get("bat", 0) / 1000.0, @@ -98,7 +98,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: ) if telemetry is None: - raise HTTPException(status_code=504, detail="No telemetry response from room server") + raise HTTPException(status_code=408, detail="No telemetry response from room server") sensors = [ LppSensor( diff --git a/app/routers/server_control.py b/app/routers/server_control.py index ca6c502..eda007e 100644 --- a/app/routers/server_control.py +++ b/app/routers/server_control.py @@ -291,7 +291,7 @@ async def send_contact_cli_command( if send_result.type == EventType.ERROR: raise HTTPException( - status_code=500, detail=f"Failed to send command: {send_result.payload}" + status_code=422, detail=f"Failed to send command: {send_result.payload}" ) response_event = await fetch_contact_cli_response(mc, contact.public_key[:12]) diff --git a/app/services/message_send.py b/app/services/message_send.py index d5e09f5..7c05ec5 100644 --- a/app/services/message_send.py +++ b/app/services/message_send.py @@ -159,7 +159,7 @@ async def send_channel_message_with_effective_scope( override_result.payload, ) raise HTTPException( - status_code=500, + status_code=422, detail=( f"Failed to apply regional override {override_scope!r} before {action_label}: " f"{override_result.payload}" @@ -189,7 +189,7 @@ async def send_channel_message_with_effective_scope( phm_result.payload, ) raise HTTPException( - status_code=500, + status_code=422, detail=( f"Failed to apply path hash mode override before {action_label}: " f"{phm_result.payload}" @@ -233,7 +233,7 @@ async def send_channel_message_with_effective_scope( set_result.payload, ) raise HTTPException( - status_code=500, + status_code=422, detail=f"Failed to configure channel on radio before {action_label}", ) radio_manager.note_channel_slot_loaded(channel_key, channel_slot) @@ -256,7 +256,7 @@ async def send_channel_message_with_effective_scope( action_label, channel.name, ) - raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) + raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) if send_result.type == EventType.ERROR: logger.error( "Radio returned error during %s for channel %s: %s", @@ -598,10 +598,10 @@ async def send_direct_message_to_contact( "No response from radio after direct send to %s; send outcome is unknown", contact.public_key[:12], ) - raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) + raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) if result.type == EventType.ERROR: - raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}") + raise HTTPException(status_code=422, detail=f"Failed to send message: {result.payload}") message = await create_outgoing_direct_message( conversation_key=contact.public_key.lower(), @@ -613,7 +613,7 @@ async def send_direct_message_to_contact( ) if message is None: raise HTTPException( - status_code=500, + status_code=422, detail="Failed to store outgoing message - unexpected duplicate", ) finally: @@ -626,7 +626,7 @@ async def send_direct_message_to_contact( ) if sent_at is None or sender_timestamp is None or message is None or result is None: - raise HTTPException(status_code=500, detail="Failed to store outgoing message") + raise HTTPException(status_code=422, detail="Failed to store outgoing message") await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at) @@ -791,7 +791,7 @@ async def send_channel_message_to_channel( ) if outgoing_message is None: raise HTTPException( - status_code=500, + status_code=422, detail="Failed to store outgoing message - unexpected duplicate", ) @@ -813,11 +813,11 @@ async def send_channel_message_to_channel( "No response from radio after channel send to %s; send outcome is unknown", channel.name, ) - raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) + raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) if result.type == EventType.ERROR: raise HTTPException( - status_code=500, detail=f"Failed to send message: {result.payload}" + status_code=422, detail=f"Failed to send message: {result.payload}" ) except Exception: if outgoing_message is not None: @@ -834,7 +834,7 @@ async def send_channel_message_to_channel( ) if sent_at is None or sender_timestamp is None or outgoing_message is None: - raise HTTPException(status_code=500, detail="Failed to store outgoing message") + raise HTTPException(status_code=422, detail="Failed to store outgoing message") outgoing_message = await build_stored_outgoing_channel_message( message_id=outgoing_message.id, @@ -928,7 +928,7 @@ async def resend_channel_message_record( ) if new_message is None: raise HTTPException( - status_code=500, + status_code=422, detail="Failed to store resent message - unexpected duplicate", ) @@ -949,10 +949,10 @@ async def resend_channel_message_record( "No response from radio after channel resend to %s; send outcome is unknown", channel.name, ) - raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) + raise HTTPException(status_code=408, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL) if result.type == EventType.ERROR: raise HTTPException( - status_code=500, + status_code=422, detail=f"Failed to resend message: {result.payload}", ) except Exception: @@ -971,7 +971,7 @@ async def resend_channel_message_record( if new_timestamp: if sent_at is None or new_message is None: - raise HTTPException(status_code=500, detail="Failed to assign resend timestamp") + raise HTTPException(status_code=422, detail="Failed to assign resend timestamp") new_message = await build_stored_outgoing_channel_message( message_id=new_message.id, diff --git a/app/services/radio_runtime.py b/app/services/radio_runtime.py index a5649c5..dba1973 100644 --- a/app/services/radio_runtime.py +++ b/app/services/radio_runtime.py @@ -52,12 +52,12 @@ class RadioRuntime: def require_connected(self): """Return MeshCore when available, mirroring existing HTTP semantics.""" if self.is_setup_in_progress: - raise HTTPException(status_code=503, detail="Radio is initializing") + raise HTTPException(status_code=423, detail="Radio is initializing") if not self.is_connected: - raise HTTPException(status_code=503, detail="Radio not connected") + raise HTTPException(status_code=423, detail="Radio not connected") mc = self.meshcore if mc is None: - raise HTTPException(status_code=503, detail="Radio not connected") + raise HTTPException(status_code=423, detail="Radio not connected") return mc @asynccontextmanager diff --git a/tests/test_api.py b/tests/test_api.py index d3799be..8b80e57 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -50,7 +50,7 @@ def _patch_require_connected(mc=None, *, detail="Radio not connected"): if mc is None: return patch( "app.services.radio_runtime.radio_runtime.require_connected", - side_effect=HTTPException(status_code=503, detail=detail), + side_effect=HTTPException(status_code=423, detail=detail), ) return patch("app.services.radio_runtime.radio_runtime.require_connected", return_value=mc) @@ -422,11 +422,11 @@ class TestDebugEndpoint: class TestRadioDisconnectedHandler: - """Test that RadioDisconnectedError maps to 503.""" + """Test that RadioDisconnectedError maps to 423.""" @pytest.mark.asyncio - async def test_disconnect_race_returns_503(self, test_db, client): - """If radio disconnects between require_connected() and lock acquisition, return 503.""" + async def test_disconnect_race_returns_423(self, test_db, client): + """If radio disconnects between require_connected() and lock acquisition, return 423.""" pub_key = "ab" * 32 await _insert_contact(pub_key, "Alice") @@ -437,7 +437,7 @@ class TestRadioDisconnectedHandler: "/api/messages/direct", json={"destination": pub_key, "text": "Hi"} ) - assert response.status_code == 503 + assert response.status_code == 423 assert "not connected" in response.json()["detail"].lower() @@ -500,25 +500,25 @@ class TestMessagesEndpoint: @pytest.mark.asyncio async def test_send_direct_message_requires_connection(self, test_db, client): - """Sending message when disconnected returns 503.""" + """Sending message when disconnected returns 423.""" with _patch_require_connected(): response = await client.post( "/api/messages/direct", json={"destination": "abc123", "text": "Hello"} ) - assert response.status_code == 503 + assert response.status_code == 423 assert "not connected" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_send_channel_message_requires_connection(self, test_db, client): - """Sending channel message when disconnected returns 503.""" + """Sending channel message when disconnected returns 423.""" with _patch_require_connected(): response = await client.post( "/api/messages/channel", json={"channel_key": "0123456789ABCDEF0123456789ABCDEF", "text": "Hello"}, ) - assert response.status_code == 503 + assert response.status_code == 423 @pytest.mark.asyncio async def test_send_direct_message_emits_websocket_message_event(self, test_db, client): @@ -603,8 +603,8 @@ class TestMessagesEndpoint: assert "not found" in response.json()["detail"].lower() @pytest.mark.asyncio - async def test_send_direct_message_duplicate_returns_500(self, test_db): - """If MessageRepository.create returns None (duplicate), returns 500.""" + async def test_send_direct_message_duplicate_returns_422(self, test_db): + """If MessageRepository.create returns None (duplicate), returns 422.""" from app.models import SendDirectMessageRequest from app.routers.messages import send_direct_message @@ -636,12 +636,12 @@ class TestMessagesEndpoint: SendDirectMessageRequest(destination=pub_key, text="Hello") ) - assert exc_info.value.status_code == 500 + assert exc_info.value.status_code == 422 assert "unexpected duplicate" in exc_info.value.detail.lower() @pytest.mark.asyncio - async def test_send_channel_message_duplicate_returns_500(self, test_db): - """If MessageRepository.create returns None (duplicate), returns 500.""" + async def test_send_channel_message_duplicate_returns_422(self, test_db): + """If MessageRepository.create returns None (duplicate), returns 422.""" from app.models import SendChannelMessageRequest from app.routers.messages import send_channel_message @@ -672,16 +672,16 @@ class TestMessagesEndpoint: SendChannelMessageRequest(channel_key=chan_key, text="Hello") ) - assert exc_info.value.status_code == 500 + assert exc_info.value.status_code == 422 assert "unexpected duplicate" in exc_info.value.detail.lower() @pytest.mark.asyncio async def test_resend_channel_message_requires_connection(self, test_db, client): - """Resend endpoint returns 503 when radio is disconnected.""" + """Resend endpoint returns 423 when radio is disconnected.""" with _patch_require_connected(): response = await client.post("/api/messages/channel/1/resend") - assert response.status_code == 503 + assert response.status_code == 423 assert "not connected" in response.json()["detail"].lower() @pytest.mark.asyncio diff --git a/tests/test_bot.py b/tests/test_bot.py index 7371bcb..e627b0a 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -709,7 +709,7 @@ class TestBotMessageRateLimiting: patch( "app.routers.messages.send_direct_message", new_callable=AsyncMock, - side_effect=HTTPException(status_code=500, detail="Send failed"), + side_effect=HTTPException(status_code=422, detail="Send failed"), ), ): await process_bot_response( diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index bb867c5..ff27787 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -317,7 +317,7 @@ class TestPathDiscovery: mock_broadcast.assert_called_once_with("contact", updated.model_dump()) @pytest.mark.asyncio - async def test_returns_504_when_no_response_is_heard(self, test_db, client): + async def test_returns_408_when_no_response_is_heard(self, test_db, client): await _insert_contact(KEY_A, "Alice", type=1) mc = MagicMock() mc.commands = MagicMock() @@ -332,7 +332,7 @@ class TestPathDiscovery: mock_rm.radio_operation = _noop_radio_operation(mc) response = await client.post(f"/api/contacts/{KEY_A}/path-discovery") - assert response.status_code == 504 + assert response.status_code == 408 assert "No path discovery response heard" in response.json()["detail"] diff --git a/tests/test_radio_operation.py b/tests/test_radio_operation.py index 69ff57d..11e2e88 100644 --- a/tests/test_radio_operation.py +++ b/tests/test_radio_operation.py @@ -174,8 +174,8 @@ class TestRadioOperationYield: class TestRequireConnected: """Test the require_connected() FastAPI dependency.""" - def test_raises_503_when_setup_in_progress(self): - """HTTPException 503 is raised when radio is connected but setup is still in progress.""" + def test_raises_423_when_setup_in_progress(self): + """HTTPException 423 is raised when radio is connected but setup is still in progress.""" from fastapi import HTTPException from app.services.radio_runtime import radio_runtime @@ -188,11 +188,11 @@ class TestRequireConnected: with pytest.raises(HTTPException) as exc_info: radio_runtime.require_connected() - assert exc_info.value.status_code == 503 + assert exc_info.value.status_code == 423 assert "initializing" in exc_info.value.detail.lower() - def test_raises_503_when_not_connected(self): - """HTTPException 503 is raised when radio is not connected.""" + def test_raises_423_when_not_connected(self): + """HTTPException 423 is raised when radio is not connected.""" from fastapi import HTTPException from app.services.radio_runtime import radio_runtime @@ -205,7 +205,7 @@ class TestRequireConnected: with pytest.raises(HTTPException) as exc_info: radio_runtime.require_connected() - assert exc_info.value.status_code == 503 + assert exc_info.value.status_code == 423 def test_returns_meshcore_when_connected_and_setup_complete(self): """Returns meshcore instance when radio is connected and setup is complete.""" diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py index 7fa20b3..f08fcf7 100644 --- a/tests/test_radio_router.py +++ b/tests/test_radio_router.py @@ -131,14 +131,14 @@ class TestGetRadioConfig: assert response.advert_location_source == "current" @pytest.mark.asyncio - async def test_returns_503_when_self_info_missing(self): + async def test_returns_423_when_self_info_missing(self): mc = MagicMock() mc.self_info = None with patch("app.routers.radio.radio_manager.require_connected", return_value=mc): with pytest.raises(HTTPException) as exc: await get_radio_config() - assert exc.value.status_code == 503 + assert exc.value.status_code == 423 class TestUpdateRadioConfig: @@ -278,7 +278,7 @@ class TestUpdateRadioConfig: with pytest.raises(HTTPException) as exc: await update_radio_config(RadioConfigUpdate(path_hash_mode=1)) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 assert "Failed to set path hash mode" in str(exc.value.detail) assert radio_manager.path_hash_mode == 0 mc.commands.send_appstart.assert_not_awaited() @@ -339,7 +339,7 @@ class TestPrivateKeyImport: with pytest.raises(HTTPException) as exc: await set_private_key(PrivateKeyUpdate(private_key="aa" * 64)) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 class TestDiscoverMesh: @@ -699,7 +699,7 @@ class TestTracePath: assert "not a repeater" in exc.value.detail @pytest.mark.asyncio - async def test_returns_422_when_no_trace_response_is_heard(self): + async def test_returns_408_when_no_trace_response_is_heard(self): mc = _mock_meshcore_with_info() repeater = Contact( public_key="44" * 32, @@ -741,7 +741,7 @@ class TestTracePath: ) ) - assert exc.value.status_code == 422 + assert exc.value.status_code == 408 assert "No trace response heard" in exc.value.detail @pytest.mark.asyncio @@ -850,7 +850,7 @@ class TestTracePath: with pytest.raises(HTTPException) as exc: await discover_mesh(RadioDiscoveryRequest(target="sensors")) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 assert exc.value.detail == "Failed to start mesh discovery" @pytest.mark.asyncio @@ -887,7 +887,7 @@ class TestTracePath: with pytest.raises(HTTPException) as exc: await set_private_key(PrivateKeyUpdate(private_key="aa" * 64)) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 assert "keystore" in exc.value.detail.lower() # Called twice: initial attempt + one retry assert mock_export.await_count == 2 @@ -926,7 +926,7 @@ class TestAdvertise: with pytest.raises(HTTPException) as exc: await send_advertisement() - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 @pytest.mark.asyncio async def test_defaults_to_flood_mode(self): @@ -1059,7 +1059,7 @@ class TestRebootAndReconnect: assert result["connected"] is True @pytest.mark.asyncio - async def test_reconnect_raises_503_on_failure(self): + async def test_reconnect_raises_423_on_failure(self): mock_rm = MagicMock() mock_rm.is_connected = False mock_rm.is_reconnecting = False @@ -1070,7 +1070,7 @@ class TestRebootAndReconnect: with pytest.raises(HTTPException) as exc: await reconnect_radio() - assert exc.value.status_code == 503 + assert exc.value.status_code == 423 @pytest.mark.asyncio async def test_disconnect_pauses_connection_attempts_and_broadcasts_health(self): diff --git a/tests/test_radio_runtime_service.py b/tests/test_radio_runtime_service.py index 41ae42e..e974c42 100644 --- a/tests/test_radio_runtime_service.py +++ b/tests/test_radio_runtime_service.py @@ -57,12 +57,12 @@ def test_require_connected_preserves_http_semantics(): ) with pytest.raises(HTTPException, match="Radio is initializing") as exc: runtime.require_connected() - assert exc.value.status_code == 503 + assert exc.value.status_code == 423 runtime = RadioRuntime(_Manager(meshcore=None, is_connected=False, is_setup_in_progress=False)) with pytest.raises(HTTPException, match="Radio not connected") as exc: runtime.require_connected() - assert exc.value.status_code == 503 + assert exc.value.status_code == 423 def test_require_connected_returns_fresh_meshcore_after_connectivity_check(): diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index 3a47afd..f759af5 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -302,7 +302,7 @@ class TestRepeaterCommandRoute: with pytest.raises(HTTPException) as exc: await send_repeater_command(KEY_A, CommandRequest(command="ver")) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 mc.start_auto_message_fetching.assert_awaited_once() @pytest.mark.asyncio @@ -502,7 +502,7 @@ class TestTraceRoute: with pytest.raises(HTTPException) as exc: await request_trace(KEY_A) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 mc.commands.send_trace.assert_awaited_once_with( path=KEY_A[:8], tag=1234, @@ -510,7 +510,7 @@ class TestTraceRoute: ) @pytest.mark.asyncio - async def test_wait_timeout_returns_422(self, test_db): + async def test_wait_timeout_returns_408(self, test_db): mc = _mock_mc() await _insert_contact(KEY_A, name="Client", contact_type=1) mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK)) @@ -524,7 +524,7 @@ class TestTraceRoute: with pytest.raises(HTTPException) as exc: await request_trace(KEY_A) - assert exc.value.status_code == 422 + assert exc.value.status_code == 408 mc.commands.send_trace.assert_awaited_once_with( path=KEY_A[:8], tag=1234, @@ -745,7 +745,7 @@ class TestRepeaterStatus: assert response.recv_errors == 42 @pytest.mark.asyncio - async def test_504_on_timeout(self, test_db): + async def test_408_on_timeout(self, test_db): mc = _mock_mc() await _insert_contact(KEY_A, name="Repeater", contact_type=2) mc.commands.req_status_sync = AsyncMock(return_value=None) @@ -756,7 +756,7 @@ class TestRepeaterStatus: ): with pytest.raises(HTTPException) as exc: await repeater_status(KEY_A) - assert exc.value.status_code == 504 + assert exc.value.status_code == 408 @pytest.mark.asyncio async def test_400_not_repeater(self, test_db): @@ -819,7 +819,7 @@ class TestRepeaterLppTelemetry: assert response.sensors == [] @pytest.mark.asyncio - async def test_504_on_timeout(self, test_db): + async def test_408_on_timeout(self, test_db): mc = _mock_mc() await _insert_contact(KEY_A, name="Repeater", contact_type=2) mc.commands.req_telemetry_sync = AsyncMock(return_value=None) @@ -830,7 +830,7 @@ class TestRepeaterLppTelemetry: ): with pytest.raises(HTTPException) as exc: await repeater_lpp_telemetry(KEY_A) - assert exc.value.status_code == 504 + assert exc.value.status_code == 408 @pytest.mark.asyncio async def test_400_not_repeater(self, test_db): @@ -1234,7 +1234,7 @@ class TestBatchCliFetch: with pytest.raises(HTTPException) as exc: await _batch_cli_fetch(contact, "test_op", [("ver", "firmware_version")]) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 assert "Failed to add contact to radio" in exc.value.detail @pytest.mark.asyncio @@ -1307,7 +1307,7 @@ class TestRepeaterAddContactError: with pytest.raises(HTTPException) as exc: await repeater_status(KEY_A) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 assert "Failed to add contact to radio" in exc.value.detail @pytest.mark.asyncio @@ -1325,7 +1325,7 @@ class TestRepeaterAddContactError: with pytest.raises(HTTPException) as exc: await repeater_lpp_telemetry(KEY_A) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 assert "Failed to add contact to radio" in exc.value.detail @pytest.mark.asyncio @@ -1343,7 +1343,7 @@ class TestRepeaterAddContactError: with pytest.raises(HTTPException) as exc: await repeater_neighbors(KEY_A) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 assert "Failed to add contact to radio" in exc.value.detail @pytest.mark.asyncio @@ -1361,5 +1361,5 @@ class TestRepeaterAddContactError: with pytest.raises(HTTPException) as exc: await repeater_acl(KEY_A) - assert exc.value.status_code == 500 + assert exc.value.status_code == 422 assert "Failed to add contact to radio" in exc.value.detail diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index 3b9e4f2..8c8162f 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -646,7 +646,7 @@ class TestOutgoingChannelBroadcast: request = SendChannelMessageRequest(channel_key=chan_key, text="hello") await send_channel_message(request) - assert exc_info.value.status_code == 500 + assert exc_info.value.status_code == 422 assert "regional override" in exc_info.value.detail.lower() mc.commands.set_channel.assert_not_awaited() mc.commands.send_chan_msg.assert_not_awaited() @@ -790,7 +790,7 @@ class TestOutgoingChannelBroadcast: SendChannelMessageRequest(channel_key=chan_key, text="this will fail") ) - assert exc_info.value.status_code == 500 + assert exc_info.value.status_code == 422 assert radio_manager.get_cached_channel_slot(chan_key) is None @@ -969,7 +969,7 @@ class TestResendChannelMessage: assert sent_timestamp == now + 1 @pytest.mark.asyncio - async def test_resend_no_radio_response_returns_504_and_creates_no_new_row(self, test_db): + async def test_resend_no_radio_response_returns_408_and_creates_no_new_row(self, test_db): """When resend returns None, report unknown outcome and create no new message row.""" mc = _make_mc(name="MyNode") chan_key = "c1" * 16 @@ -995,7 +995,7 @@ class TestResendChannelMessage: ): await resend_channel_message(msg_id, new_timestamp=True) - assert exc_info.value.status_code == 504 + assert exc_info.value.status_code == 408 assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL messages = await MessageRepository.get_all( @@ -1317,7 +1317,7 @@ class TestPathHashModeOverride: SendChannelMessageRequest(channel_key=chan_key, text="hello") ) - assert exc_info.value.status_code == 500 + assert exc_info.value.status_code == 422 assert "path hash mode" in exc_info.value.detail.lower() mc.commands.send_chan_msg.assert_not_awaited() @@ -1567,7 +1567,7 @@ class TestRadioExceptionMidSend: assert len(messages) == 0 @pytest.mark.asyncio - async def test_dm_send_no_radio_response_returns_504_without_storing_message(self, test_db): + async def test_dm_send_no_radio_response_returns_408_without_storing_message(self, test_db): """When mc.commands.send_msg() returns None, report unknown outcome and store nothing.""" mc = _make_mc() pub_key = "ac" * 32 @@ -1584,7 +1584,7 @@ class TestRadioExceptionMidSend: SendDirectMessageRequest(destination=pub_key, text="Did this send?") ) - assert exc_info.value.status_code == 504 + assert exc_info.value.status_code == 408 assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL messages = await MessageRepository.get_all( @@ -1593,7 +1593,7 @@ class TestRadioExceptionMidSend: assert len(messages) == 0 @pytest.mark.asyncio - async def test_channel_send_no_radio_response_returns_504_without_storing_message( + async def test_channel_send_no_radio_response_returns_408_without_storing_message( self, test_db ): """When mc.commands.send_chan_msg() returns None, report unknown outcome and store nothing.""" @@ -1612,7 +1612,7 @@ class TestRadioExceptionMidSend: SendChannelMessageRequest(channel_key=chan_key, text="Did this send?") ) - assert exc_info.value.status_code == 504 + assert exc_info.value.status_code == 408 assert exc_info.value.detail == NO_RADIO_RESPONSE_AFTER_SEND_DETAIL messages = await MessageRepository.get_all( @@ -1733,7 +1733,7 @@ class TestRadioExceptionMidSend: SendChannelMessageRequest(channel_key=chan_key_b, text="Never sent") ) - assert exc_info.value.status_code == 500 + assert exc_info.value.status_code == 422 assert radio_manager.get_cached_channel_slot(chan_key_a) is None assert radio_manager.get_cached_channel_slot(chan_key_b) is None mc.commands.send_chan_msg.assert_not_called()