Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2756a2b
chore: Improve default error handling and enhance exception messages
Alexj9837 Mar 11, 2026
94657ad
fix: Use typed exceptions in cli and client
Alexj9837 Mar 11, 2026
b2061c7
test: Update tests for new exception hierarchy
Alexj9837 Mar 11, 2026
575518b
fix: Address PR review comments
Alexj9837 Mar 17, 2026
f33a736
added content for test
Alexj9837 Mar 17, 2026
4cb9605
Update src/blueapi/cli/cli.py
Alexj9837 Apr 10, 2026
4de01fa
updated expected error message for poor grammar
Alexj9837 Apr 10, 2026
04973f0
Removing "I think this will prevent your new check_connection change…
Alexj9837 Apr 13, 2026
b3cc259
Fix: 400 responsed previously raised a bare status with no body, upd…
Alexj9837 Apr 14, 2026
d61ea10
chore: improve default error handling 1379 1409 (#1490)
Alexj9837 Apr 16, 2026
ffefee4
Merge branch 'main' into chore/improve-default-error-handling-1379-1409
Alexj9837 Apr 16, 2026
135cd15
Remove duplicate BlueskyRequestError
Alexj9837 Apr 16, 2026
74f83e4
Merge branch 'main' into chore/improve-default-error-handling-1379-1409
Alexj9837 Apr 22, 2026
eb037b7
add another test for non_json_body
Alexj9837 Apr 22, 2026
61e973a
chasing from raising a KeyError to a NotFoundError
Alexj9837 Apr 22, 2026
deb0dae
Update src/blueapi/client/rest.py
Alexj9837 Apr 22, 2026
38965f8
responding to PR feedback
Alexj9837 Apr 22, 2026
b666346
formatting
Alexj9837 Apr 22, 2026
aa8a87f
raise correct error message for reload_enviroment
Alexj9837 Apr 23, 2026
1e01e3e
import json at the top
Alexj9837 Apr 23, 2026
1b654cf
feedback from PR comments
Alexj9837 Apr 23, 2026
5281e75
Merge branch 'main' into chore/improve-default-error-handling-1379-1409
Alexj9837 Apr 24, 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 @@ -237,13 +237,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 @@ -372,12 +369,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
16 changes: 11 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 @@ -658,9 +663,10 @@ 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
16 changes: 12 additions & 4 deletions src/blueapi/service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,11 +498,19 @@ 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:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=(
f"Error while transitioning from {current_state} "
f"to {new_state} - {e}"
),
) 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