Skip to content

Commit 6d08154

Browse files
Alexj9837tpoliawshree-iyengar-dlsZohebShaikhdan-fernandes
authored
chore: Improve default error handling (#1491)
Fixes #1379 Fixes #1409 Both issues related to error handling in the REST client. 1379 - Catches 'UnauthorisedAccessErrorexplicitly instead of string-matching onBlueskyRemoteControlError`. 1409 - Expands the exception hierarchy so each HTTP status code raises a distinct typed exception rather than bundling everything into BlueskyRemoteControlError. Tests Update tests to match new exception signatures Add missing coverage for 422 non-list detail response Add missing coverage for streaming error in run command --------- Co-authored-by: Peter Holloway <peter.holloway@diamond.ac.uk> Co-authored-by: Shreelakshmi Iyengar <shreelakshmi.iyengar@diamond.ac.uk> Co-authored-by: Zoheb Shaikh <zoheb.shaikh@diamond.ac.uk> Co-authored-by: Daniel Fernandes <65790536+dan-fernandes@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Co-authored-by: Abigail Emery <abigail.emery@diamond.ac.uk> Co-authored-by: oliwenmandiamond <136330507+oliwenmandiamond@users.noreply.github.com> Co-authored-by: NeilSmithDLS <118457264+NeilSmithDLS@users.noreply.github.com>
1 parent ab2ebc6 commit 6d08154

9 files changed

Lines changed: 177 additions & 63 deletions

File tree

src/blueapi/cli/cli.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -254,13 +254,10 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
254254
raise ClickException(
255255
"Failed to establish connection to blueapi server."
256256
) from ce
257-
except BlueskyRemoteControlError as e:
258-
if str(e) == "<Response [401]>":
259-
raise ClickException(
260-
"Access denied. Please check your login status and try again."
261-
) from e
262-
else:
263-
raise e
257+
except UnauthorisedAccessError as e:
258+
raise ClickException(
259+
"Access denied. Please check your login status and try again."
260+
) from e
264261

265262
return wrapper
266263

@@ -394,12 +391,12 @@ def on_event(event: AnyEvent) -> None:
394391
raise ClickException(*mse.args) from mse
395392
except UnknownPlanError as up:
396393
raise ClickException(f"Plan '{name}' was not recognised") from up
397-
except UnauthorisedAccessError as ua:
398-
raise ClickException("Unauthorised request") from ua
399394
except InvalidParametersError as ip:
400395
raise ClickException(ip.message()) from ip
401-
except (BlueskyRemoteControlError, BlueskyStreamingError) as e:
402-
raise ClickException(f"server error with this message: {e}") from e
396+
except BlueskyStreamingError as se:
397+
raise ClickException(f"Streaming error: {se}") from se
398+
except BlueskyRemoteControlError as e:
399+
raise ClickException(f"Remote control error: {e.args[0]}") from e
403400
except ValueError as ve:
404401
raise ClickException(f"task could not run: {ve}") from ve
405402

src/blueapi/client/client.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,13 @@
4242
from blueapi.worker.task_worker import TrackableTask
4343

4444
from .event_bus import AnyEvent, EventBusClient, OnAnyEvent
45-
from .rest import BlueapiRestClient, BlueskyRemoteControlError
45+
from .rest import (
46+
BlueapiRestClient,
47+
BlueskyRemoteControlError,
48+
BlueskyRequestError,
49+
NotFoundError,
50+
ServiceUnavailableError,
51+
)
4652

4753
TRACER = get_tracer("client")
4854

@@ -99,9 +105,8 @@ def __getitem__(self, name: str) -> "DeviceRef":
99105
self._cache[name] = device
100106
setattr(self, model.name, device)
101107
return device
102-
except KeyError:
103-
pass
104-
raise AttributeError(f"No device named '{name}' available")
108+
except NotFoundError as e:
109+
raise AttributeError(f"No device named '{name}' available") from e
105110

106111
def __getattr__(self, name: str) -> "DeviceRef":
107112
if name.startswith("_"):
@@ -668,9 +673,13 @@ def reload_environment(
668673
EnvironmentResponse: Details of the new worker
669674
environment.
670675
"""
671-
672676
try:
673677
status = self._rest.delete_environment()
678+
except (
679+
BlueskyRequestError,
680+
ServiceUnavailableError,
681+
):
682+
raise
674683
except Exception as e:
675684
raise BlueskyRemoteControlError(
676685
"Failed to tear down the environment"

src/blueapi/client/rest.py

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,41 @@
3838
LOGGER = logging.getLogger(__name__)
3939

4040

41-
class UnauthorisedAccessError(Exception):
41+
class BlueskyRequestError(Exception):
42+
"""An error response from the blueapi server."""
43+
44+
def __init__(self, code: int | None = None, message: str = "") -> None:
45+
super().__init__(code, message)
46+
47+
48+
class UnauthorisedAccessError(BlueskyRequestError):
49+
"""Request was rejected due to missing or invalid credentials (401/403)."""
50+
4251
pass
4352

4453

45-
class BlueskyRemoteControlError(Exception):
54+
class NotFoundError(BlueskyRequestError):
55+
"""Requested something that couldn't be found (404)."""
56+
4657
pass
4758

4859

49-
class NonJsonResponseError(Exception):
60+
class UnknownPlanError(BlueskyRequestError):
61+
"""Plan '{name}' was not recognised"""
62+
5063
pass
5164

5265

53-
class BlueskyRequestError(Exception):
54-
def __init__(self, code: int, message: str) -> None:
55-
super().__init__(message, code)
66+
class BlueskyRemoteControlError(Exception):
67+
"""Unexpected or failed response from the blueapi server."""
68+
69+
pass
70+
71+
72+
class NonJsonResponseError(Exception):
73+
"""Server returned a response that could not be parsed as JSON."""
74+
75+
pass
5676

5777

5878
class NoContentError(Exception):
@@ -105,28 +125,35 @@ def from_validation_error(cls, ve: ValidationError):
105125
)
106126

107127

108-
class UnknownPlanError(Exception):
109-
pass
110-
111-
112128
def _exception(response: requests.Response) -> Exception | None:
113129
code = response.status_code
114130
if code < 400:
115131
return None
132+
elif code in (401, 403):
133+
return UnauthorisedAccessError(code, response.text)
116134
elif code == 404:
117-
return KeyError(str(_response_json(response)))
135+
return NotFoundError(code, response.text)
118136
else:
119-
return BlueskyRemoteControlError(code, str(response))
137+
try:
138+
body = _response_json(response)
139+
message = (
140+
body.get("detail", response.text)
141+
if isinstance(body, dict)
142+
else response.text
143+
)
144+
except NonJsonResponseError:
145+
message = response.text
146+
return BlueskyRemoteControlError(code, message)
120147

121148

122149
def _create_task_exceptions(response: requests.Response) -> Exception | None:
123150
code = response.status_code
124151
if code < 400:
125152
return None
126153
elif code == 401 or code == 403:
127-
return UnauthorisedAccessError()
154+
return UnauthorisedAccessError(code, response.text)
128155
elif code == 404:
129-
return UnknownPlanError()
156+
return UnknownPlanError(code, response.text)
130157
elif code == 422:
131158
try:
132159
content = _response_json(response)
@@ -173,7 +200,10 @@ def get_plans(self) -> PlanResponse:
173200
return self._request_and_deserialize("/plans", PlanResponse)
174201

175202
def get_plan(self, name: str) -> PlanModel:
176-
return self._request_and_deserialize(f"/plans/{name}", PlanModel)
203+
try:
204+
return self._request_and_deserialize(f"/plans/{name}", PlanModel)
205+
except NotFoundError as e:
206+
raise UnknownPlanError(404, f"Plan '{name}' not found") from e
177207

178208
def get_devices(self) -> DeviceResponse:
179209
return self._request_and_deserialize("/devices", DeviceResponse)

src/blueapi/service/main.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -534,11 +534,20 @@ def set_state(
534534
state_change_request.new_state is WorkerState.ABORTING,
535535
state_change_request.reason,
536536
)
537-
except TransitionError:
538-
response.status_code = status.HTTP_400_BAD_REQUEST
537+
except TransitionError as e:
538+
suffix = f" - {e}" if str(e) else ""
539+
raise HTTPException(
540+
status.HTTP_400_BAD_REQUEST,
541+
detail=(
542+
f"Error while transitioning from {current_state} "
543+
f"to {new_state}{suffix}"
544+
),
545+
) from e
539546
else:
540-
response.status_code = status.HTTP_400_BAD_REQUEST
541-
547+
raise HTTPException(
548+
status_code=status.HTTP_400_BAD_REQUEST,
549+
detail=(f"Cannot transition from {current_state} to {new_state}"),
550+
)
542551
return runner.run(interface.get_worker_state)
543552

544553

tests/system_tests/test_blueapi_system.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
BlueapiRestClient,
1616
BlueskyRemoteControlError,
1717
BlueskyRequestError,
18+
NotFoundError,
1819
ServiceUnavailableError,
20+
UnauthorisedAccessError,
21+
UnknownPlanError,
1922
)
2023
from blueapi.config import (
2124
ApplicationConfig,
@@ -217,7 +220,7 @@ def test_cannot_access_endpoints(
217220
"get_oidc_config"
218221
) # get_oidc_config can be accessed without auth
219222
for get_method in blueapi_rest_client_get_methods:
220-
with pytest.raises(BlueskyRemoteControlError, match=r"<Response \[401\]>"):
223+
with pytest.raises(UnauthorisedAccessError, match=r"Not authenticated"):
221224
getattr(client_without_auth._rest, get_method)()
222225

223226

@@ -243,7 +246,7 @@ def test_get_plans_by_name(client: BlueapiClient, expected_plans: PlanResponse):
243246

244247

245248
def test_get_non_existent_plan(rest_client: BlueapiRestClient):
246-
with pytest.raises(KeyError, match="{'detail': 'Item not found'}"):
249+
with pytest.raises(UnknownPlanError, match=r"Plan 'Not exists' not found"):
247250
rest_client.get_plan("Not exists")
248251

249252

@@ -268,7 +271,7 @@ def test_get_device_by_name(
268271

269272

270273
def test_get_non_existent_device(rest_client: BlueapiRestClient):
271-
with pytest.raises(KeyError, match="{'detail': 'Item not found'}"):
274+
with pytest.raises(NotFoundError, match=r"Item not found"):
272275
rest_client.get_device("Not exists")
273276

274277

@@ -336,12 +339,12 @@ def test_get_task_by_id(rest_client: BlueapiRestClient):
336339

337340

338341
def test_get_non_existent_task(rest_client: BlueapiRestClient):
339-
with pytest.raises(KeyError, match="{'detail': 'Item not found'}"):
342+
with pytest.raises(NotFoundError, match=r"Item not found"):
340343
rest_client.get_task("Not-exists")
341344

342345

343346
def test_delete_non_existent_task(rest_client: BlueapiRestClient):
344-
with pytest.raises(KeyError, match="{'detail': 'Item not found'}"):
347+
with pytest.raises(NotFoundError, match=r"Item not found"):
345348
rest_client.clear_task("Not-exists")
346349

347350

@@ -363,7 +366,7 @@ def test_put_worker_task_fails_if_not_idle(rest_client: BlueapiRestClient):
363366

364367
with pytest.raises(BlueskyRemoteControlError) as exception:
365368
rest_client.update_worker_task(WorkerTask(task_id=small_task.task_id))
366-
assert "<Response [409]>" in str(exception)
369+
assert exception.value.args[0] == 409
367370
rest_client.cancel_current_task(WorkerState.ABORTING)
368371
rest_client.clear_task(small_task.task_id)
369372
rest_client.clear_task(long_task.task_id)
@@ -376,10 +379,10 @@ def test_get_worker_state(client: BlueapiClient):
376379
def test_set_state_transition_error(client: BlueapiClient):
377380
with pytest.raises(BlueskyRemoteControlError) as exception:
378381
client.resume()
379-
assert "<Response [400]>" in str(exception)
382+
assert "Cannot transition from IDLE to RUNNING" in exception.value.args[1]
380383
with pytest.raises(BlueskyRemoteControlError) as exception:
381384
client.pause()
382-
assert "<Response [400]>" in str(exception)
385+
assert "Cannot transition from IDLE to PAUSED" in exception.value.args[1]
383386

384387

385388
def test_get_task_by_status(rest_client: BlueapiRestClient):
@@ -621,7 +624,7 @@ def on_event(event: AnyEvent) -> None:
621624

622625
# Regression test for #1480
623626
def test_task_submission_after_invalid_task(client_with_stomp: BlueapiClient):
624-
with pytest.raises(KeyError):
627+
with pytest.raises(NotFoundError):
625628
# This task hasn't been submitted so should return an error...
626629
client_with_stomp._rest.update_worker_task(WorkerTask(task_id="missing"))
627630

tests/unit_tests/cli/test_cli.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,8 @@ def test_connection_error_caught_by_wrapper_func(
118118
def test_authentication_error_caught_by_wrapper_func(
119119
mock_requests: Mock, runner: CliRunner
120120
):
121-
mock_requests.side_effect = BlueskyRemoteControlError("<Response [401]>")
121+
mock_requests.side_effect = UnauthorisedAccessError(message="<Response [401]>")
122122
result = runner.invoke(main, ["controller", "plans"])
123-
124123
assert (
125124
result.output
126125
== "Error: Access denied. Please check your login status and try again.\n"
@@ -130,7 +129,6 @@ def test_authentication_error_caught_by_wrapper_func(
130129
@patch("blueapi.client.rest.requests.Session.request")
131130
def test_remote_error_raised_by_wrapper_func(mock_requests: Mock, runner: CliRunner):
132131
mock_requests.side_effect = BlueskyRemoteControlError("Response [450]")
133-
134132
result = runner.invoke(main, ["controller", "plans"])
135133
assert (
136134
isinstance(result.exception, BlueskyRemoteControlError)
@@ -701,7 +699,10 @@ def test_env_reload_server_side_error(runner: CliRunner):
701699
"exception, error_message",
702700
[
703701
(UnknownPlanError(), "Error: Plan 'sleep' was not recognised\n"),
704-
(UnauthorisedAccessError(), "Error: Unauthorised request\n"),
702+
(
703+
UnauthorisedAccessError(),
704+
"Error: Access denied. Please check your login status and try again.\n",
705+
),
705706
(
706707
InvalidParametersError(
707708
errors=[
@@ -717,19 +718,24 @@ def test_env_reload_server_side_error(runner: CliRunner):
717718
),
718719
(
719720
BlueskyRemoteControlError("Server error"),
720-
"Error: server error with this message: Server error\n",
721+
"Error: Remote control error: Server error\n",
721722
),
722723
(
723724
ValueError("Error parsing parameters"),
724725
"Error: task could not run: Error parsing parameters\n",
725726
),
727+
(
728+
BlueskyStreamingError("streaming failed"),
729+
"Error: Streaming error: streaming failed\n",
730+
),
726731
],
727732
ids=[
728733
"unknown_plan",
729734
"unauthorised_access",
730735
"invalid_parameters",
731736
"remote_control",
732737
"value_error",
738+
"streaming_error",
733739
],
734740
)
735741
def test_error_handling(exception, error_message, runner: CliRunner):
@@ -1163,7 +1169,7 @@ def test_invalid_json(
11631169
responses.GET,
11641170
"http://localhost:8000/config/oidc",
11651171
body="blah blah",
1166-
status=404,
1172+
status=200,
11671173
)
11681174

11691175
result = runner.invoke(main, ["-c", config_with_auth, "login"])

0 commit comments

Comments
 (0)