Skip to content

Commit

Permalink
Randomise HTTP API access to avoid rate limiting/DDoS protection
Browse files Browse the repository at this point in the history
  • Loading branch information
KaSroka committed Aug 12, 2024
1 parent 46828c4 commit 3f88f91
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 9 deletions.
6 changes: 5 additions & 1 deletion toshiba_ac/device/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ async def periodic_state_reload(self) -> None:
await self.state_reload()
except asyncio.CancelledError:
raise
except:
except Exception as e:
logger.error(f"State reload failed: {e}")
pass

async def handle_cmd_fcu_from_ac(self, payload: dict[str, JSONSerializable]) -> None:
Expand All @@ -154,6 +155,9 @@ async def handle_cmd_heartbeat(self, payload: dict[str, t.Any]) -> None:
hb_data = {k: int(v, base=16) for k, v in payload.items()}
logger.debug(f"[{self.name}] AC heartbeat from AMQP: {hb_data}")

if self.fcu_state.update_from_hbt(hb_data):
await self.state_changed()

async def handle_update_ac_energy_consumption(self, val: ToshibaAcDeviceEnergyConsumption) -> None:
if self._ac_energy_consumption != val:
self._ac_energy_consumption = val
Expand Down
13 changes: 13 additions & 0 deletions toshiba_ac/device/fcu_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,19 @@ def update(self, hex_state: str) -> bool:

return changed

def update_from_hbt(self, hb_data: t.Any) -> bool:
changed = False

if "iTemp" in hb_data and hb_data["iTemp"] != self._ac_indoor_temperature:
self._ac_indoor_temperature = hb_data["iTemp"]
changed = True

if "oTemp" in hb_data and hb_data["oTemp"] != self._ac_outdoor_temperature:
self._ac_outdoor_temperature = hb_data["oTemp"]
changed = True

return changed

@property
def ac_status(self) -> ToshibaAcStatus:
return ToshibaAcFcuState.AcStatus.from_raw(self._ac_status)
Expand Down
3 changes: 2 additions & 1 deletion toshiba_ac/device_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ async def periodic_fetch_energy_consumption(self) -> None:
await self.fetch_energy_consumption()
except asyncio.CancelledError:
raise
except:
except Exception as e:
logger.error(f"Fetching energy consumption failed: {e}")
pass

async def fetch_energy_consumption(self) -> None:
Expand Down
70 changes: 68 additions & 2 deletions toshiba_ac/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@

import asyncio
import datetime
import random
import functools
import logging
from enum import Enum
import typing as t

logger = logging.getLogger(__name__)

async def async_sleep_until_next_multiply_of_minutes(minutes: int) -> None:

async def async_sleep_until_next_multiply_of_minutes(minutes: int, backoff_s: float = 300) -> None:
next = datetime.datetime.now() + datetime.timedelta(minutes=minutes)
next_rounded = datetime.datetime(
year=next.year,
Expand All @@ -29,8 +35,68 @@ async def async_sleep_until_next_multiply_of_minutes(minutes: int) -> None:
microsecond=0,
)

await asyncio.sleep((next_rounded - datetime.datetime.now()).total_seconds())
backoff = random.uniform(0, backoff_s)

await asyncio.sleep((next_rounded - datetime.datetime.now()).total_seconds() + backoff)


def pretty_enum_name(enum: Enum) -> str:
return enum.name.title().replace("_", " ")


# Define a generic type variable that will capture the return type of the retried function
R = t.TypeVar("R")

# Define a ParamSpec to capture the parameters of the retried function
P = t.ParamSpec("P")


def retry_with_timeout(
*, timeout: float, retries: int, backoff: float
) -> t.Callable[[t.Callable[P, t.Awaitable[R]]], t.Callable[P, t.Awaitable[R]]]:
def decorator(func: t.Callable[P, t.Awaitable[R]]) -> t.Callable[P, t.Awaitable[R]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
attempt = 0
while True:
try:
return await asyncio.wait_for(func(*args, **kwargs), timeout=timeout)
except asyncio.TimeoutError:
attempt += 1
if attempt < retries + 1:
logger.info("Timeout exception. Will retry after backoff.")
bk = random.uniform(0.1 * backoff * attempt, backoff * attempt)
await asyncio.sleep(bk)
else:
raise

return wrapper

return decorator


def retry_on_exception(
*,
retries: int,
backoff: float,
exceptions: t.Type[BaseException] | t.Tuple[t.Type[BaseException], ...],
) -> t.Callable[[t.Callable[P, t.Awaitable[R]]], t.Callable[P, t.Awaitable[R]]]:
def decorator(func: t.Callable[P, t.Awaitable[R]]) -> t.Callable[P, t.Awaitable[R]]:
@functools.wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
attempt = 0
while True:
try:
return await func(*args, **kwargs)
except exceptions as e:
attempt += 1
if attempt < retries + 1:
logger.info("Known exception occurred. Will retry after backoff.")
bk = random.uniform(0.1 * backoff * attempt, backoff * attempt)
await asyncio.sleep(bk)
else:
raise e # Re-raise the exception if all retries are exhausted

return wrapper

return decorator
14 changes: 9 additions & 5 deletions toshiba_ac/utils/http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import aiohttp
from toshiba_ac.device.properties import ToshibaAcDeviceEnergyConsumption
from toshiba_ac.utils import retry_with_timeout, retry_on_exception

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -64,6 +65,8 @@ def __init__(self, username: str, password: str) -> None:
self.consumer_id: t.Optional[str] = None
self.session: t.Optional[aiohttp.ClientSession] = None

@retry_with_timeout(timeout=5, retries=3, backoff=10)
@retry_on_exception(exceptions=ToshibaAcHttpApiError, retries=3, backoff=10)
async def request_api(
self,
path: str,
Expand Down Expand Up @@ -95,19 +98,20 @@ async def request_api(
method = self.session.get

async with method(url, **method_args) as response:
json = await response.json()
logger.debug(f"Response code: {response.status}")

err_type = ToshibaAcHttpApiError

if response.status == 200:
json = await response.json()

if json["IsSuccess"]:
return json["ResObj"]
else:
if json["StatusCode"] == "InvalidUserNameorPassword":
err_type = ToshibaAcHttpApiAuthError
err_type = ToshibaAcHttpApiAuthError(json["Message"])

raise ToshibaAcHttpApiError(json["Message"])

raise err_type(json["Message"])
raise ToshibaAcHttpApiError(await response.text())

async def connect(self) -> None:
headers = {"Content-Type": "application/json"}
Expand Down

0 comments on commit 3f88f91

Please sign in to comment.