mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-03 12:03:04 +02:00
Do full rewrite of 5xx => 4xx
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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 ──────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user