diff --git a/src/haclient/__init__.py b/src/haclient/__init__.py index d5616e7..6943747 100644 --- a/src/haclient/__init__.py +++ b/src/haclient/__init__.py @@ -33,6 +33,7 @@ ConnectionClosedError, EntityNotFoundError, HAClientError, + HTTPError, TimeoutError, ) from haclient.ports import Clock, RestPort, WebSocketPort @@ -55,6 +56,7 @@ "EventBus", "HAClient", "HAClientError", + "HTTPError", "RestPort", "ServiceCaller", "ServicePolicy", diff --git a/src/haclient/exceptions.py b/src/haclient/exceptions.py index b7ac2a8..62fabb5 100644 --- a/src/haclient/exceptions.py +++ b/src/haclient/exceptions.py @@ -36,6 +36,44 @@ def __init__(self, code: str, message: str) -> None: self.message = message +class HTTPError(HAClientError): + """Raised when Home Assistant returns an HTTP error response. + + Parameters + ---------- + status : int + The HTTP status code (e.g. 404, 500). + method : str + The HTTP method used (e.g. ``"GET"``). + path : str + The relative API path that was requested. + body : str + The response body text. + + Attributes + ---------- + status : int + The HTTP status code. + method : str + The HTTP method. + path : str + The relative API path. + body : str + The response body text. + + Examples + -------- + >>> raise HTTPError(404, "GET", "/api/states/light.missing", "not found") + """ + + def __init__(self, status: int, method: str, path: str, body: str) -> None: + super().__init__(f"HTTP {status} from {method} {path}: {body.strip()}") + self.status = status + self.method = method + self.path = path + self.body = body + + class TimeoutError(HAClientError): # noqa: A001 """Raised when a request to Home Assistant does not complete in time.""" diff --git a/src/haclient/infra/rest_aiohttp.py b/src/haclient/infra/rest_aiohttp.py index fcda93d..1c4bc33 100644 --- a/src/haclient/infra/rest_aiohttp.py +++ b/src/haclient/infra/rest_aiohttp.py @@ -12,7 +12,7 @@ import aiohttp -from haclient.exceptions import AuthenticationError, HAClientError +from haclient.exceptions import AuthenticationError, HAClientError, HTTPError from haclient.exceptions import TimeoutError as HATimeoutError _LOGGER = logging.getLogger(__name__) @@ -110,8 +110,10 @@ async def _request( ------ AuthenticationError On HTTP 401. + HTTPError + On any other HTTP error (status >= 400). HAClientError - On any other HTTP error or connection failure. + On connection failure. TimeoutError On request timeout. """ @@ -130,7 +132,7 @@ async def _request( raise AuthenticationError("Invalid or expired access token") if resp.status >= 400: body = await resp.text() - raise HAClientError(f"HTTP {resp.status} from {method} {path}: {body.strip()}") + raise HTTPError(resp.status, method, path, body) if resp.status == 200 and resp.content_type == "application/json": return await resp.json() return await resp.text() @@ -182,11 +184,18 @@ async def get_state(self, entity_id: str) -> dict[str, Any] | None: ------- dict or None The state object, or ``None`` on HTTP 404. + + Raises + ------ + HTTPError + On any HTTP error other than 404. + AuthenticationError + On HTTP 401. """ try: data = await self._request("GET", f"/api/states/{entity_id}") - except HAClientError as err: - if "HTTP 404" in str(err): + except HTTPError as err: + if err.status == 404: return None raise if isinstance(data, dict): diff --git a/tests/test_rest.py b/tests/test_rest.py index 2c1c29f..b769b16 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -4,7 +4,7 @@ import pytest -from haclient.exceptions import AuthenticationError, HAClientError +from haclient.exceptions import AuthenticationError, HAClientError, HTTPError from haclient.infra.rest_aiohttp import AiohttpRestAdapter from .fake_ha import FakeHA @@ -65,11 +65,68 @@ async def test_call_service_error(fake_ha: FakeHA) -> None: async def test_request_server_error(fake_ha: FakeHA) -> None: - """Trigger a non-auth error path by hitting an unknown REST endpoint.""" + """Non-auth HTTP errors are raised as HTTPError (subclass of HAClientError).""" rc = AiohttpRestAdapter(fake_ha.base_url, fake_ha.token) try: - with pytest.raises(HAClientError): + with pytest.raises(HTTPError) as exc_info: + await rc._request("GET", "/api/does-not-exist") + assert exc_info.value.status == 404 + assert exc_info.value.method == "GET" + assert exc_info.value.path == "/api/does-not-exist" + # HTTPError is still a HAClientError + assert isinstance(exc_info.value, HAClientError) + finally: + await rc.close() + + +async def test_http_error_attributes(fake_ha: FakeHA) -> None: + """HTTPError exposes status, method, path, and body as structured attributes.""" + rc = AiohttpRestAdapter(fake_ha.base_url, fake_ha.token) + try: + with pytest.raises(HTTPError) as exc_info: await rc._request("GET", "/api/does-not-exist") + err = exc_info.value + assert err.status == 404 + assert err.method == "GET" + assert err.path == "/api/does-not-exist" + assert isinstance(err.body, str) + # String representation should include status code + assert "404" in str(err) + finally: + await rc.close() + + +async def test_get_state_returns_none_only_for_404(fake_ha: FakeHA) -> None: + """get_state() returns None for a real 404, not for other HTTP errors.""" + fake_ha.states = [{"entity_id": "light.kitchen", "state": "on", "attributes": {}}] + rc = AiohttpRestAdapter(fake_ha.base_url, fake_ha.token) + try: + # Known entity returns state dict + state = await rc.get_state("light.kitchen") + assert state is not None + assert state["state"] == "on" + + # Unknown entity → 404 → None (not an exception) + missing = await rc.get_state("light.does_not_exist") + assert missing is None + finally: + await rc.close() + + +async def test_get_state_reraises_non_404_http_error( + fake_ha: FakeHA, monkeypatch: pytest.MonkeyPatch +) -> None: + """get_state() re-raises HTTPError for status codes other than 404.""" + rc = AiohttpRestAdapter(fake_ha.base_url, fake_ha.token) + + async def fake_request(method: str, path: str, **_: object) -> object: + raise HTTPError(500, method, path, "internal server error") + + monkeypatch.setattr(rc, "_request", fake_request) + try: + with pytest.raises(HTTPError) as exc_info: + await rc.get_state("light.kitchen") + assert exc_info.value.status == 500 finally: await rc.close()