Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 125 additions & 22 deletions ThermiaOnlineAPI/api/ThermiaAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions ThermiaOnlineAPI/tests/test_retry_resilience.py
Original file line number Diff line number Diff line change
@@ -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("[email protected]", "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("[email protected]", "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()