Skip to content

Commit 7f5b2c1

Browse files
authored
Merge pull request #243 from jaroschek/feature/api-throttle
Implement dynamic throttling based on (new) rate limit header values
2 parents ebd07d7 + 2788f63 commit 7f5b2c1

1 file changed

Lines changed: 86 additions & 17 deletions

File tree

custom_components/myuplink/api.py

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,39 @@ def __init__(
5252
"""Initialize myUplink auth."""
5353
self._websession = websession
5454
self._oauth_session = oauth_session
55+
self.rate_limit_limit: int | None = None
56+
self.rate_limit_remaining: int | None = None
57+
self.rate_limit_reset_at: datetime | None = None
5558

5659
async def async_get_access_token(self) -> str:
5760
"""Return a valid access token."""
5861
await self._oauth_session.async_ensure_token_valid()
5962

6063
return self._oauth_session.token["access_token"]
6164

65+
def _update_rate_limit_headers(self, response: ClientResponse) -> None:
66+
"""Extract and update rate limit headers from response.
67+
68+
RateLimit-Limit: maximum requests allowed in the current window (e.g., 25)
69+
RateLimit-Remaining: requests still available in the current window
70+
RateLimit-Reset: seconds until the current window expires
71+
"""
72+
if "RateLimit-Limit" in response.headers:
73+
self.rate_limit_limit = int(response.headers["RateLimit-Limit"])
74+
if "RateLimit-Remaining" in response.headers:
75+
self.rate_limit_remaining = int(response.headers["RateLimit-Remaining"])
76+
if "RateLimit-Reset" in response.headers:
77+
reset_seconds = int(response.headers["RateLimit-Reset"])
78+
self.rate_limit_reset_at = datetime.now() + timedelta(seconds=reset_seconds)
79+
_LOGGER.debug(
80+
"Rate limit window: %d/%d remaining, resets in %d seconds",
81+
self.rate_limit_remaining,
82+
self.rate_limit_limit,
83+
reset_seconds,
84+
)
85+
6286
async def request(self, method, path, **kwargs) -> ClientResponse:
63-
"""Make an authorized request."""
87+
"""Make an authorized request with rate limit window awareness."""
6488
headers = kwargs.pop("headers", None)
6589

6690
if headers is None:
@@ -71,13 +95,32 @@ async def request(self, method, path, **kwargs) -> ClientResponse:
7195
access_token = await self.async_get_access_token()
7296
headers["authorization"] = f"Bearer {access_token}"
7397

74-
return await self._websession.request(
98+
url = f"{API_HOST}/{API_VERSION}/{path}"
99+
100+
response = await self._websession.request(
75101
method,
76-
f"{API_HOST}/{API_VERSION}/{path}",
102+
url,
77103
**kwargs,
78104
headers=headers,
79105
)
80106

107+
self._update_rate_limit_headers(response)
108+
109+
if response.status == 429:
110+
if self.rate_limit_reset_at:
111+
wait_time = (self.rate_limit_reset_at - datetime.now()).total_seconds()
112+
if wait_time > 0:
113+
_LOGGER.warning(
114+
"Rate limit exceeded (429). Waiting %d seconds until window resets for %s %s",
115+
int(wait_time),
116+
method.upper(),
117+
path,
118+
)
119+
await asyncio.sleep(wait_time + 0.1)
120+
return response
121+
122+
return response
123+
81124

82125
class Subscription:
83126
"""Class that represents the subscription in the myUplink API."""
@@ -599,27 +642,53 @@ async def update_smart_home_mode(self, value) -> None:
599642

600643

601644
class Throttle:
602-
"""Throttling requests to API."""
645+
"""Throttling requests to API with rate limit window awareness.
603646
604-
def __init__(self, delay) -> None:
647+
The throttle respects the 25 requests per minute limit by:
648+
1. Tracking RateLimit-Remaining in the current window
649+
2. When RateLimit-Remaining = 0, waiting until RateLimit-Reset window expires
650+
3. Otherwise, maintaining a minimum delay of 60/25 = 2.4 seconds between requests
651+
"""
652+
653+
MIN_DELAY_SECONDS = 60 / 25
654+
655+
def __init__(self, auth: AsyncConfigEntryAuth) -> None:
605656
"""Initialize throttle."""
606-
self._delay = delay
607-
self._timestamp = datetime.now()
657+
self._auth = auth
658+
self._last_request_time = datetime.now()
608659

609660
async def __aenter__(self):
610-
"""Enter async throttle."""
611-
timestamp = datetime.now()
612-
delay = (self._timestamp - timestamp).total_seconds()
613-
if delay > 0:
614-
_LOGGER.debug("Delaying request by %s seconds due to throttle", delay)
615-
with suppress(asyncio.CancelledError):
616-
await asyncio.sleep(delay)
661+
"""Enter async throttle - apply delay before making request."""
662+
now = datetime.now()
663+
664+
if (
665+
self._auth.rate_limit_reset_at
666+
and self._auth.rate_limit_remaining is not None
667+
):
668+
if self._auth.rate_limit_remaining <= 0:
669+
wait_seconds = (self._auth.rate_limit_reset_at - now).total_seconds()
670+
if wait_seconds > 0:
671+
_LOGGER.debug(
672+
"Rate limit window exhausted (0 requests remaining). Waiting %d seconds for window reset",
673+
int(wait_seconds),
674+
)
675+
await asyncio.sleep(wait_seconds + 0.1)
676+
return self
677+
678+
time_since_last_request = (now - self._last_request_time).total_seconds()
679+
if time_since_last_request < self.MIN_DELAY_SECONDS:
680+
delay = self.MIN_DELAY_SECONDS - time_since_last_request
681+
_LOGGER.debug(
682+
"Throttling request: waiting %.2f seconds to maintain rate limit (25 req/min)",
683+
delay,
684+
)
685+
await asyncio.sleep(delay)
617686

618687
return self
619688

620689
async def __aexit__(self, exc_type, exc_val, exc_tb):
621-
"""Exit async throttle."""
622-
self._timestamp = datetime.now() + self._delay
690+
"""Exit async throttle - record request time."""
691+
self._last_request_time = datetime.now()
623692

624693

625694
class MyUplink:
@@ -635,7 +704,7 @@ def __init__(
635704
self.auth = auth
636705
self.entry = entry
637706
self.lock = asyncio.Lock()
638-
self.throttle = Throttle(timedelta(seconds=5))
707+
self.throttle = Throttle(auth)
639708

640709
self.header = {"Accept-Language": language_code}
641710

0 commit comments

Comments
 (0)