Set timeout for remote calendar (#147024)

This commit is contained in:
Thomas55555 2025-07-03 19:08:58 +02:00 committed by GitHub
parent 01b4a5ceed
commit 4a937d2452
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 48 additions and 16 deletions

View File

@ -0,0 +1,12 @@
"""Specifies the parameter for the httpx download."""
from httpx import AsyncClient, Response, Timeout
async def get_calendar(client: AsyncClient, url: str) -> Response:
"""Make an HTTP GET request using Home Assistant's async HTTPX client with timeout."""
return await client.get(
url,
follow_redirects=True,
timeout=Timeout(5, read=30, write=5, pool=5),
)

View File

@ -4,13 +4,14 @@ from http import HTTPStatus
import logging import logging
from typing import Any from typing import Any
from httpx import HTTPError, InvalidURL from httpx import HTTPError, InvalidURL, TimeoutException
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL from homeassistant.const import CONF_URL
from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.httpx_client import get_async_client
from .client import get_calendar
from .const import CONF_CALENDAR_NAME, DOMAIN from .const import CONF_CALENDAR_NAME, DOMAIN
from .ics import InvalidIcsException, parse_calendar from .ics import InvalidIcsException, parse_calendar
@ -49,7 +50,7 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
client = get_async_client(self.hass) client = get_async_client(self.hass)
try: try:
res = await client.get(user_input[CONF_URL], follow_redirects=True) res = await get_calendar(client, user_input[CONF_URL])
if res.status_code == HTTPStatus.FORBIDDEN: if res.status_code == HTTPStatus.FORBIDDEN:
errors["base"] = "forbidden" errors["base"] = "forbidden"
return self.async_show_form( return self.async_show_form(
@ -58,9 +59,14 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
res.raise_for_status() res.raise_for_status()
except TimeoutException as err:
errors["base"] = "timeout_connect"
_LOGGER.debug(
"A timeout error occurred: %s", str(err) or type(err).__name__
)
except (HTTPError, InvalidURL) as err: except (HTTPError, InvalidURL) as err:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
_LOGGER.debug("An error occurred: %s", err) _LOGGER.debug("An error occurred: %s", str(err) or type(err).__name__)
else: else:
try: try:
await parse_calendar(self.hass, res.text) await parse_calendar(self.hass, res.text)

View File

@ -3,7 +3,7 @@
from datetime import timedelta from datetime import timedelta
import logging import logging
from httpx import HTTPError, InvalidURL from httpx import HTTPError, InvalidURL, TimeoutException
from ical.calendar import Calendar from ical.calendar import Calendar
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .client import get_calendar
from .const import DOMAIN from .const import DOMAIN
from .ics import InvalidIcsException, parse_calendar from .ics import InvalidIcsException, parse_calendar
@ -36,7 +37,7 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=DOMAIN, name=f"{DOMAIN}_{config_entry.title}",
update_interval=SCAN_INTERVAL, update_interval=SCAN_INTERVAL,
always_update=True, always_update=True,
) )
@ -46,13 +47,19 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
async def _async_update_data(self) -> Calendar: async def _async_update_data(self) -> Calendar:
"""Update data from the url.""" """Update data from the url."""
try: try:
res = await self._client.get(self._url, follow_redirects=True) res = await get_calendar(self._client, self._url)
res.raise_for_status() res.raise_for_status()
except TimeoutException as err:
_LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout",
) from err
except (HTTPError, InvalidURL) as err: except (HTTPError, InvalidURL) as err:
_LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__)
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="unable_to_fetch", translation_key="unable_to_fetch",
translation_placeholders={"err": str(err)},
) from err ) from err
try: try:
self.ics = res.text self.ics = res.text

View File

@ -18,14 +18,18 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]" "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}, },
"error": { "error": {
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"forbidden": "The server understood the request but refuses to authorize it.", "forbidden": "The server understood the request but refuses to authorize it.",
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
} }
}, },
"exceptions": { "exceptions": {
"timeout": {
"message": "The connection timed out. See the debug log for additional details."
},
"unable_to_fetch": { "unable_to_fetch": {
"message": "Unable to fetch calendar data: {err}" "message": "Unable to fetch calendar data. See the debug log for additional details."
}, },
"unable_to_parse": { "unable_to_parse": {
"message": "Unable to parse calendar data: {err}" "message": "Unable to parse calendar data: {err}"

View File

@ -1,6 +1,6 @@
"""Test the Remote Calendar config flow.""" """Test the Remote Calendar config flow."""
from httpx import ConnectError, Response, UnsupportedProtocol from httpx import HTTPError, InvalidURL, Response, TimeoutException
import pytest import pytest
import respx import respx
@ -75,10 +75,11 @@ async def test_form_import_webcal(hass: HomeAssistant, ics_content: str) -> None
@pytest.mark.parametrize( @pytest.mark.parametrize(
("side_effect"), ("side_effect", "base_error"),
[ [
ConnectError("Connection failed"), (TimeoutException("Connection timed out"), "timeout_connect"),
UnsupportedProtocol("Unsupported protocol"), (HTTPError("Connection failed"), "cannot_connect"),
(InvalidURL("Unsupported protocol"), "cannot_connect"),
], ],
) )
@respx.mock @respx.mock
@ -86,6 +87,7 @@ async def test_form_inavild_url(
hass: HomeAssistant, hass: HomeAssistant,
side_effect: Exception, side_effect: Exception,
ics_content: str, ics_content: str,
base_error: str,
) -> None: ) -> None:
"""Test we get the import form.""" """Test we get the import form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -102,7 +104,7 @@ async def test_form_inavild_url(
}, },
) )
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"} assert result2["errors"] == {"base": base_error}
respx.get(CALENDER_URL).mock( respx.get(CALENDER_URL).mock(
return_value=Response( return_value=Response(
status_code=200, status_code=200,

View File

@ -1,6 +1,6 @@
"""Tests for init platform of Remote Calendar.""" """Tests for init platform of Remote Calendar."""
from httpx import ConnectError, Response, UnsupportedProtocol from httpx import HTTPError, InvalidURL, Response, TimeoutException
import pytest import pytest
import respx import respx
@ -56,8 +56,9 @@ async def test_raise_for_status(
@pytest.mark.parametrize( @pytest.mark.parametrize(
"side_effect", "side_effect",
[ [
ConnectError("Connection failed"), TimeoutException("Connection timed out"),
UnsupportedProtocol("Unsupported protocol"), HTTPError("Connection failed"),
InvalidURL("Unsupported protocol"),
ValueError("Invalid response"), ValueError("Invalid response"),
], ],
) )