Compare commits

..

2 Commits

Author SHA1 Message Date
9d7636448d Merge PR #6: HA-Client-Fehler sicher mappen 2026-06-11 18:48:23 +02:00
29ec53cc5e add safe home assistant error handling 2026-06-10 21:24:34 +02:00
7 changed files with 186 additions and 8 deletions

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core application helpers."""

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError, HaTimeoutError
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(HaClientError)
async def handle_ha_client_error(_: Request, exc: HaClientError) -> JSONResponse:
return JSONResponse(
status_code=_status_code_for_ha_error(exc),
content={"detail": exc.public_detail},
)
def _status_code_for_ha_error(exc: HaClientError) -> int:
if isinstance(exc, HaTimeoutError):
return status.HTTP_504_GATEWAY_TIMEOUT
if isinstance(exc, (HaAuthError, HaHttpError)):
return status.HTTP_502_BAD_GATEWAY
return status.HTTP_502_BAD_GATEWAY

View File

@@ -6,6 +6,13 @@ from typing import Any
import requests import requests
from app.ha.exceptions import (
HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,13 +33,35 @@ class HaClient:
}) })
def list_entities(self) -> list[dict[str, Any]]: def list_entities(self) -> list[dict[str, Any]]:
response = self._session.get( try:
f"{self._settings.url}/api/states", response = self._session.get(
timeout=self._settings.timeout_seconds, f"{self._settings.url}/api/states",
) timeout=self._settings.timeout_seconds,
response.raise_for_status() )
payload = response.json() except requests.Timeout as exc:
raise HaTimeoutError("Home Assistant request timed out.") from exc
except requests.RequestException as exc:
raise HaHttpError(status_code=502, message="Home Assistant request failed.") from exc
if response.status_code in {401, 403}:
raise HaAuthError(
status_code=response.status_code,
message="Home Assistant authentication failed.",
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise HaHttpError(
status_code=response.status_code,
message="Home Assistant returned an HTTP error.",
) from exc
try:
payload = response.json()
except ValueError as exc:
raise HaUnexpectedPayloadError("Home Assistant returned invalid JSON.") from exc
if not isinstance(payload, list): if not isinstance(payload, list):
msg = "Home Assistant states response must be a list." raise HaUnexpectedPayloadError("Home Assistant states response must be a list.")
raise TypeError(msg)
return payload return payload

27
app/ha/exceptions.py Normal file
View File

@@ -0,0 +1,27 @@
from __future__ import annotations
class HaClientError(Exception):
"""Base class for Home Assistant integration failures."""
public_detail = "Home Assistant is currently unavailable."
class HaTimeoutError(HaClientError):
public_detail = "Home Assistant request timed out."
class HaHttpError(HaClientError):
public_detail = "Home Assistant returned an error."
def __init__(self, status_code: int, message: str | None = None) -> None:
super().__init__(message or self.public_detail)
self.status_code = status_code
class HaAuthError(HaHttpError):
public_detail = "Home Assistant authentication failed."
class HaUnexpectedPayloadError(HaClientError):
public_detail = "Home Assistant returned an unexpected response."

View File

@@ -5,6 +5,7 @@ from fastapi import FastAPI
from app.api.v1.entities import router as entities_router from app.api.v1.entities import router as entities_router
from app.config import load_settings from app.config import load_settings
from app.core.exception_handlers import register_exception_handlers
from app.ha.client import HaClient, HaClientSettings from app.ha.client import HaClient, HaClientSettings
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -31,6 +32,7 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
register_exception_handlers(app)
app.include_router(entities_router) app.include_router(entities_router)

View File

@@ -2,6 +2,7 @@ from collections.abc import Sequence
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.ha.exceptions import HaTimeoutError
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
from app.main import app from app.main import app
@@ -15,6 +16,14 @@ class FakeHaReader(HaReader):
return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")] return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")]
class TimeoutHaReader(HaReader):
def __init__(self) -> None:
pass
def read_entities(self) -> Sequence[HaEntitySummary]:
raise HaTimeoutError("contains internal details that must not leak")
def test_openapi_docs_are_available() -> None: def test_openapi_docs_are_available() -> None:
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/docs") response = client.get("/docs")
@@ -44,3 +53,11 @@ def test_entities_returns_503_without_home_assistant_config() -> None:
delattr(app.state, "ha_reader") delattr(app.state, "ha_reader")
response = client.get("/v1/entities") response = client.get("/v1/entities")
assert response.status_code == 503 assert response.status_code == 503
def test_entities_maps_ha_errors_without_leaking_details() -> None:
with TestClient(app) as client:
app.state.ha_reader = TimeoutHaReader()
response = client.get("/v1/entities")
assert response.status_code == 504
assert response.json() == {"detail": "Home Assistant request timed out."}

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from unittest.mock import Mock
import pytest
import requests
from app.ha.client import HaClient, HaClientSettings
from app.ha.exceptions import (
HaAuthError,
HaHttpError,
HaTimeoutError,
HaUnexpectedPayloadError,
)
def _client_with_response(response: Mock) -> HaClient:
client = HaClient(HaClientSettings(url="http://ha.local", token="secret-token"))
client._session.get = Mock(return_value=response) # type: ignore[method-assign]
return client
def _response(status_code: int = 200, payload: object | None = None) -> Mock:
response = Mock()
response.status_code = status_code
response.json.return_value = [] if payload is None else payload
if status_code >= 400:
response.raise_for_status.side_effect = requests.HTTPError("upstream failed")
return response
def test_list_entities_returns_home_assistant_payload() -> None:
payload = [{"entity_id": "sensor.temperature", "state": "21"}]
client = _client_with_response(_response(payload=payload))
assert client.list_entities() == payload
def test_list_entities_maps_timeout() -> None:
client = HaClient(HaClientSettings(url="http://ha.local", token="secret-token"))
client._session.get = Mock(side_effect=requests.Timeout("secret-token")) # type: ignore[method-assign]
with pytest.raises(HaTimeoutError):
client.list_entities()
@pytest.mark.parametrize("status_code", [401, 403])
def test_list_entities_maps_auth_errors(status_code: int) -> None:
client = _client_with_response(_response(status_code=status_code))
with pytest.raises(HaAuthError) as exc_info:
client.list_entities()
assert exc_info.value.status_code == status_code
def test_list_entities_maps_http_errors() -> None:
client = _client_with_response(_response(status_code=500))
with pytest.raises(HaHttpError) as exc_info:
client.list_entities()
assert exc_info.value.status_code == 500
def test_list_entities_rejects_invalid_json() -> None:
response = _response()
response.json.side_effect = ValueError("not json")
client = _client_with_response(response)
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()
def test_list_entities_rejects_non_list_payload() -> None:
client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"}))
with pytest.raises(HaUnexpectedPayloadError):
client.list_entities()