From 384c153186de3f095d62188bb2d6e88a03dc7dd6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 8 Sep 2026 01:29:46 +0200 Subject: [PATCH] Return an error response when a REST API service call fails (#181590) Co-authored-by: Claude --- homeassistant/components/api/__init__.py | 10 +++++ tests/components/api/test_init.py | 54 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 8372038ae183..5b1d16111224 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -39,9 +39,11 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant from homeassistant.exceptions import ( + HomeAssistantError, InvalidEntityFormatError, InvalidStateError, ServiceNotFound, + ServiceValidationError, TemplateError, Unauthorized, ) @@ -455,6 +457,14 @@ class APIDomainServicesView(HomeAssistantView): ) except (vol.Invalid, ServiceNotFound) as ex: raise HTTPBadRequest from ex + except ServiceValidationError as ex: + return self.json_message(str(ex), HTTPStatus.BAD_REQUEST) + except Unauthorized: + # Handled by the view wrapper, which maps it to 401 + raise + except HomeAssistantError as ex: + _LOGGER.error("Error during service call to %s.%s: %s", domain, service, ex) + return self.json_message(str(ex), HTTPStatus.INTERNAL_SERVER_ERROR) finally: cancel_listen() diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 7db0e2fc5679..e407f1b084fc 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -20,6 +20,11 @@ from homeassistant.components.group import DOMAIN as GROUP_DOMAIN from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN from homeassistant.components.system_health import DOMAIN as SYSTEM_HEALTH_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceValidationError, + Unauthorized, +) from homeassistant.loader import Integration from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import JSON_TYPE @@ -943,6 +948,55 @@ async def test_api_call_service_not_found( assert resp.status == HTTPStatus.BAD_REQUEST +@pytest.mark.parametrize( + ("error", "status"), + [ + pytest.param( + ServiceValidationError("Bad input"), + HTTPStatus.BAD_REQUEST, + id="service_validation_error", + ), + pytest.param( + HomeAssistantError("Something failed"), + HTTPStatus.INTERNAL_SERVER_ERROR, + id="home_assistant_error", + ), + ], +) +async def test_api_call_service_raises( + hass: HomeAssistant, + mock_api_client: TestClient, + error: HomeAssistantError, + status: HTTPStatus, +) -> None: + """Test the API returns a JSON error if the service raises.""" + + async def handler(service_call: ha.ServiceCall) -> None: + """Raise the configured error.""" + raise error + + hass.services.async_register("test_domain", "test_service", handler) + + resp = await mock_api_client.post("/api/services/test_domain/test_service") + assert resp.status == status + assert await resp.json() == {"message": str(error)} + + +async def test_api_call_service_unauthorized( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test the API returns 401 if the service denies permission.""" + + async def handler(service_call: ha.ServiceCall) -> None: + """Deny the call.""" + raise Unauthorized + + hass.services.async_register("test_domain", "test_service", handler) + + resp = await mock_api_client.post("/api/services/test_domain/test_service") + assert resp.status == HTTPStatus.UNAUTHORIZED + + async def test_api_call_service_bad_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: