diff --git a/ThermiaOnlineAPI/api/ThermiaAPI.py b/ThermiaOnlineAPI/api/ThermiaAPI.py index a4a450f..b9cabb1 100644 --- a/ThermiaOnlineAPI/api/ThermiaAPI.py +++ b/ThermiaOnlineAPI/api/ThermiaAPI.py @@ -65,21 +65,102 @@ def __init__(self, email, password): "Access-Control-Allow-Origin": "*", } - self.__session = requests.Session() + self.__session = self._create_session() + + self.configuration = self.__fetch_configuration() + self.authenticated = self.__authenticate() + + def _create_session(self) -> requests.Session: + """Create a configured requests session with retries for transient errors.""" + session = requests.Session() retry = Retry( - total=20, backoff_factor=0.1, status_forcelist=[500, 502, 503, 504] + total=3, + backoff_factor=0.5, + status_forcelist=[500, 502, 503, 504], + allowed_methods=frozenset( + ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"] + ), + raise_on_status=False, ) adapter = HTTPAdapter(max_retries=retry) - self.__session.mount("https://", adapter) + session.mount("https://", adapter) + session.mount("http://", adapter) + return session + + def _request_with_retry( + self, + method: str, + url: str, + max_retries: int = 3, + timeout: int = 15, + **kwargs, + ) -> requests.Response: + """Execute an HTTP request with automatic retry, timeout, and session recovery.""" + kwargs.setdefault("timeout", timeout) + last_exception = None + + for attempt in range(max_retries): + try: + if method.upper() == "GET": + return self.__session.get(url, **kwargs) + elif method.upper() == "POST": + return self.__session.post(url, **kwargs) + else: + return self.__session.request(method, url, **kwargs) + except ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + ConnectionResetError, + TimeoutError, + OSError, + ) as exc: + last_exception = exc + _LOGGER.warning( + "Network error (%s) on %s %s (attempt %d/%d). Resetting session and retrying...", + type(exc).__name__, + method, + url, + attempt + 1, + max_retries, + ) + try: + self.__session.close() + except Exception: + pass + self.__session = self._create_session() + + # Re-authenticate if session was previously authenticated and this isn't an auth endpoint + if ( + hasattr(self, "authenticated") + and self.authenticated + and "oauth2" not in url + and "SelfAsserted" not in url + and "CombinedSigninAndSignup" not in url + ): + try: + self.__check_token_validity() + except Exception as auth_exc: + _LOGGER.warning( + "Re-authentication during retry failed: %s", auth_exc + ) - self.configuration = self.__fetch_configuration() - self.authenticated = self.__authenticate() + _LOGGER.error( + "Failed %s request to %s after %d attempts: %s", + method, + url, + max_retries, + last_exception, + ) + raise NetworkException(f"Network error during {method} {url}: {last_exception}") def get_devices(self): self.__check_token_validity() url = self.configuration["apiBaseUrl"] + "/api/v1/installationsInfo" - request = self.__session.get(url, headers=self.__default_request_headers) + request = self._request_with_retry( + "GET", url, headers=self.__default_request_headers + ) status = request.status_code if status != 200: @@ -114,7 +195,9 @@ def get_device_info(self, device_id: str): self.__check_token_validity() url = self.configuration["apiBaseUrl"] + "/api/v1/installations/" + device_id - request = self.__session.get(url, headers=self.__default_request_headers) + request = self._request_with_retry( + "GET", url, headers=self.__default_request_headers + ) status = request.status_code if status != 200: @@ -139,7 +222,9 @@ def get_device_status(self, device_id: str): + device_id + "/status" ) - request = self.__session.get(url, headers=self.__default_request_headers) + request = self._request_with_retry( + "GET", url, headers=self.__default_request_headers + ) status = request.status_code if status != 200: @@ -164,7 +249,9 @@ def get_all_alarms(self, device_id: str): + str(device_id) + "/events?onlyActiveAlarms=false" ) - request = self.__session.get(url, headers=self.__default_request_headers) + request = self._request_with_retry( + "GET", url, headers=self.__default_request_headers + ) status = request.status_code if status != 200: @@ -188,7 +275,9 @@ def get_historical_data_registers(self, device_id: str): + "/api/v1/DataHistory/installation/" + str(device_id) ) - request = self.__session.get(url, headers=self.__default_request_headers) + request = self._request_with_retry( + "GET", url, headers=self.__default_request_headers + ) status = request.status_code if status != 200: @@ -220,7 +309,9 @@ def get_historical_data( + "&periodEnd=" + end_date_str ) - request = self.__session.get(url, headers=self.__default_request_headers) + request = self._request_with_retry( + "GET", url, headers=self.__default_request_headers + ) status = request.status_code if status != 200: @@ -246,7 +337,9 @@ def get_all_available_groups(self, installation_profile_id: int): + "/groups" ) - request = self.__session.get(url, headers=self.__default_request_headers) + request = self._request_with_retry( + "GET", url, headers=self.__default_request_headers + ) status = request.status_code if status != 200: @@ -477,7 +570,9 @@ def __get_register_group(self, device_id: str, register_group: str) -> list: + "/Groups/" + register_group ) - request = self.__session.get(url, headers=self.__default_request_headers) + request = self._request_with_retry( + "GET", url, headers=self.__default_request_headers + ) status = request.status_code if status != 200: @@ -512,8 +607,8 @@ def __set_register_value( "clientUuid": "api-client-uuid", } - request = self.__session.post( - url, headers=self.__default_request_headers, json=body + request = self._request_with_retry( + "POST", url, headers=self.__default_request_headers, json=body ) status = request.status_code @@ -526,9 +621,13 @@ def __set_register_value( + ", Response: " + request.text ) + raise NetworkException( + f"Error setting register {register_index} value. Status: {status}", + status, + ) def __fetch_configuration(self): - request = self.__session.get(THERMIA_CONFIG_URL) + request = self._request_with_retry("GET", THERMIA_CONFIG_URL) status = request.status_code if status != 200: @@ -553,7 +652,8 @@ def __authenticate_refresh_token(self) -> Optional[str]: "grant_type": "refresh_token", } - request_token = self.__session.post( + request_token = self._request_with_retry( + "POST", AZURE_AUTH_GET_TOKEN_URL, headers=azure_auth_request_headers, data=request_token__data, @@ -605,8 +705,8 @@ def __authenticate(self) -> bool: "code_challenge_method": "S256", } - request_auth = self.__session.get( - AZURE_AUTH_AUTHORIZE_URL, data=request_auth__data + request_auth = self._request_with_retry( + "GET", AZURE_AUTH_AUTHORIZE_URL, data=request_auth__data ) state_code = "" @@ -653,7 +753,8 @@ def __authenticate(self) -> bool: "p": "B2C_1A_SignUpOrSigninOnline", } - request_self_asserted = self.__session.post( + request_self_asserted = self._request_with_retry( + "POST", AZURE_SELF_ASSERTED_URL, cookies=request_auth.cookies, data=request_self_asserted__data, @@ -686,7 +787,8 @@ def __authenticate(self) -> bool: "p": "B2C_1A_SignUpOrSigninOnline", } - request_confirmed = self.__session.get( + request_confirmed = self._request_with_retry( + "GET", AZURE_AUTH_CONFIRM_URL, cookies=request_confirmed__cookies, params=request_confirmed__params, @@ -703,7 +805,8 @@ def __authenticate(self) -> bool: "grant_type": "authorization_code", } - request_token = self.__session.post( + request_token = self._request_with_retry( + "POST", AZURE_AUTH_GET_TOKEN_URL, headers=azure_auth_request_headers, data=request_token__data, diff --git a/ThermiaOnlineAPI/tests/test_retry_resilience.py b/ThermiaOnlineAPI/tests/test_retry_resilience.py new file mode 100644 index 0000000..d4587a5 --- /dev/null +++ b/ThermiaOnlineAPI/tests/test_retry_resilience.py @@ -0,0 +1,70 @@ +import unittest +from unittest.mock import MagicMock, patch +import requests + +from ThermiaOnlineAPI.api.ThermiaAPI import ThermiaAPI +from ThermiaOnlineAPI.exceptions.NetworkException import NetworkException + + +class TestRetryResilience(unittest.TestCase): + @patch.object(ThermiaAPI, "_ThermiaAPI__fetch_configuration") + @patch.object(ThermiaAPI, "_ThermiaAPI__authenticate") + def test_request_retries_on_connection_reset_and_succeeds( + self, mock_auth, mock_fetch_config + ): + mock_fetch_config.return_value = {"apiBaseUrl": "https://test.thermia.se"} + mock_auth.return_value = True + + api = ThermiaAPI("test@example.com", "password") + + # Mock session to fail on first attempt with ConnectionResetError, then succeed + mock_response = MagicMock(spec=requests.Response) + mock_response.status_code = 200 + mock_response.text = '{"success": true}' + + side_effects = [ + requests.exceptions.ConnectionError( + ConnectionResetError(104, "Connection reset by peer") + ), + mock_response, + ] + + with patch.object(api, "_create_session") as mock_create_session: + mock_new_session = MagicMock() + mock_new_session.post.side_effect = side_effects + mock_create_session.return_value = mock_new_session + api._ThermiaAPI__session = mock_new_session + + response = api._request_with_retry( + "POST", "https://test.thermia.se/Registers", max_retries=3 + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_new_session.post.call_count, 2) + + @patch.object(ThermiaAPI, "_ThermiaAPI__fetch_configuration") + @patch.object(ThermiaAPI, "_ThermiaAPI__authenticate") + def test_request_raises_network_exception_when_all_retries_exhausted( + self, mock_auth, mock_fetch_config + ): + mock_fetch_config.return_value = {"apiBaseUrl": "https://test.thermia.se"} + mock_auth.return_value = True + + api = ThermiaAPI("test@example.com", "password") + + with patch.object(api, "_create_session") as mock_create_session: + mock_session = MagicMock() + mock_session.get.side_effect = requests.exceptions.ConnectionError( + ConnectionResetError(104, "Connection reset by peer") + ) + mock_create_session.return_value = mock_session + api._ThermiaAPI__session = mock_session + + with self.assertRaises(NetworkException): + api._request_with_retry( + "GET", "https://test.thermia.se/status", max_retries=3 + ) + + +if __name__ == "__main__": + unittest.main()