Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
30f7c58
chore: Improve default error handling and enhance exception messages
Alexj9837 Mar 11, 2026
65764a8
fix: Use typed exceptions in cli and client
Alexj9837 Mar 11, 2026
58d910c
test: Update tests for new exception hierarchy
Alexj9837 Mar 11, 2026
44dc8ed
fix: Address PR review comments
Alexj9837 Mar 17, 2026
0a9cbff
added content for test
Alexj9837 Mar 17, 2026
b3a5fb3
Update src/blueapi/cli/cli.py
Alexj9837 Apr 10, 2026
3d1b210
updated expected error message for poor grammar
Alexj9837 Apr 10, 2026
71fa2e7
Removing "I think this will prevent your new check_connection change…
Alexj9837 Apr 13, 2026
ddfdf00
Fix: 400 responsed previously raised a bare status with no body, upd…
Alexj9837 Apr 14, 2026
ecfe2b7
chore: improve default error handling 1379 1409 (#1490)
Alexj9837 Apr 16, 2026
60ec975
add another test for non_json_body
Alexj9837 Apr 22, 2026
0190147
chasing from raising a KeyError to a NotFoundError
Alexj9837 Apr 22, 2026
869faff
Update src/blueapi/client/rest.py
Alexj9837 Apr 22, 2026
13cdba0
responding to PR feedback
Alexj9837 Apr 22, 2026
ea376ce
formatting
Alexj9837 Apr 22, 2026
554181c
raise correct error message for reload_enviroment
Alexj9837 Apr 23, 2026
01dff9e
import json at the top
Alexj9837 Apr 23, 2026
f29f33a
feedback from PR comments
Alexj9837 Apr 23, 2026
69357f3
failing test
Alexj9837 May 27, 2026
041ad53
Merge branch 'main' into chore/improve-default-error-handling-1379-1409
ZohebShaikh May 28, 2026
7bc4e11
Potential fix for pull request finding 'Module is imported more than …
ZohebShaikh May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions src/blueapi/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,10 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
raise ClickException(
"Failed to establish connection to blueapi server."
) from ce
except BlueskyRemoteControlError as e:
if str(e) == "<Response [401]>":
raise ClickException(
"Access denied. Please check your login status and try again."
) from e
else:
raise e
except UnauthorisedAccessError as e:
raise ClickException(
"Access denied. Please check your login status and try again."
) from e

return wrapper

Expand Down Expand Up @@ -394,12 +391,12 @@ def on_event(event: AnyEvent) -> None:
raise ClickException(*mse.args) from mse
except UnknownPlanError as up:
raise ClickException(f"Plan '{name}' was not recognised") from up
except UnauthorisedAccessError as ua:
raise ClickException("Unauthorised request") from ua
except InvalidParametersError as ip:
raise ClickException(ip.message()) from ip
except (BlueskyRemoteControlError, BlueskyStreamingError) as e:
raise ClickException(f"server error with this message: {e}") from e
except BlueskyStreamingError as se:
raise ClickException(f"Streaming error: {se}") from se
except BlueskyRemoteControlError as e:
raise ClickException(f"Remote control error: {e.args[0]}") from e
except ValueError as ve:
raise ClickException(f"task could not run: {ve}") from ve

Expand Down
19 changes: 14 additions & 5 deletions src/blueapi/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@
from blueapi.worker.task_worker import TrackableTask

from .event_bus import AnyEvent, EventBusClient, OnAnyEvent
from .rest import BlueapiRestClient, BlueskyRemoteControlError
from .rest import (
BlueapiRestClient,
BlueskyRemoteControlError,
BlueskyRequestError,
NotFoundError,
ServiceUnavailableError,
)

TRACER = get_tracer("client")

Expand Down Expand Up @@ -99,9 +105,8 @@ def __getitem__(self, name: str) -> "DeviceRef":
self._cache[name] = device
setattr(self, model.name, device)
return device
except KeyError:
pass
raise AttributeError(f"No device named '{name}' available")
except NotFoundError as e:
raise AttributeError(f"No device named '{name}' available") from e

def __getattr__(self, name: str) -> "DeviceRef":
if name.startswith("_"):
Expand Down Expand Up @@ -668,9 +673,13 @@ def reload_environment(
EnvironmentResponse: Details of the new worker
environment.
"""

try:
status = self._rest.delete_environment()
except (
BlueskyRequestError,
ServiceUnavailableError,
):
raise
except Exception as e:
raise BlueskyRemoteControlError(
"Failed to tear down the environment"
Expand Down
60 changes: 45 additions & 15 deletions src/blueapi/client/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,41 @@
LOGGER = logging.getLogger(__name__)


class UnauthorisedAccessError(Exception):
class BlueskyRequestError(Exception):
"""An error response from the blueapi server."""

def __init__(self, code: int | None = None, message: str = "") -> None:
super().__init__(code, message)


class UnauthorisedAccessError(BlueskyRequestError):
"""Request was rejected due to missing or invalid credentials (401/403)."""

pass


class BlueskyRemoteControlError(Exception):
class NotFoundError(BlueskyRequestError):
"""Requested something that couldn't be found (404)."""

pass


class NonJsonResponseError(Exception):
class UnknownPlanError(BlueskyRequestError):
Comment thread
Alexj9837 marked this conversation as resolved.
"""Plan '{name}' was not recognised"""

pass


class BlueskyRequestError(Exception):
def __init__(self, code: int, message: str) -> None:
super().__init__(message, code)
class BlueskyRemoteControlError(Exception):
"""Unexpected or failed response from the blueapi server."""

pass


class NonJsonResponseError(Exception):
"""Server returned a response that could not be parsed as JSON."""

pass


class NoContentError(Exception):
Expand Down Expand Up @@ -105,28 +125,35 @@ def from_validation_error(cls, ve: ValidationError):
)


class UnknownPlanError(Exception):
pass


def _exception(response: requests.Response) -> Exception | None:
code = response.status_code
if code < 400:
return None
elif code in (401, 403):
return UnauthorisedAccessError(code, response.text)
elif code == 404:
return KeyError(str(_response_json(response)))
return NotFoundError(code, response.text)
else:
return BlueskyRemoteControlError(code, str(response))
try:
body = _response_json(response)
message = (
body.get("detail", response.text)
if isinstance(body, dict)
else response.text
)
except NonJsonResponseError:
message = response.text
return BlueskyRemoteControlError(code, message)


def _create_task_exceptions(response: requests.Response) -> Exception | None:
code = response.status_code
if code < 400:
return None
elif code == 401 or code == 403:
return UnauthorisedAccessError()
return UnauthorisedAccessError(code, response.text)
elif code == 404:
return UnknownPlanError()
return UnknownPlanError(code, response.text)
elif code == 422:
try:
content = _response_json(response)
Expand Down Expand Up @@ -173,7 +200,10 @@ def get_plans(self) -> PlanResponse:
return self._request_and_deserialize("/plans", PlanResponse)

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

def get_devices(self) -> DeviceResponse:
return self._request_and_deserialize("/devices", DeviceResponse)
Expand Down
17 changes: 13 additions & 4 deletions src/blueapi/service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,11 +534,20 @@ def set_state(
state_change_request.new_state is WorkerState.ABORTING,
state_change_request.reason,
)
except TransitionError:
response.status_code = status.HTTP_400_BAD_REQUEST
except TransitionError as e:
suffix = f" - {e}" if str(e) else ""
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=(
f"Error while transitioning from {current_state} "
f"to {new_state}{suffix}"
),
) from e
else:
response.status_code = status.HTTP_400_BAD_REQUEST

raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(f"Cannot transition from {current_state} to {new_state}"),
)
return runner.run(interface.get_worker_state)


Expand Down
21 changes: 12 additions & 9 deletions tests/system_tests/test_blueapi_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
BlueapiRestClient,
BlueskyRemoteControlError,
BlueskyRequestError,
NotFoundError,
ServiceUnavailableError,
UnauthorisedAccessError,
UnknownPlanError,
)
from blueapi.config import (
ApplicationConfig,
Expand Down Expand Up @@ -217,7 +220,7 @@ def test_cannot_access_endpoints(
"get_oidc_config"
) # get_oidc_config can be accessed without auth
for get_method in blueapi_rest_client_get_methods:
with pytest.raises(BlueskyRemoteControlError, match=r"<Response \[401\]>"):
with pytest.raises(UnauthorisedAccessError, match=r"Not authenticated"):
getattr(client_without_auth._rest, get_method)()


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


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


Expand All @@ -268,7 +271,7 @@ def test_get_device_by_name(


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


Expand Down Expand Up @@ -336,12 +339,12 @@ def test_get_task_by_id(rest_client: BlueapiRestClient):


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


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


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

with pytest.raises(BlueskyRemoteControlError) as exception:
rest_client.update_worker_task(WorkerTask(task_id=small_task.task_id))
assert "<Response [409]>" in str(exception)
assert exception.value.args[0] == 409
rest_client.cancel_current_task(WorkerState.ABORTING)
rest_client.clear_task(small_task.task_id)
rest_client.clear_task(long_task.task_id)
Expand All @@ -376,10 +379,10 @@ def test_get_worker_state(client: BlueapiClient):
def test_set_state_transition_error(client: BlueapiClient):
with pytest.raises(BlueskyRemoteControlError) as exception:
client.resume()
assert "<Response [400]>" in str(exception)
assert "Cannot transition from IDLE to RUNNING" in exception.value.args[1]
with pytest.raises(BlueskyRemoteControlError) as exception:
client.pause()
assert "<Response [400]>" in str(exception)
assert "Cannot transition from IDLE to PAUSED" in exception.value.args[1]


def test_get_task_by_status(rest_client: BlueapiRestClient):
Expand Down Expand Up @@ -621,7 +624,7 @@ def on_event(event: AnyEvent) -> None:

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

Expand Down
18 changes: 12 additions & 6 deletions tests/unit_tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,8 @@ def test_connection_error_caught_by_wrapper_func(
def test_authentication_error_caught_by_wrapper_func(
mock_requests: Mock, runner: CliRunner
):
mock_requests.side_effect = BlueskyRemoteControlError("<Response [401]>")
mock_requests.side_effect = UnauthorisedAccessError(message="<Response [401]>")
result = runner.invoke(main, ["controller", "plans"])

assert (
result.output
== "Error: Access denied. Please check your login status and try again.\n"
Expand All @@ -130,7 +129,6 @@ def test_authentication_error_caught_by_wrapper_func(
@patch("blueapi.client.rest.requests.Session.request")
def test_remote_error_raised_by_wrapper_func(mock_requests: Mock, runner: CliRunner):
mock_requests.side_effect = BlueskyRemoteControlError("Response [450]")

result = runner.invoke(main, ["controller", "plans"])
assert (
isinstance(result.exception, BlueskyRemoteControlError)
Expand Down Expand Up @@ -701,7 +699,10 @@ def test_env_reload_server_side_error(runner: CliRunner):
"exception, error_message",
[
(UnknownPlanError(), "Error: Plan 'sleep' was not recognised\n"),
(UnauthorisedAccessError(), "Error: Unauthorised request\n"),
(
UnauthorisedAccessError(),
"Error: Access denied. Please check your login status and try again.\n",
),
(
InvalidParametersError(
errors=[
Expand All @@ -717,19 +718,24 @@ def test_env_reload_server_side_error(runner: CliRunner):
),
(
BlueskyRemoteControlError("Server error"),
"Error: server error with this message: Server error\n",
"Error: Remote control error: Server error\n",
),
(
ValueError("Error parsing parameters"),
"Error: task could not run: Error parsing parameters\n",
),
(
BlueskyStreamingError("streaming failed"),
"Error: Streaming error: streaming failed\n",
),
],
ids=[
"unknown_plan",
"unauthorised_access",
"invalid_parameters",
"remote_control",
"value_error",
"streaming_error",
],
)
def test_error_handling(exception, error_message, runner: CliRunner):
Expand Down Expand Up @@ -1163,7 +1169,7 @@ def test_invalid_json(
responses.GET,
"http://localhost:8000/config/oidc",
body="blah blah",
status=404,
status=200,
)

result = runner.invoke(main, ["-c", config_with_auth, "login"])
Expand Down
15 changes: 13 additions & 2 deletions tests/unit_tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
PlanFailedError,
)
from blueapi.client.event_bus import AnyEvent, EventBusClient
from blueapi.client.rest import BlueapiRestClient, BlueskyRemoteControlError
from blueapi.client.rest import (
BlueapiRestClient,
BlueskyRemoteControlError,
NotFoundError,
)
from blueapi.config import MissingStompConfigurationError
from blueapi.core import DataEvent
from blueapi.service.model import (
Expand Down Expand Up @@ -99,7 +103,14 @@ def mock_rest() -> BlueapiRestClient:
mock.get_plans.return_value = PLANS
mock.get_plan.side_effect = lambda n: {p.name: p for p in PLANS.plans}[n]
mock.get_devices.return_value = DEVICES
mock.get_device.side_effect = lambda n: {d.name: d for d in DEVICES.devices}[n]
device_map = {d.name: d for d in DEVICES.devices}

def get_device(n: str):
if n not in device_map:
raise NotFoundError(404, "<Response [404]>")
return device_map[n]

mock.get_device.side_effect = get_device
Comment thread
Alexj9837 marked this conversation as resolved.
mock.get_state.return_value = WorkerState.IDLE
mock.get_task.return_value = TASK
mock.get_all_tasks.return_value = TASKS
Expand Down
Loading
Loading