@@ -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
82125class 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
601644class 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
625694class 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