Improve error handling in Airly integration (#181718)

This commit is contained in:
Maciej Bieniek
2026-09-10 20:10:13 +02:00
committed by GitHub
parent fc78d26a74
commit 3ea011d36b
6 changed files with 136 additions and 21 deletions
+19 -4
View File
@@ -2,9 +2,10 @@
from asyncio import timeout
from http import HTTPStatus
import logging
from typing import Any, override
from aiohttp import ClientSession
from aiohttp import ClientConnectorError, ClientSession
from airly import Airly
from airly.exceptions import AirlyError
import voluptuous as vol
@@ -14,11 +15,18 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_USE_NEAREST, DEFAULT_NAME, DOMAIN, NO_AIRLY_SENSORS
from .const import (
CONF_USE_NEAREST,
DEFAULT_NAME,
DEFAULT_TIMEOUT,
DOMAIN,
NO_AIRLY_SENSORS,
)
DESCRIPTION_PLACEHOLDERS = {
"developer_registration_url": "https://developer.airly.eu/register",
}
_LOGGER = logging.getLogger(__name__)
class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -59,8 +67,15 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
except AirlyError as err:
if err.status_code == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_api_key"
if err.status_code == HTTPStatus.NOT_FOUND:
elif err.status_code == HTTPStatus.NOT_FOUND:
errors["base"] = "wrong_location"
else:
errors["base"] = "unknown"
except ClientConnectorError, TimeoutError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if not location_point_valid:
if not location_nearest_valid:
@@ -106,7 +121,7 @@ async def check_location(
measurements = airly.create_measurements_session_point(
latitude=latitude, longitude=longitude
)
async with timeout(10):
async with timeout(DEFAULT_TIMEOUT):
await measurements.update()
current = measurements.current
+1
View File
@@ -37,3 +37,4 @@ NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet."
URL = "https://airly.org/map/#{latitude},{longitude}"
DEFAULT_NAME: Final = "Airly"
DEFAULT_TIMEOUT = 10
+12 -11
View File
@@ -21,6 +21,7 @@ from .const import (
ATTR_API_CAQI,
ATTR_API_CAQI_DESCRIPTION,
ATTR_API_CAQI_LEVEL,
DEFAULT_TIMEOUT,
DOMAIN,
MAX_UPDATE_INTERVAL,
MIN_UPDATE_INTERVAL,
@@ -103,18 +104,18 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i
measurements = self.airly.create_measurements_session_point(
self.latitude, self.longitude
)
async with timeout(20):
try:
try:
async with timeout(DEFAULT_TIMEOUT):
await measurements.update()
except (AirlyError, ClientConnectorError) as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"entry": self.config_entry.title,
"error": repr(error),
},
) from error
except (AirlyError, ClientConnectorError, TimeoutError) as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"entry": self.config_entry.title,
"error": repr(error),
},
) from error
_LOGGER.debug(
"Requests remaining: %s/%s",
@@ -5,7 +5,9 @@
"wrong_location": "[%key:component::airly::config::error::wrong_location%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"wrong_location": "No Airly measuring stations in this area."
},
"step": {
+90 -2
View File
@@ -2,8 +2,9 @@
from collections.abc import Generator
from http import HTTPStatus
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock, patch
from aiohttp import ClientConnectorError
from airly.exceptions import AirlyError
import pytest
@@ -15,7 +16,7 @@ from homeassistant.data_entry_flow import FlowResultType
from . import API_NEAREST_URL, API_POINT_URL
from tests.common import MockConfigEntry, async_load_fixture, patch
from tests.common import MockConfigEntry, async_load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
CONFIG = {
@@ -228,3 +229,90 @@ async def test_create_entry_with_nearest_method(
assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE]
assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY]
assert result["data"][CONF_USE_NEAREST] is True
@pytest.mark.parametrize(
("exception", "error"),
[
(TimeoutError(), "cannot_connect"),
(ClientConnectorError(Mock(), OSError("test")), "cannot_connect"),
],
)
async def test_cannot_connect(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
exception: Exception,
error: str,
) -> None:
"""Test that cannot_connect error is shown when connection fails."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
with patch("airly.measurements.MeasurementsSession.update", side_effect=exception):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG
)
assert result["errors"] == {"base": error}
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
aioclient_mock.clear_requests()
aioclient_mock.get(
API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
("exception", "error"),
[
(Exception("unexpected"), "unknown"),
(
AirlyError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "Server error"}),
"unknown",
),
],
)
async def test_unknown_error(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
exception: Exception,
error: str,
) -> None:
"""Test that unknown error is shown for unexpected exceptions."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
with patch("airly.measurements.MeasurementsSession.update", side_effect=exception):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG
)
assert result["errors"] == {"base": error}
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
aioclient_mock.clear_requests()
aioclient_mock.get(
API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=CONFIG
)
assert result["type"] is FlowResultType.CREATE_ENTRY
+12 -4
View File
@@ -5,6 +5,7 @@ from http import HTTPStatus
from unittest.mock import patch
from airly.exceptions import AirlyError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.airly.const import DOMAIN
@@ -43,8 +44,17 @@ async def test_sensor(
assert state == snapshot(name=f"{entity_entry.entity_id}-state")
@pytest.mark.parametrize(
"exception",
[
AirlyError(HTTPStatus.NOT_FOUND, {"message": "Not found"}),
TimeoutError(),
],
)
async def test_availability(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
exception: Exception,
) -> None:
"""Ensure that we mark the entities unavailable correctly.
@@ -58,9 +68,7 @@ async def test_availability(
assert state.state == "68.35"
aioclient_mock.clear_requests()
aioclient_mock.get(
API_POINT_URL, exc=AirlyError(HTTPStatus.NOT_FOUND, {"message": "Not found"})
)
aioclient_mock.get(API_POINT_URL, exc=exception)
future = utcnow() + timedelta(minutes=60)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()