3535API_URL_BASE = f"https://{ API_URL_HOSTNAME } /v1"
3636
3737DEFAULT_REQUEST_RETRIES = 4
38+ DEFAULT_MEDIA_RETRIES = 4
3839DEFAULT_TIMEOUT = 10
3940DEFAULT_TOKEN_EXPIRATION_WINDOW = 5
4041
@@ -49,12 +50,15 @@ class API: # pylint: disable=too-many-instance-attributes
4950 Args:
5051 session: session: An optional ``aiohttp`` ``ClientSession``.
5152 request_retries: The default number of request retries to use.
53+ media_retries: The default number of request retries to use to
54+ fetch media files.
5255 """
5356
5457 def __init__ (
5558 self ,
5659 * ,
5760 request_retries : int = DEFAULT_REQUEST_RETRIES ,
61+ media_retries : int = DEFAULT_MEDIA_RETRIES ,
5862 session : ClientSession ,
5963 ) -> None :
6064 """Initialize.
@@ -67,6 +71,7 @@ def __init__(
6771 Callable [[str ], Awaitable [None ] | None ]
6872 ] = []
6973 self ._request_retries = request_retries
74+ self ._media_retries = media_retries
7075 self .session : ClientSession = session
7176
7277 # These will get filled in after initial authentication:
@@ -78,7 +83,16 @@ def __init__(
7883 self .user_id : int | None = None
7984 self .websocket : WebsocketClient | None = None
8085
81- self .async_request = self ._wrap_request_method (self ._request_retries )
86+ self .async_request = self ._wrap_request_method (
87+ request_retries = self ._request_retries ,
88+ retry_codes = [401 , 409 ],
89+ request_func = self ._async_api_request ,
90+ )
91+ self ._async_media_data = self ._wrap_request_method (
92+ request_retries = self ._media_retries ,
93+ retry_codes = [401 , 404 , 409 ],
94+ request_func = self ._async_media_request ,
95+ )
8296
8397 @classmethod
8498 async def async_from_auth (
@@ -237,6 +251,38 @@ async def _async_api_request(
237251
238252 return data
239253
254+ async def async_media (self , url : str ) -> bytes | None :
255+ """Fetch a media file and return raw bytes to caller.
256+
257+ Args:
258+ url: An absolute url for the media file.
259+
260+ Returns:
261+ The raw bytes of the media file.
262+ """
263+ data = await self ._async_media_data (url )
264+ return cast (bytes , data ["bytes" ])
265+
266+ async def _async_media_request (self , url : str ) -> dict [str , Any ]:
267+ """Fetch a media file.
268+
269+ Args:
270+ url: An absolute url for the media file.
271+
272+ Returns:
273+ A dict that looks like { "bytes": <raw-bytes> }.
274+ """
275+ async with self .session .request (
276+ "get" ,
277+ url ,
278+ headers = {
279+ "User-Agent" : DEFAULT_USER_AGENT ,
280+ "Authorization" : f"Bearer { self .access_token } " ,
281+ },
282+ ) as resp :
283+ resp .raise_for_status ()
284+ return {"bytes" : await resp .read ()}
285+
240286 @staticmethod
241287 def _handle_on_giveup (_ : dict [str , Any ]) -> None :
242288 """Handle a give up after retries are exhausted.
@@ -260,60 +306,100 @@ def _save_token_data_from_response(self, token_data: dict[str, Any]) -> None:
260306 self .refresh_token = refresh_token
261307
262308 @staticmethod
263- def is_fatal_error (err : ClientResponseError ) -> bool :
309+ def is_fatal_error (
310+ retriable_error_codes : list [int ],
311+ ) -> Callable [[ClientResponseError ], bool ]:
264312 """Determine whether a ClientResponseError is fatal and shouldn't be retried.
265313
266- In general, we retry anything outside of HTTP 4xx codes (client errors) with a
267- few exceptions:
314+ When sending general API requests:
268315
269316 1. 401: We catch this, refresh the access token, and retry the original request.
270317 2. 409: SimpliSafe base stations regular synchronize themselves with the API,
271318 which is where this error can occur; we can't control when/how that
272319 happens (e.g., we might query the API in the middle of a base station
273320 update), so it should be viewed as retryable.
274321
322+ But when fetching media files:
323+
324+ 3. 404: When fetching media files, you may get a 404 if the media file is not
325+ yet available to read. Keep trying however, and it will eventually
326+ return a 200.
327+
275328 Args:
276- err: An ``aiohttp`` ``ClientResponseError``
329+ retriable_error_codes: A list of retriable error status codes.
277330
278331 Returns:
279- Whether the error is a fatal one .
332+ A callable function used by backoff to check for errors .
280333 """
281- if err .status in (401 , 409 ):
282- return False
283- return 400 <= err .status < 500
334+
335+ def check (err : ClientResponseError ) -> bool :
336+ """Perform the check.
337+
338+ Args:
339+ err: An ``aiohttp`` ``ClientResponseError``
340+
341+ Returns:
342+ Whether the error is a fatal one.
343+ """
344+ if err .status in retriable_error_codes :
345+ return False
346+ return 400 <= err .status < 500
347+
348+ return check
284349
285350 def _wrap_request_method (
286- self , request_retries : int
351+ self ,
352+ request_retries : int ,
353+ retry_codes : list [int ],
354+ request_func : Callable [..., Awaitable [dict [str , Any ]]],
287355 ) -> Callable [..., Awaitable [dict [str , Any ]]]:
288- """Wrap the request method in backoff/retry logic.
356+ """Wrap a request method in backoff/retry logic.
289357
290358 Args:
291359 request_retries: The number of retries to give a failed request.
360+ retry_codes: A list of HTTP status codes that cause the retry
361+ loop to continue.
362+ request_func: A function that performs the request.
292363
293364 Returns:
294365 A version of the request callable that can do retries.
295366 """
296- return cast (
297- Callable ,
298- backoff .on_exception (
299- backoff .expo ,
300- ClientResponseError ,
301- giveup = self .is_fatal_error , # type: ignore[arg-type]
302- jitter = backoff .random_jitter ,
303- logger = LOGGER ,
304- max_tries = request_retries ,
305- on_backoff = self ._async_handle_on_backoff , # type: ignore[arg-type]
306- on_giveup = self ._handle_on_giveup , # type: ignore[arg-type]
307- )(self ._async_api_request ),
308- )
367+ return backoff .on_exception (
368+ backoff .expo ,
369+ ClientResponseError ,
370+ giveup = self .is_fatal_error (retry_codes ), # type: ignore[arg-type]
371+ jitter = backoff .random_jitter ,
372+ logger = LOGGER ,
373+ max_tries = request_retries ,
374+ on_backoff = self ._async_handle_on_backoff , # type: ignore[arg-type]
375+ on_giveup = self ._handle_on_giveup , # type: ignore[arg-type]
376+ )(request_func )
309377
310378 def disable_request_retries (self ) -> None :
311379 """Disable the request retry mechanism."""
312- self .async_request = self ._wrap_request_method (1 )
380+ self .async_request = self ._wrap_request_method (
381+ request_retries = 1 ,
382+ retry_codes = [401 , 409 ],
383+ request_func = self ._async_api_request ,
384+ )
385+ self ._async_media_data = self ._wrap_request_method (
386+ request_retries = 1 ,
387+ retry_codes = [401 , 404 , 409 ],
388+ request_func = self ._async_media_request ,
389+ )
313390
314391 def enable_request_retries (self ) -> None :
315392 """Enable the request retry mechanism."""
316- self .async_request = self ._wrap_request_method (self ._request_retries )
393+ self .async_request = self ._wrap_request_method (
394+ request_retries = self ._request_retries ,
395+ retry_codes = [401 , 409 ],
396+ request_func = self ._async_api_request ,
397+ )
398+ self ._async_media_data = self ._wrap_request_method (
399+ request_retries = self ._media_retries ,
400+ retry_codes = [401 , 404 , 409 ],
401+ request_func = self ._async_media_request ,
402+ )
317403
318404 def add_refresh_token_callback (
319405 self , callback : Callable [[str ], Awaitable [None ] | None ]
0 commit comments