2020-10-01 19:39:44 +00:00
|
|
|
"""Ratelimit helper."""
|
2021-03-17 17:34:19 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2020-10-01 19:39:44 +00:00
|
|
|
import asyncio
|
2021-09-29 14:32:11 +00:00
|
|
|
from collections.abc import Callable, Hashable
|
2020-10-01 19:39:44 +00:00
|
|
|
from datetime import datetime, timedelta
|
|
|
|
import logging
|
2021-09-29 14:32:11 +00:00
|
|
|
from typing import Any
|
2020-10-01 19:39:44 +00:00
|
|
|
|
|
|
|
from homeassistant.core import HomeAssistant, callback
|
|
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
class KeyedRateLimit:
|
|
|
|
"""Class to track rate limits."""
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
hass: HomeAssistant,
|
2021-05-20 15:53:29 +00:00
|
|
|
) -> None:
|
2020-10-01 19:39:44 +00:00
|
|
|
"""Initialize ratelimit tracker."""
|
|
|
|
self.hass = hass
|
2021-03-17 17:34:19 +00:00
|
|
|
self._last_triggered: dict[Hashable, datetime] = {}
|
|
|
|
self._rate_limit_timers: dict[Hashable, asyncio.TimerHandle] = {}
|
2020-10-01 19:39:44 +00:00
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_has_timer(self, key: Hashable) -> bool:
|
|
|
|
"""Check if a rate limit timer is running."""
|
2020-10-15 10:02:05 +00:00
|
|
|
if not self._rate_limit_timers:
|
|
|
|
return False
|
2020-10-01 19:39:44 +00:00
|
|
|
return key in self._rate_limit_timers
|
|
|
|
|
|
|
|
@callback
|
2021-03-17 17:34:19 +00:00
|
|
|
def async_triggered(self, key: Hashable, now: datetime | None = None) -> None:
|
2020-10-01 19:39:44 +00:00
|
|
|
"""Call when the action we are tracking was triggered."""
|
|
|
|
self.async_cancel_timer(key)
|
|
|
|
self._last_triggered[key] = now or dt_util.utcnow()
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_cancel_timer(self, key: Hashable) -> None:
|
|
|
|
"""Cancel a rate limit time that will call the action."""
|
2020-10-15 10:02:05 +00:00
|
|
|
if not self._rate_limit_timers or not self.async_has_timer(key):
|
2020-10-01 19:39:44 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
self._rate_limit_timers.pop(key).cancel()
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_remove(self) -> None:
|
|
|
|
"""Remove all timers."""
|
|
|
|
for timer in self._rate_limit_timers.values():
|
|
|
|
timer.cancel()
|
|
|
|
self._rate_limit_timers.clear()
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_schedule_action(
|
|
|
|
self,
|
|
|
|
key: Hashable,
|
2021-03-17 17:34:19 +00:00
|
|
|
rate_limit: timedelta | None,
|
2020-10-01 19:39:44 +00:00
|
|
|
now: datetime,
|
|
|
|
action: Callable,
|
|
|
|
*args: Any,
|
2021-03-17 17:34:19 +00:00
|
|
|
) -> datetime | None:
|
2020-10-01 19:39:44 +00:00
|
|
|
"""Check rate limits and schedule an action if we hit the limit.
|
|
|
|
|
|
|
|
If the rate limit is hit:
|
|
|
|
Schedules the action for when the rate limit expires
|
|
|
|
if there are no pending timers. The action must
|
|
|
|
be called in async.
|
|
|
|
|
|
|
|
Returns the time the rate limit will expire
|
|
|
|
|
|
|
|
If the rate limit is not hit:
|
|
|
|
|
|
|
|
Return None
|
|
|
|
"""
|
2020-10-15 10:02:05 +00:00
|
|
|
if rate_limit is None:
|
2020-10-01 19:39:44 +00:00
|
|
|
return None
|
|
|
|
|
2021-10-17 18:08:11 +00:00
|
|
|
if not (last_triggered := self._last_triggered.get(key)):
|
2020-10-15 10:02:05 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
next_call_time = last_triggered + rate_limit
|
2020-10-01 19:39:44 +00:00
|
|
|
|
|
|
|
if next_call_time <= now:
|
|
|
|
self.async_cancel_timer(key)
|
|
|
|
return None
|
|
|
|
|
|
|
|
_LOGGER.debug(
|
|
|
|
"Reached rate limit of %s for %s and deferred action until %s",
|
|
|
|
rate_limit,
|
|
|
|
key,
|
|
|
|
next_call_time,
|
|
|
|
)
|
|
|
|
|
|
|
|
if key not in self._rate_limit_timers:
|
|
|
|
self._rate_limit_timers[key] = self.hass.loop.call_later(
|
2020-11-05 15:34:56 +00:00
|
|
|
(next_call_time - now).total_seconds(),
|
2020-10-01 19:39:44 +00:00
|
|
|
action,
|
|
|
|
*args,
|
|
|
|
)
|
|
|
|
|
|
|
|
return next_call_time
|