Skip to content

Commit c404b99

Browse files
authored
further improvements to error handling (#13)
* handle no devices found cleanly * remove magic strings in requests/response creation * further error handling refinement
1 parent d8d201e commit c404b99

File tree

6 files changed

+57
-63
lines changed

6 files changed

+57
-63
lines changed

src/usb_remote/__main__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def list_command(
131131

132132
logger.debug(f"Listing remote USB devices on hosts: {servers}")
133133

134-
results = list_devices(server_hosts=servers, server_port=5055)
134+
results = list_devices(server_hosts=servers)
135135

136136
for server, devices in results.items():
137137
typer.echo(f"\n=== {server} ===")
@@ -379,8 +379,9 @@ def main(args: Sequence[str] | None = None) -> None:
379379
"""Argument parser for the CLI."""
380380
try:
381381
app()
382-
except RuntimeError as e:
383-
typer.echo(f"Error: {e}", err=True)
382+
except Exception as e:
383+
logger.debug("Exception caught in main()", exc_info=True)
384+
typer.echo(f"ERROR: {e}", err=True)
384385

385386

386387
if __name__ == "__main__":

src/usb_remote/api.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from .usbdevice import UsbDevice
88

9+
PORT = 5055
10+
911

1012
class StrictBaseModel(BaseModel):
1113
"""Base model with strict validation - no extra fields allowed."""
@@ -19,6 +21,11 @@ class ListRequest(StrictBaseModel):
1921
command: Literal["list"] = "list"
2022

2123

24+
find_command = "find"
25+
attach_command = "attach"
26+
detach_command = "detach"
27+
28+
2229
class DeviceRequest(StrictBaseModel):
2330
"""Request to find/attach/detach a USB device."""
2431

@@ -44,6 +51,11 @@ class DeviceResponse(StrictBaseModel):
4451
data: UsbDevice
4552

4653

54+
error_response = "error"
55+
not_found_response = "not_found"
56+
multiple_matches_response = "multiple_matches"
57+
58+
4759
class ErrorResponse(StrictBaseModel):
4860
"""Error response."""
4961

src/usb_remote/client.py

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44
from pydantic import TypeAdapter
55

66
from .api import (
7+
PORT,
78
DeviceRequest,
89
DeviceResponse,
910
ErrorResponse,
1011
ListRequest,
1112
ListResponse,
13+
attach_command,
14+
detach_command,
15+
find_command,
1216
)
1317
from .config import get_timeout
1418
from .port import Port
@@ -24,7 +28,7 @@
2428
def send_request(
2529
request: ListRequest | DeviceRequest,
2630
server_host: str = "localhost",
27-
server_port: int = 5055,
31+
server_port: int = PORT,
2832
timeout: float | None = None,
2933
) -> ListResponse | DeviceResponse:
3034
"""
@@ -89,7 +93,6 @@ def send_request(
8993

9094
def list_devices(
9195
server_hosts: list[str],
92-
server_port: int = 5055,
9396
timeout: float | None = None,
9497
) -> dict[str, list[UsbDevice]]:
9598
"""
@@ -112,7 +115,7 @@ def list_devices(
112115
for server in server_hosts:
113116
try:
114117
request = ListRequest()
115-
response = send_request(request, server, server_port, timeout=timeout)
118+
response = send_request(request, server, timeout=timeout)
116119
assert isinstance(response, ListResponse)
117120
results[server] = response.data
118121
logger.debug(f"Server {server}: {len(response.data)} devices")
@@ -148,7 +151,6 @@ def attach_device(bus_id: str, server_host: str) -> None:
148151
Args:
149152
bus_id: The bus ID of the device to attach
150153
server_host: Server hostname or IP address
151-
server_port: Server port number
152154
timeout: Connection timeout in seconds. If None, uses configured timeout.
153155
"""
154156

@@ -159,7 +161,7 @@ def attach_device(bus_id: str, server_host: str) -> None:
159161

160162
logger.debug(f"Asking remote {server_host} to bind {bus_id} to usbip")
161163
request = DeviceRequest(
162-
command="attach",
164+
command=attach_command,
163165
bus=bus_id,
164166
)
165167
send_request(request, server_host)
@@ -186,14 +188,13 @@ def detach_device(bus_id: str, server_host: str) -> None:
186188
Args:
187189
bus_id: The bus ID of the device to detach
188190
server_host: Server hostname or IP address
189-
server_port: Server port number
190191
timeout: Connection timeout in seconds. If None, uses configured timeout.
191192
"""
192193
detach_local_device(bus_id, server_host)
193194

194195
logger.debug(f"Asking remote {server_host} to unbind {bus_id} from usbip")
195196
request = DeviceRequest(
196-
command="detach",
197+
command=detach_command,
197198
bus=bus_id,
198199
)
199200
send_request(request, server_host)
@@ -219,7 +220,6 @@ def find_device(
219220
Args:
220221
args: AttachRequest with device search criteria
221222
server_hosts: list of server hostnames/IPs
222-
server_port: Server port number
223223
timeout: Connection timeout in seconds. If None, uses configured timeout.
224224
225225
Returns:
@@ -235,7 +235,7 @@ def find_device(
235235
)
236236

237237
request = DeviceRequest(
238-
command="find",
238+
command=find_command,
239239
id=id,
240240
bus=bus,
241241
desc=desc,
@@ -258,26 +258,25 @@ def find_device(
258258
continue
259259
except MultipleDevicesError as e:
260260
# Multiple matches on this server
261-
logger.error(f"Error on Server {server}:\n{e}")
262-
exit(1)
263-
except RuntimeError as e:
264-
# Server returned a generic error
265-
logger.error(f"Server {server} error: {e}")
261+
raise RuntimeError(f"Server {server}:\n{e}") from e
262+
except Exception as e:
263+
# Server returned a generic error - continue to next server
264+
logger.error(f"Server {server}:\n {e}")
266265
continue
267266

268267
if len(matches) == 0:
269268
msg = f"No matching device found across {len(server_hosts)} servers"
270-
logger.error(msg)
269+
logger.debug(msg, exc_info=True)
271270
raise DeviceNotFoundError(msg)
272271

273272
if len(matches) > 1 and not request.first:
274273
device_list = "\n".join(f" {dev} (on {srv})" for dev, srv in matches)
275274
msg = (
276275
f"Multiple devices matched across servers:\n{device_list}\n\n"
277-
"Use --first to attach the first match."
276+
"Use --first to attach to the first match."
278277
)
279-
logger.error(msg)
280-
exit(1)
278+
logger.debug(msg, exc_info=True)
279+
raise MultipleDevicesError(msg)
281280

282281
device, server = matches[0]
283282

src/usb_remote/server.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import logging
22
import socket
33
import threading
4+
from typing import Literal
45

56
from pydantic import TypeAdapter, ValidationError
67

78
from .api import (
9+
PORT,
810
DeviceRequest,
911
DeviceResponse,
1012
ErrorResponse,
1113
ListRequest,
1214
ListResponse,
15+
error_response,
16+
multiple_matches_response,
17+
not_found_response,
1318
)
1419
from .usbdevice import (
1520
DeviceNotFoundError,
@@ -24,7 +29,7 @@
2429

2530

2631
class CommandServer:
27-
def __init__(self, host: str = "0.0.0.0", port: int = 5055):
32+
def __init__(self, host: str = "0.0.0.0", port: int = PORT):
2833
self.host = host
2934
self.port = port
3035
self.server_socket = None
@@ -77,28 +82,36 @@ def _send_response(
7782
"""Send a JSON response to the client."""
7883
client_socket.sendall(response.model_dump_json().encode("utf-8") + b"\n")
7984

85+
def _send_error_response(
86+
self,
87+
client_socket: socket.socket,
88+
status: Literal["error", "not_found", "multiple_matches"],
89+
message: str,
90+
):
91+
"""Send an error response to the client."""
92+
response = ErrorResponse(status=status, message=message)
93+
self._send_response(client_socket, response)
94+
8095
def handle_client(self, client_socket: socket.socket, address):
8196
"""Handle individual client connections."""
8297

8398
try:
8499
data = client_socket.recv(1024).decode("utf-8")
85100

86101
if not data:
87-
response = ErrorResponse(
88-
status="error", message="Empty or invalid command"
102+
self._send_error_response(
103+
client_socket, error_response, "Empty or invalid command"
89104
)
90-
self._send_response(client_socket, response)
91105
return
92106

93107
# Try to parse as either ListRequest or AttachRequest
94108
request_adapter = TypeAdapter(ListRequest | DeviceRequest)
95109
try:
96110
request = request_adapter.validate_json(data)
97111
except ValidationError as e:
98-
response = ErrorResponse(
99-
status="error", message=f"Invalid request format: {str(e)}"
112+
self._send_error_response(
113+
client_socket, error_response, f"Invalid request format: {str(e)}"
100114
)
101-
self._send_response(client_socket, response)
102115
return
103116

104117
logger.info(
@@ -117,23 +130,17 @@ def handle_client(self, client_socket: socket.socket, address):
117130

118131
except DeviceNotFoundError as e:
119132
logger.warning(f"Device not found for client {address}: {e}")
120-
response = ErrorResponse(status="not_found", message=str(e))
121-
self._send_response(client_socket, response)
133+
self._send_error_response(client_socket, not_found_response, str(e))
122134
except MultipleDevicesError as e:
123135
logger.warning(f"Multiple devices matched for client {address}: {e}")
124-
response = ErrorResponse(status="multiple_matches", message=str(e))
125-
self._send_response(client_socket, response)
136+
self._send_error_response(client_socket, multiple_matches_response, str(e))
126137
except Exception as e:
127138
logger.error(f"Error handling client {address}: {e}")
128-
response = ErrorResponse(status="error", message=str(e))
129-
self._send_response(client_socket, response)
139+
self._send_error_response(client_socket, error_response, str(e))
130140

131141
finally:
132142
client_socket.close()
133143

134-
def _respond_to_client(self, client_socket, response):
135-
self._send_response(client_socket, response)
136-
137144
def start(self):
138145
"""Start the server."""
139146
logger.debug(f"Starting server on {self.host}:{self.port}")

tests/test_cli.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,7 @@ def test_list_with_host(self, mock_usb_devices):
9191
) as mock_list:
9292
result = runner.invoke(app, ["list", "--host", "192.168.1.100"])
9393
assert result.exit_code == 0
94-
mock_list.assert_called_once_with(
95-
server_hosts=["192.168.1.100"], server_port=5055
96-
)
94+
mock_list.assert_called_once_with(server_hosts=["192.168.1.100"])
9795

9896
def test_list_error_handling(self):
9997
"""Test list command error handling."""

tests/test_protocol.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
ListRequest,
3535
ListResponse,
3636
)
37-
from usb_remote.client import list_devices
3837
from usb_remote.server import CommandServer
3938
from usb_remote.usbdevice import UsbDevice
4039

@@ -244,17 +243,6 @@ def test_error_response_deserialization(self):
244243
class TestClientServerIntegration:
245244
"""Integration tests for client-server communication."""
246245

247-
def test_list_devices_integration(self, server, server_port, mock_usb_devices):
248-
"""Test full list devices flow from client to server."""
249-
result = list_devices(server_hosts=["127.0.0.1"], server_port=server_port)
250-
251-
assert "127.0.0.1" in result
252-
devices = result["127.0.0.1"]
253-
assert len(devices) == 2
254-
assert devices[0].bus_id == "1-1.1"
255-
assert devices[0].vendor_id == "1234"
256-
assert devices[1].bus_id == "2-2.1"
257-
258246
def test_find_device_integration(self, server, server_port, mock_usb_devices):
259247
"""Test full find device flow from client to server."""
260248
from usb_remote.client import find_device
@@ -324,17 +312,6 @@ def test_server_handles_unknown_command(self, server, server_port):
324312

325313
assert parsed["status"] == "error"
326314

327-
def test_client_handles_error_response(self, server, server_port, mock_get_devices):
328-
"""Test that client properly handles error responses."""
329-
# Override the mock to raise an exception
330-
with patch(
331-
"usb_remote.server.get_devices", side_effect=Exception("Test error")
332-
):
333-
result = list_devices(server_hosts=["127.0.0.1"], server_port=server_port)
334-
# In multi-server mode, errors are caught and the server returns empty list
335-
assert "127.0.0.1" in result
336-
assert result["127.0.0.1"] == []
337-
338315

339316
class TestProtocolRobustness:
340317
"""Test protocol robustness and edge cases."""

0 commit comments

Comments
 (0)