mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Honor rate limit retry hint in Tesla Fleet vehicle coordinator (#181661)
This commit is contained in:
@@ -152,6 +152,8 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update vehicle data using TeslaFleet API."""
|
||||
|
||||
self.update_interval = VEHICLE_INTERVAL
|
||||
|
||||
try:
|
||||
# Check if the vehicle is awake using a free API call
|
||||
response = await self.api.vehicle()
|
||||
@@ -166,11 +168,16 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except VehicleOffline:
|
||||
self.data["state"] = TeslaFleetState.ASLEEP
|
||||
return self.data
|
||||
except RateLimited:
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will skip refresh",
|
||||
self.name,
|
||||
)
|
||||
except RateLimited as e:
|
||||
if isinstance(e.data, dict) and (after := e.data.get("after")):
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
after,
|
||||
)
|
||||
self.update_interval = timedelta(seconds=int(after))
|
||||
else:
|
||||
LOGGER.warning("%s rate limited, will skip refresh", self.name)
|
||||
return self.data
|
||||
except (InvalidToken, OAuthExpired) as e:
|
||||
_invalidate_access_token(self.hass, self.config_entry)
|
||||
@@ -180,8 +187,6 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except TeslaFleetError as e:
|
||||
raise UpdateFailed(e.message) from e
|
||||
|
||||
self.update_interval = VEHICLE_INTERVAL
|
||||
|
||||
self.updated_once = True
|
||||
|
||||
if self.api.pre2021 and data["state"] == TeslaFleetState.ONLINE:
|
||||
@@ -238,13 +243,13 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
try:
|
||||
data = (await self.api.live_status())["response"]
|
||||
except RateLimited as e:
|
||||
if isinstance(e.data, dict) and "after" in e.data:
|
||||
if isinstance(e.data, dict) and (after := e.data.get("after")):
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
e.data["after"],
|
||||
after,
|
||||
)
|
||||
self.update_interval = timedelta(seconds=int(e.data["after"]))
|
||||
self.update_interval = timedelta(seconds=int(after))
|
||||
else:
|
||||
LOGGER.warning("%s rate limited, will skip refresh", self.name)
|
||||
return self.data
|
||||
@@ -320,13 +325,13 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any
|
||||
try:
|
||||
data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"]
|
||||
except RateLimited as e:
|
||||
if isinstance(e.data, dict) and "after" in e.data:
|
||||
if isinstance(e.data, dict) and (after := e.data.get("after")):
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
e.data["after"],
|
||||
after,
|
||||
)
|
||||
self.update_interval = timedelta(seconds=int(e.data["after"]))
|
||||
self.update_interval = timedelta(seconds=int(after))
|
||||
else:
|
||||
LOGGER.warning("%s rate limited, will skip refresh", self.name)
|
||||
return self.data
|
||||
@@ -401,13 +406,13 @@ class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
|
||||
try:
|
||||
data = (await self.api.site_info())["response"]
|
||||
except RateLimited as e:
|
||||
if isinstance(e.data, dict) and "after" in e.data:
|
||||
if isinstance(e.data, dict) and (after := e.data.get("after")):
|
||||
LOGGER.warning(
|
||||
"%s rate limited, will retry in %s seconds",
|
||||
self.name,
|
||||
e.data["after"],
|
||||
after,
|
||||
)
|
||||
self.update_interval = timedelta(seconds=int(e.data["after"]))
|
||||
self.update_interval = timedelta(seconds=int(after))
|
||||
else:
|
||||
LOGGER.warning("%s rate limited, will skip refresh", self.name)
|
||||
return self.data
|
||||
|
||||
@@ -377,31 +377,102 @@ async def test_vehicle_refresh_ratelimited(
|
||||
mock_vehicle_data: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test coordinator refresh handles 429."""
|
||||
"""Test coordinator refresh handles 429 and backs off using the after hint."""
|
||||
|
||||
await setup_platform(hass, normal_config_entry)
|
||||
|
||||
after_seconds = VEHICLE_INTERVAL_SECONDS + 10
|
||||
mock_vehicle_data.side_effect = RateLimited({"after": str(after_seconds)})
|
||||
freezer.tick(VEHICLE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_vehicle_data.call_count == 2
|
||||
assert (state := hass.states.get("sensor.test_battery_level"))
|
||||
assert state.state == "77"
|
||||
|
||||
freezer.tick(VEHICLE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Not yet past the after hint, must not call
|
||||
assert mock_vehicle_data.call_count == 2
|
||||
|
||||
freezer.tick(timedelta(seconds=after_seconds - VEHICLE_INTERVAL_SECONDS))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Exactly past the after hint, must call
|
||||
assert mock_vehicle_data.call_count == 3
|
||||
|
||||
|
||||
async def test_vehicle_refresh_ratelimited_on_first_refresh(
|
||||
hass: HomeAssistant,
|
||||
normal_config_entry: MockConfigEntry,
|
||||
mock_vehicle_data: AsyncMock,
|
||||
) -> None:
|
||||
"""Test coordinator handles 429 on the first refresh, before any data exists."""
|
||||
|
||||
mock_vehicle_data.side_effect = RateLimited(
|
||||
{"after": VEHICLE_INTERVAL_SECONDS + 10}
|
||||
{"after": str(VEHICLE_INTERVAL_SECONDS + 10)}
|
||||
)
|
||||
await setup_platform(hass, normal_config_entry)
|
||||
|
||||
assert (state := hass.states.get("sensor.test_battery_level"))
|
||||
assert state.state == "unknown"
|
||||
|
||||
mock_vehicle_data.reset_mock()
|
||||
|
||||
async def test_vehicle_refresh_ratelimited_backoff_not_sticky(
|
||||
hass: HomeAssistant,
|
||||
normal_config_entry: MockConfigEntry,
|
||||
mock_vehicle_state: AsyncMock,
|
||||
mock_vehicle_data: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test the after backoff does not persist through an asleep refresh."""
|
||||
|
||||
await setup_platform(hass, normal_config_entry)
|
||||
|
||||
after_seconds = VEHICLE_INTERVAL_SECONDS * 3
|
||||
mock_vehicle_data.side_effect = RateLimited({"after": str(after_seconds)})
|
||||
freezer.tick(VEHICLE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_vehicle_state.call_count == 2
|
||||
assert mock_vehicle_data.call_count == 2
|
||||
|
||||
# The vehicle falls asleep, so the next refresh returns before vehicle_data
|
||||
mock_vehicle_state.return_value = VEHICLE_ASLEEP
|
||||
freezer.tick(timedelta(seconds=after_seconds))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_vehicle_state.call_count == 3
|
||||
assert mock_vehicle_data.call_count == 2
|
||||
|
||||
freezer.tick(VEHICLE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (state := hass.states.get("sensor.test_battery_level"))
|
||||
assert state.state == "unknown"
|
||||
# Back on the normal interval rather than the after backoff
|
||||
assert mock_vehicle_state.call_count == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
pytest.param({}, id="missing"),
|
||||
# The library sets after to None when the Retry-After header is absent
|
||||
pytest.param({"reset": None, "after": None}, id="none"),
|
||||
],
|
||||
)
|
||||
async def test_vehicle_refresh_ratelimited_no_after(
|
||||
hass: HomeAssistant,
|
||||
normal_config_entry: MockConfigEntry,
|
||||
mock_vehicle_data: AsyncMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
data: dict[str, str | None],
|
||||
) -> None:
|
||||
"""Test coordinator refresh handles 429 without after."""
|
||||
|
||||
@@ -409,13 +480,15 @@ async def test_vehicle_refresh_ratelimited_no_after(
|
||||
# mock_vehicle_data called once during setup
|
||||
assert mock_vehicle_data.call_count == 1
|
||||
|
||||
mock_vehicle_data.side_effect = RateLimited({})
|
||||
mock_vehicle_data.side_effect = RateLimited(data)
|
||||
freezer.tick(VEHICLE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Called again during refresh, failed with RateLimited
|
||||
assert mock_vehicle_data.call_count == 2
|
||||
assert (state := hass.states.get("sensor.test_battery_level"))
|
||||
assert state.state == "77"
|
||||
|
||||
freezer.tick(VEHICLE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
@@ -773,7 +846,9 @@ async def test_energy_live_refresh_ratelimited(
|
||||
|
||||
await setup_platform(hass, normal_config_entry)
|
||||
|
||||
mock_live_status.side_effect = RateLimited({"after": ENERGY_INTERVAL_SECONDS + 10})
|
||||
mock_live_status.side_effect = RateLimited(
|
||||
{"after": str(ENERGY_INTERVAL_SECONDS + 10)}
|
||||
)
|
||||
freezer.tick(ENERGY_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
@@ -797,7 +872,7 @@ async def test_energy_live_refresh_ratelimited(
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "second_refresh_call_count", "third_refresh_call_count"),
|
||||
[
|
||||
(RateLimited({"after": ENERGY_INTERVAL_SECONDS + 10}), 2, 3),
|
||||
(RateLimited({"after": str(ENERGY_INTERVAL_SECONDS + 10)}), 2, 3),
|
||||
(RateLimited({}), 3, 4),
|
||||
],
|
||||
)
|
||||
@@ -847,7 +922,7 @@ async def test_energy_history_refresh_ratelimited(
|
||||
await setup_platform(hass, normal_config_entry)
|
||||
|
||||
mock_energy_history.side_effect = RateLimited(
|
||||
{"after": int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10)}
|
||||
{"after": str(int(ENERGY_HISTORY_INTERVAL.total_seconds() + 10))}
|
||||
)
|
||||
freezer.tick(ENERGY_HISTORY_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
|
||||
Reference in New Issue
Block a user