1515from __future__ import annotations
1616
1717import base64
18+ import asyncio
1819import json
1920import logging
2021import time
22+ import random
2123from typing import Optional , List , Any
2224
2325import aiohttp
4042
4143
4244class FmdClient :
43- def __init__ (self , base_url : str , session_duration : int = 3600 , * , cache_ttl : int = 30 , timeout : float = 30.0 ):
45+ def __init__ (
46+ self ,
47+ base_url : str ,
48+ session_duration : int = 3600 ,
49+ * ,
50+ cache_ttl : int = 30 ,
51+ timeout : float = 30.0 ,
52+ max_retries : int = 3 ,
53+ backoff_base : float = 0.5 ,
54+ backoff_max : float = 10.0 ,
55+ jitter : bool = True ,
56+ ):
4457 self .base_url = base_url .rstrip ('/' )
4558 self .session_duration = session_duration
4659 self .cache_ttl = cache_ttl
4760 self .timeout = timeout # default timeout for all HTTP requests (seconds)
61+ self .max_retries = max (0 , int (max_retries ))
62+ self .backoff_base = float (backoff_base )
63+ self .backoff_max = float (backoff_max )
64+ self .jitter = bool (jitter )
4865
4966 self ._fmd_id : Optional [str ] = None
5067 self ._password : Optional [str ] = None
@@ -53,6 +70,15 @@ def __init__(self, base_url: str, session_duration: int = 3600, *, cache_ttl: in
5370
5471 self ._session : Optional [aiohttp .ClientSession ] = None
5572
73+ # -------------------------
74+ # Async context manager
75+ # -------------------------
76+ async def __aenter__ (self ) -> "FmdClient" :
77+ return self
78+
79+ async def __aexit__ (self , exc_type , exc , tb ) -> None :
80+ await self .close ()
81+
5682 @classmethod
5783 async def create (
5884 cls ,
@@ -180,61 +206,110 @@ def decrypt_data_blob(self, data_b64: str) -> bytes:
180206 # HTTP helper
181207 # -------------------------
182208 async def _make_api_request (self , method : str , endpoint : str , payload : Any ,
183- stream : bool = False , expect_json : bool = True , retry_auth : bool = True , timeout : Optional [float ] = None ):
209+ stream : bool = False , expect_json : bool = True , retry_auth : bool = True ,
210+ timeout : Optional [float ] = None , max_retries : Optional [int ] = None ):
184211 """
185212 Makes an API request and returns Data or text depending on expect_json/stream.
186213 Mirrors get_all_locations/_make_api_request logic from original file (including 401 re-auth).
187214 """
188215 url = self .base_url + endpoint
189216 await self ._ensure_session ()
190217 req_timeout = aiohttp .ClientTimeout (total = timeout if timeout is not None else self .timeout )
191- try :
192- async with self ._session .request (method , url , json = payload , timeout = req_timeout ) as resp :
193- # Handle 401 -> re-authenticate once
194- if resp .status == 401 and retry_auth and self ._fmd_id and self ._password :
195- log .info ("Received 401 Unauthorized, re-authenticating..." )
196- await self .authenticate (
197- self ._fmd_id , self ._password , self .session_duration )
198- payload ["IDT" ] = self .access_token
199- return await self ._make_api_request (
200- method , endpoint , payload , stream , expect_json ,
201- retry_auth = False , timeout = timeout )
202218
203- resp .raise_for_status ()
204- log .debug (
205- f"{ endpoint } response - status: { resp .status } , "
206- f"content-type: { resp .content_type } , "
207- f"content-length: { resp .content_length } " )
208-
209- if not stream :
210- if expect_json :
211- # server sometimes reports wrong content-type -> force JSON parse
212- try :
213- json_data = await resp .json (content_type = None )
214- log .debug (f"{ endpoint } JSON response: { json_data } " )
215- return json_data ["Data" ]
216- except (KeyError , ValueError , json .JSONDecodeError ) as e :
217- # fall back to text
218- log .debug (f"{ endpoint } JSON parsing failed ({ e } ), trying as text" )
219+ # Determine retry policy
220+ attempts_left = self .max_retries if max_retries is None else max (0 , int (max_retries ))
221+
222+ # Avoid unsafe retries for commands unless it's a 401 (handled separately) or 429 with Retry-After
223+ is_command = endpoint .rstrip ('/' ).endswith ('/api/v1/command' )
224+
225+ backoff_attempt = 0
226+ while True :
227+ try :
228+ async with self ._session .request (method , url , json = payload , timeout = req_timeout ) as resp :
229+ # Handle 401 -> re-authenticate once
230+ if resp .status == 401 and retry_auth and self ._fmd_id and self ._password :
231+ log .info ("Received 401 Unauthorized, re-authenticating..." )
232+ await self .authenticate (
233+ self ._fmd_id , self ._password , self .session_duration )
234+ payload ["IDT" ] = self .access_token
235+ return await self ._make_api_request (
236+ method , endpoint , payload , stream , expect_json ,
237+ retry_auth = False , timeout = timeout , max_retries = attempts_left )
238+
239+ # Rate limit handling (429)
240+ if resp .status == 429 :
241+ if attempts_left <= 0 :
242+ # Exhausted retries
243+ body_text = await _safe_read_text (resp )
244+ raise FmdApiException (f"Rate limited (429) and retries exhausted. Body={ body_text [:200 ] if body_text else '' } " )
245+ retry_after = resp .headers .get ('Retry-After' )
246+ delay = _parse_retry_after (retry_after )
247+ if delay is None :
248+ delay = _compute_backoff (self .backoff_base , backoff_attempt , self .backoff_max , self .jitter )
249+ log .warning (f"Received 429 Too Many Requests. Sleeping { delay :.2f} s before retrying..." )
250+ attempts_left -= 1
251+ backoff_attempt += 1
252+ await asyncio .sleep (delay )
253+ continue
254+
255+ # Transient server errors -> retry (except for unsafe command POSTs)
256+ if resp .status in (500 , 502 , 503 , 504 ) and not (is_command and method .upper () == 'POST' ):
257+ if attempts_left > 0 :
258+ delay = _compute_backoff (self .backoff_base , backoff_attempt , self .backoff_max , self .jitter )
259+ log .warning (f"Server error { resp .status } . Retrying in { delay :.2f} s ({ attempts_left } retries left)..." )
260+ attempts_left -= 1
261+ backoff_attempt += 1
262+ await asyncio .sleep (delay )
263+ continue
264+
265+ # For all other statuses, raise for non-2xx
266+ resp .raise_for_status ()
267+
268+ log .debug (
269+ f"{ endpoint } response - status: { resp .status } , "
270+ f"content-type: { resp .content_type } , "
271+ f"content-length: { resp .content_length } " )
272+
273+ if not stream :
274+ if expect_json :
275+ # server sometimes reports wrong content-type -> force JSON parse
276+ try :
277+ json_data = await resp .json (content_type = None )
278+ log .debug (f"{ endpoint } JSON response: { json_data } " )
279+ return json_data ["Data" ]
280+ except (KeyError , ValueError , json .JSONDecodeError ) as e :
281+ # fall back to text
282+ log .debug (f"{ endpoint } JSON parsing failed ({ e } ), trying as text" )
283+ text_data = await resp .text ()
284+ if text_data :
285+ log .debug (f"{ endpoint } first 200 chars: { text_data [:200 ]} " )
286+ else :
287+ log .warning (f"{ endpoint } returned EMPTY response body" )
288+ return text_data
289+ else :
219290 text_data = await resp .text ()
220- if text_data :
221- log .debug (f"{ endpoint } first 200 chars: { text_data [:200 ]} " )
222- else :
223- log .warning (f"{ endpoint } returned EMPTY response body" )
291+ log .debug (f"{ endpoint } text response length: { len (text_data )} " )
224292 return text_data
225293 else :
226- text_data = await resp .text ()
227- log .debug (f"{ endpoint } text response length: { len (text_data )} " )
228- return text_data
229- else :
230- # Return the aiohttp response for streaming consumers
231- return resp
232- except aiohttp .ClientError as e :
233- log .error (f"API request failed for { endpoint } : { e } " )
234- raise FmdApiException (f"API request failed for { endpoint } : { e } " ) from e
235- except (KeyError , ValueError ) as e :
236- log .error (f"Failed to parse server response for { endpoint } : { e } " )
237- raise FmdApiException (f"Failed to parse server response for { endpoint } : { e } " ) from e
294+ # Return the aiohttp response for streaming consumers
295+ return resp
296+ except aiohttp .ClientConnectionError as e :
297+ # Transient connection issues -> retry if allowed (avoid unsafe command repeats)
298+ if attempts_left > 0 and not (is_command and method .upper () == 'POST' ):
299+ delay = _compute_backoff (self .backoff_base , backoff_attempt , self .backoff_max , self .jitter )
300+ log .warning (f"Connection error calling { endpoint } : { e } . Retrying in { delay :.2f} s..." )
301+ attempts_left -= 1
302+ backoff_attempt += 1
303+ await asyncio .sleep (delay )
304+ continue
305+ log .error (f"API request failed for { endpoint } : { e } " )
306+ raise FmdApiException (f"API request failed for { endpoint } : { e } " ) from e
307+ except aiohttp .ClientError as e :
308+ log .error (f"API request failed for { endpoint } : { e } " )
309+ raise FmdApiException (f"API request failed for { endpoint } : { e } " ) from e
310+ except (KeyError , ValueError ) as e :
311+ log .error (f"Failed to parse server response for { endpoint } : { e } " )
312+ raise FmdApiException (f"Failed to parse server response for { endpoint } : { e } " ) from e
238313
239314 # -------------------------
240315 # Location / picture access
@@ -534,3 +609,35 @@ async def take_picture(self, camera: str = "back") -> bool:
534609 command = "camera front" if camera == "front" else "camera back"
535610 log .info (f"Requesting picture from { camera } camera" )
536611 return await self .send_command (command )
612+
613+
614+ # -------------------------
615+ # Internal helpers for retry/backoff (module-level)
616+ # -------------------------
617+ def _compute_backoff (base : float , attempt : int , max_delay : float , jitter : bool ) -> float :
618+ delay = min (max_delay , base * (2 ** attempt ))
619+ if jitter :
620+ # Full jitter: random between 0 and delay
621+ return random .uniform (0 , delay )
622+ return delay
623+
624+
625+ def _parse_retry_after (retry_after_header : Optional [str ]) -> Optional [float ]:
626+ """Parse Retry-After header. Supports seconds; returns None if not usable."""
627+ if not retry_after_header :
628+ return None
629+ try :
630+ seconds = int (retry_after_header .strip ())
631+ if seconds < 0 :
632+ return None
633+ return float (seconds )
634+ except Exception :
635+ # Parsing HTTP-date would require email.utils; skip and return None
636+ return None
637+
638+
639+ async def _safe_read_text (resp : aiohttp .ClientResponse ) -> Optional [str ]:
640+ try :
641+ return await resp .text ()
642+ except Exception :
643+ return None
0 commit comments