diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index fd4b0419ab3..a058f858077 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -6,13 +6,21 @@ import logging from typing import Any import here_routing -from here_routing import HERERoutingApi, Return, RoutingMode, Spans, TransportMode +from here_routing import ( + HERERoutingApi, + HERERoutingTooManyRequestsError, + Return, + RoutingMode, + Spans, + TransportMode, +) import here_transit from here_transit import ( HERETransitApi, HERETransitConnectionError, HERETransitDepartureArrivalTooCloseError, HERETransitNoRouteFoundError, + HERETransitTooManyRequestsError, ) import voluptuous as vol @@ -27,6 +35,8 @@ from homeassistant.util.unit_conversion import DistanceConverter from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST from .model import HERETravelTimeConfig, HERETravelTimeData +BACKOFF_MULTIPLIER = 1.1 + _LOGGER = logging.getLogger(__name__) @@ -71,19 +81,35 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator): departure, ) - response = await self._api.route( - transport_mode=TransportMode(self.config.travel_mode), - origin=here_routing.Place(origin[0], origin[1]), - destination=here_routing.Place(destination[0], destination[1]), - routing_mode=route_mode, - arrival_time=arrival, - departure_time=departure, - return_values=[Return.POLYINE, Return.SUMMARY], - spans=[Spans.NAMES], - ) - + try: + response = await self._api.route( + transport_mode=TransportMode(self.config.travel_mode), + origin=here_routing.Place(origin[0], origin[1]), + destination=here_routing.Place(destination[0], destination[1]), + routing_mode=route_mode, + arrival_time=arrival, + departure_time=departure, + return_values=[Return.POLYINE, Return.SUMMARY], + spans=[Spans.NAMES], + ) + except HERERoutingTooManyRequestsError as error: + assert self.update_interval is not None + _LOGGER.debug( + "Rate limit has been reached. Increasing update interval to %s", + self.update_interval.total_seconds() * BACKOFF_MULTIPLIER, + ) + self.update_interval = timedelta( + seconds=self.update_interval.total_seconds() * BACKOFF_MULTIPLIER + ) + raise UpdateFailed("Rate limit has been reached") from error _LOGGER.debug("Raw response is: %s", response) + if self.update_interval != timedelta(seconds=DEFAULT_SCAN_INTERVAL): + _LOGGER.debug( + "Resetting update interval to %s", + DEFAULT_SCAN_INTERVAL, + ) + self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) return self._parse_routing_response(response) def _parse_routing_response(self, response: dict[str, Any]) -> HERETravelTimeData: @@ -160,16 +186,31 @@ class HERETransitDataUpdateCoordinator(DataUpdateCoordinator): here_transit.Return.TRAVEL_SUMMARY, ], ) - - _LOGGER.debug("Raw response is: %s", response) - - return self._parse_transit_response(response) + except HERETransitTooManyRequestsError as error: + assert self.update_interval is not None + _LOGGER.debug( + "Rate limit has been reached. Increasing update interval to %s", + self.update_interval.total_seconds() * BACKOFF_MULTIPLIER, + ) + self.update_interval = timedelta( + seconds=self.update_interval.total_seconds() * BACKOFF_MULTIPLIER + ) + raise UpdateFailed("Rate limit has been reached") from error except HERETransitDepartureArrivalTooCloseError: _LOGGER.debug("Ignoring HERETransitDepartureArrivalTooCloseError") return None except (HERETransitConnectionError, HERETransitNoRouteFoundError) as error: raise UpdateFailed from error + _LOGGER.debug("Raw response is: %s", response) + if self.update_interval != timedelta(seconds=DEFAULT_SCAN_INTERVAL): + _LOGGER.debug( + "Resetting update interval to %s", + DEFAULT_SCAN_INTERVAL, + ) + self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) + return self._parse_transit_response(response) + def _parse_transit_response(self, response: dict[str, Any]) -> HERETravelTimeData: """Parse the transit response dict to a HERETravelTimeData.""" sections: list[dict[str, Any]] = response["routes"][0]["sections"] diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json index c49518c8994..5f46c344af8 100644 --- a/homeassistant/components/here_travel_time/manifest.json +++ b/homeassistant/components/here_travel_time/manifest.json @@ -3,7 +3,7 @@ "name": "HERE Travel Time", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/here_travel_time", - "requirements": ["here_routing==0.1.1", "here_transit==1.1.1"], + "requirements": ["here_routing==0.2.0", "here_transit==1.2.0"], "codeowners": ["@eifinger"], "iot_class": "cloud_polling", "loggers": ["here_routing", "here_transit", "homeassistant.helpers.location"] diff --git a/requirements_all.txt b/requirements_all.txt index fce121b3c19..ddef4bc8ae2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -867,10 +867,10 @@ hdate==0.10.4 heatmiserV3==1.1.18 # homeassistant.components.here_travel_time -here_routing==0.1.1 +here_routing==0.2.0 # homeassistant.components.here_travel_time -here_transit==1.1.1 +here_transit==1.2.0 # homeassistant.components.hikvisioncam hikvision==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9583fc20e51..33df1afdc77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -653,10 +653,10 @@ hatasmota==0.6.1 hdate==0.10.4 # homeassistant.components.here_travel_time -here_routing==0.1.1 +here_routing==0.2.0 # homeassistant.components.here_travel_time -here_transit==1.1.1 +here_transit==1.2.0 # homeassistant.components.hlk_sw16 hlk-sw16==0.0.9 diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 861b5385f1a..f9f12504891 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -1,8 +1,10 @@ """The test for the HERE Travel Time sensor platform.""" +from datetime import timedelta from unittest.mock import MagicMock, patch from here_routing import ( HERERoutingError, + HERERoutingTooManyRequestsError, Place, Return, RoutingMode, @@ -13,6 +15,7 @@ from here_transit import ( HERETransitDepartureArrivalTooCloseError, HERETransitNoRouteFoundError, HERETransitNoTransitRouteFoundError, + HERETransitTooManyRequestsError, ) import pytest @@ -27,6 +30,7 @@ from homeassistant.components.here_travel_time.const import ( CONF_ORIGIN_LATITUDE, CONF_ORIGIN_LONGITUDE, CONF_ROUTE_MODE, + DEFAULT_SCAN_INTERVAL, DOMAIN, ICON_BICYCLE, ICON_CAR, @@ -39,6 +43,7 @@ from homeassistant.components.here_travel_time.const import ( TRAVEL_MODE_PUBLIC, TRAVEL_MODE_TRUCK, ) +from homeassistant.components.here_travel_time.coordinator import BACKOFF_MULTIPLIER from homeassistant.components.sensor import ( ATTR_LAST_RESET, ATTR_STATE_CLASS, @@ -59,7 +64,9 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow +from .conftest import RESPONSE, TRANSIT_RESPONSE from .const import ( API_KEY, DEFAULT_CONFIG, @@ -69,7 +76,11 @@ from .const import ( ORIGIN_LONGITUDE, ) -from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache_with_extra_data, +) @pytest.mark.parametrize( @@ -634,3 +645,107 @@ async def test_transit_errors(hass: HomeAssistant, caplog, exception, expected_m await hass.async_block_till_done() assert expected_message in caplog.text + + +async def test_routing_rate_limit(hass: HomeAssistant, caplog): + """Test that rate limiting is applied when encountering HTTP 429.""" + with patch( + "here_routing.HERERoutingApi.route", + return_value=RESPONSE, + ): + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="0123456789", + data=DEFAULT_CONFIG, + options=DEFAULT_OPTIONS, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test_distance").state == "13.682" + + with patch( + "here_routing.HERERoutingApi.route", + side_effect=HERERoutingTooManyRequestsError( + "Rate limit for this service has been reached" + ), + ): + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1) + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test_distance").state == "unavailable" + assert "Increasing update interval to" in caplog.text + + with patch( + "here_routing.HERERoutingApi.route", + return_value=RESPONSE, + ): + async_fire_time_changed( + hass, + utcnow() + + timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1), + ) + await hass.async_block_till_done() + assert hass.states.get("sensor.test_distance").state == "13.682" + assert "Resetting update interval to" in caplog.text + + +async def test_transit_rate_limit(hass: HomeAssistant, caplog): + """Test that rate limiting is applied when encountering HTTP 429.""" + with patch( + "here_transit.HERETransitApi.route", + return_value=TRANSIT_RESPONSE, + ): + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="0123456789", + data={ + CONF_ORIGIN_LATITUDE: float(ORIGIN_LATITUDE), + CONF_ORIGIN_LONGITUDE: float(ORIGIN_LONGITUDE), + CONF_DESTINATION_LATITUDE: float(DESTINATION_LATITUDE), + CONF_DESTINATION_LONGITUDE: float(DESTINATION_LONGITUDE), + CONF_API_KEY: API_KEY, + CONF_MODE: TRAVEL_MODE_PUBLIC, + CONF_NAME: "test", + }, + options=DEFAULT_OPTIONS, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test_distance").state == "1.883" + + with patch( + "here_transit.HERETransitApi.route", + side_effect=HERETransitTooManyRequestsError( + "Rate limit for this service has been reached" + ), + ): + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=DEFAULT_SCAN_INTERVAL + 1) + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test_distance").state == "unavailable" + assert "Increasing update interval to" in caplog.text + + with patch( + "here_transit.HERETransitApi.route", + return_value=TRANSIT_RESPONSE, + ): + async_fire_time_changed( + hass, + utcnow() + + timedelta(seconds=DEFAULT_SCAN_INTERVAL * BACKOFF_MULTIPLIER + 1), + ) + await hass.async_block_till_done() + assert hass.states.get("sensor.test_distance").state == "1.883" + assert "Resetting update interval to" in caplog.text