Honor rate limit retry hint in Tesla Fleet vehicle coordinator (#181661)

This commit is contained in:
Brett Adams
2026-09-17 09:09:15 +03:00
committed by GitHub
parent 81230a3d48
commit 76665ef504
2 changed files with 105 additions and 25 deletions
@@ -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
+84 -9
View File
@@ -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)