11"""Define functionality for interacting with the SimpliSafe API."""
22from __future__ import annotations
33
4+ from datetime import datetime , timedelta
45from json .decoder import JSONDecodeError
56import sys
6- from typing import Any , Callable
7+ from typing import TYPE_CHECKING , Any , Callable
78
89from aiohttp import ClientSession
910from aiohttp .client_exceptions import ClientResponseError
2829API_URL_HOSTNAME = "api.simplisafe.com"
2930API_URL_BASE = f"https://{ API_URL_HOSTNAME } /v1"
3031
32+ DEFAULT_EXPIRATION_PADDING = 60
3133DEFAULT_REQUEST_RETRIES = 4
3234DEFAULT_TIMEOUT = 10
3335
3436
37+ def get_expiration_datetime (expires_in_seconds : int ) -> datetime :
38+ """Get a token expiration datetime as an offset of UTC now + a number of seconds.
39+
40+ Note that we pad the value to ensure the token doesn't expire without us knowing.
41+ """
42+ return datetime .utcnow () + (
43+ timedelta (seconds = expires_in_seconds - DEFAULT_EXPIRATION_PADDING )
44+ )
45+
46+
3547class API : # pylint: disable=too-many-instance-attributes
3648 """An API object to interact with the SimpliSafe cloud.
3749
@@ -56,6 +68,7 @@ def __init__(
5668 self .session : ClientSession = session
5769
5870 # These will get filled in after initial authentication:
71+ self ._access_token_expire_dt : datetime | None = None
5972 self .access_token : str | None = None
6073 self .refresh_token : str | None = None
6174 self .subscription_data : dict [int , Any ] = {}
@@ -114,6 +127,7 @@ async def async_from_auth(
114127 raise InvalidCredentialsError ("Invalid credentials" ) from err
115128 raise RequestError (err ) from err
116129
130+ api ._access_token_expire_dt = get_expiration_datetime (token_resp ["expires_in" ])
117131 api .access_token = token_resp ["access_token" ]
118132 api .refresh_token = token_resp ["refresh_token" ]
119133 await api ._async_post_init ()
@@ -149,8 +163,14 @@ async def _async_handle_on_backoff(self, _: dict[str, Any]) -> None:
149163 err : ClientResponseError = err_info [1 ].with_traceback (err_info [2 ]) # type: ignore
150164
151165 if err .status == 401 or err .status == 403 :
152- LOGGER .info ("401 detected; attempting refresh token" )
153- await self ._async_refresh_access_token ()
166+ if TYPE_CHECKING :
167+ assert self ._access_token_expire_dt
168+ if datetime .utcnow () >= self ._access_token_expire_dt :
169+ # Since we might have multiple requests (each running their own retry
170+ # sequence) land here, we only refresh the access token if it hasn't
171+ # been refreshed within the expiration window:
172+ LOGGER .info ("401 detected; attempting refresh token" )
173+ await self ._async_refresh_access_token ()
154174
155175 async def _async_handle_on_giveup (self , _ : dict [str , Any ]) -> None :
156176 """Handle a give up after retries are exhausted."""
@@ -183,6 +203,7 @@ async def _async_refresh_access_token(self) -> None:
183203 raise InvalidCredentialsError ("Invalid refresh token" ) from err
184204 raise RequestError (err ) from err
185205
206+ self ._access_token_expire_dt = get_expiration_datetime (token_resp ["expires_in" ])
186207 self .access_token = token_resp ["access_token" ]
187208 self .refresh_token = token_resp ["refresh_token" ]
188209
0 commit comments