From a067423e6edd9d3a383b80cdb8063705a90a0ab9 Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Wed, 3 Jun 2026 14:31:18 +0100 Subject: [PATCH 01/12] Pre-emptive token refresh mechanism --- src/ansys/hps/client/client.py | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index 4e9351ed1..d4256a466 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -27,8 +27,12 @@ import os import platform import tempfile +import threading +import time import warnings +import arrow +import humanfriendly import jwt import requests from ansys.hps.data_transfer.client import Client as DataTransferClient @@ -162,6 +166,7 @@ def __init__( self.client_secret = client_secret self.verify = verify self.data_transfer_url = url + "/dt/api/v1" + self._token_refresh_thread = None self._dt_client: DataTransferClient | None = None self._dt_api: DataTransferApi | None = None @@ -214,6 +219,44 @@ def __init__( # client credentials flow does not return a refresh token self.refresh_token = tokens.get("refresh_token", None) + expires_in = [] + access_expires_in = tokens.get("expires_in", None) + if access_expires_in is not None: + # TODO: switch to trace level logging for token details + log.info( + f"Access token expires in {humanfriendly.format_timespan(access_expires_in)}" + ) + expires_in.append(access_expires_in) + refresh_expires_in = tokens.get("refresh_expires_in", None) + if refresh_expires_in is not None: + info = ( + "offline" + if refresh_expires_in == 0 and "offline_access" in scope + else f"expires in {humanfriendly.format_timespan(refresh_expires_in)}" + ) + # TODO: switch to trace level logging for token details + log.info(f"Refresh token {info}") + if refresh_expires_in > 0: + expires_in.append(refresh_expires_in) + self.token_expires_in = min(expires_in) if expires_in else None + if self.token_expires_in is not None: + # TODO: switch to trace level logging for token details + log.info( + "Setting token expiry to " + f"{humanfriendly.format_timespan(self.token_expires_in)}" + ) + self.token_acquired_date = arrow.now() if self.token_expires_in is not None else None + + # Set token_refresh_factor to 95%? + self.token_refresh_factor = 0.95 + offset = max(1, int(self.token_expires_in * self.token_refresh_factor)) + self.token_refresh_date = self.token_acquired_date.shift(seconds=offset) + # TODO: switch to trace level logging for token details + log.info( + "Refresh token set, auto refresh in " + f"{humanfriendly.format_timespan(offset)} ({self.token_refresh_date})" + ) + parsed_username = None token = {} try: @@ -244,6 +287,8 @@ def __init__( self.session.hooks["response"] = [self._auto_refresh_token, raise_for_status] self._unauthorized_num_retry = 0 self._unauthorized_max_retry = 1 + if self.token_refresh_date is not None: + self._start_token_refresh_thread(self.token_refresh_date) def exit_handler(): if self._dt_client is not None: @@ -344,6 +389,41 @@ def auth_api_url(self) -> str: log.error("auth_api not valid for non-keycloak implementation") return None + def _start_token_refresh_thread(self, refresh_date): + """Start a background thread to refresh the access token.""" + if self._token_refresh_thread is not None and self._token_refresh_thread.is_alive(): + return + + self._token_refresh_thread = threading.Thread( + target=self._periodically_refresh_token, + args=(refresh_date,), + name="periodic_token_refresh", + ) + self._token_refresh_thread.daemon = True + self._token_refresh_thread.start() + + def _periodically_refresh_token(self, refresh_date): + self.loop_interval = 60 # Check every 60 seconds + + while True: + if refresh_date is None: + time.sleep(self.loop_interval) + continue + + now = arrow.now() + if now > refresh_date: + log.debug("Attempting preemptive authentication token refresh") + self.refresh_access_token() + else: + diff = refresh_date - now + sleep_time = min(self.loop_interval, diff.total_seconds() * 0.25) + sleep_time = max(0.1, sleep_time) + if sleep_time >= 1.0: + # TODO: switch to trace level logging for token details + log.info(f"Token refresh in {humanfriendly.format_timespan(diff)}") + time.sleep(sleep_time) + log.debug("Token refresh thread stopped") + def _auto_refresh_token(self, response, *args, **kwargs): """Provide a callback for refreshing an expired token. @@ -396,6 +476,7 @@ def refresh_access_token(self): self.access_token = tokens["access_token"] self.refresh_token = tokens.get("refresh_token", None) self.session.headers.update({"Authorization": f"Bearer {tokens['access_token']}"}) + # TODO: set self.token_refresh_date again! @property def data_transfer_client(self) -> DataTransferClient: From fab3707cbeea4f2737c94426eb407c78f89e793e Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Wed, 3 Jun 2026 14:40:57 +0100 Subject: [PATCH 02/12] introduce _update_token_expiry --- src/ansys/hps/client/client.py | 80 ++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index d4256a466..2f107b96b 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -219,43 +219,7 @@ def __init__( # client credentials flow does not return a refresh token self.refresh_token = tokens.get("refresh_token", None) - expires_in = [] - access_expires_in = tokens.get("expires_in", None) - if access_expires_in is not None: - # TODO: switch to trace level logging for token details - log.info( - f"Access token expires in {humanfriendly.format_timespan(access_expires_in)}" - ) - expires_in.append(access_expires_in) - refresh_expires_in = tokens.get("refresh_expires_in", None) - if refresh_expires_in is not None: - info = ( - "offline" - if refresh_expires_in == 0 and "offline_access" in scope - else f"expires in {humanfriendly.format_timespan(refresh_expires_in)}" - ) - # TODO: switch to trace level logging for token details - log.info(f"Refresh token {info}") - if refresh_expires_in > 0: - expires_in.append(refresh_expires_in) - self.token_expires_in = min(expires_in) if expires_in else None - if self.token_expires_in is not None: - # TODO: switch to trace level logging for token details - log.info( - "Setting token expiry to " - f"{humanfriendly.format_timespan(self.token_expires_in)}" - ) - self.token_acquired_date = arrow.now() if self.token_expires_in is not None else None - - # Set token_refresh_factor to 95%? - self.token_refresh_factor = 0.95 - offset = max(1, int(self.token_expires_in * self.token_refresh_factor)) - self.token_refresh_date = self.token_acquired_date.shift(seconds=offset) - # TODO: switch to trace level logging for token details - log.info( - "Refresh token set, auto refresh in " - f"{humanfriendly.format_timespan(offset)} ({self.token_refresh_date})" - ) + self._update_token_expiry(tokens) parsed_username = None token = {} @@ -402,6 +366,46 @@ def _start_token_refresh_thread(self, refresh_date): self._token_refresh_thread.daemon = True self._token_refresh_thread.start() + def _update_token_expiry(self, tokens): + """Update expiry-related fields from a token response.""" + expires_in = [] + access_expires_in = tokens.get("expires_in", None) + if access_expires_in is not None: + # TODO: switch to trace level logging for token details + log.info(f"Access token expires in {humanfriendly.format_timespan(access_expires_in)}") + expires_in.append(access_expires_in) + refresh_expires_in = tokens.get("refresh_expires_in", None) + if refresh_expires_in is not None: + info = ( + "offline" + if refresh_expires_in == 0 and "offline_access" in self.scope + else f"expires in {humanfriendly.format_timespan(refresh_expires_in)}" + ) + # TODO: switch to trace level logging for token details + log.info(f"Refresh token {info}") + if refresh_expires_in > 0: + expires_in.append(refresh_expires_in) + self.token_expires_in = min(expires_in) if expires_in else None + if self.token_expires_in is not None: + # TODO: switch to trace level logging for token details + log.info( + f"Setting token expiry to {humanfriendly.format_timespan(self.token_expires_in)}" + ) + self.token_acquired_date = arrow.now() if self.token_expires_in is not None else None + + # Set token_refresh_factor to 95%? + self.token_refresh_factor = 0.95 + if self.token_expires_in is not None: + offset = max(1, int(self.token_expires_in * self.token_refresh_factor)) + self.token_refresh_date = self.token_acquired_date.shift(seconds=offset) + # TODO: switch to trace level logging for token details + log.info( + "Refresh token set, auto refresh in " + f"{humanfriendly.format_timespan(offset)} ({self.token_refresh_date})" + ) + else: + self.token_refresh_date = None + def _periodically_refresh_token(self, refresh_date): self.loop_interval = 60 # Check every 60 seconds @@ -476,7 +480,7 @@ def refresh_access_token(self): self.access_token = tokens["access_token"] self.refresh_token = tokens.get("refresh_token", None) self.session.headers.update({"Authorization": f"Bearer {tokens['access_token']}"}) - # TODO: set self.token_refresh_date again! + self._update_token_expiry(tokens) @property def data_transfer_client(self) -> DataTransferClient: From 48982fdd4884cbe9dd513c2bc665d6ab05cc1c3d Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Wed, 3 Jun 2026 14:42:53 +0100 Subject: [PATCH 03/12] move vars to top --- src/ansys/hps/client/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index 2f107b96b..1310c7223 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -167,6 +167,9 @@ def __init__( self.verify = verify self.data_transfer_url = url + "/dt/api/v1" self._token_refresh_thread = None + # Set token_refresh_factor to 95%? + self.token_refresh_factor = 0.95 + self.loop_interval = 60 # Check every 60 seconds self._dt_client: DataTransferClient | None = None self._dt_api: DataTransferApi | None = None @@ -393,8 +396,6 @@ def _update_token_expiry(self, tokens): ) self.token_acquired_date = arrow.now() if self.token_expires_in is not None else None - # Set token_refresh_factor to 95%? - self.token_refresh_factor = 0.95 if self.token_expires_in is not None: offset = max(1, int(self.token_expires_in * self.token_refresh_factor)) self.token_refresh_date = self.token_acquired_date.shift(seconds=offset) @@ -407,8 +408,7 @@ def _update_token_expiry(self, tokens): self.token_refresh_date = None def _periodically_refresh_token(self, refresh_date): - self.loop_interval = 60 # Check every 60 seconds - + """Periodically check if the token needs to be refreshed and refresh it.""" while True: if refresh_date is None: time.sleep(self.loop_interval) From 156d466f739959be58dd54e3b10733ec4cf70224 Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Wed, 3 Jun 2026 14:58:31 +0100 Subject: [PATCH 04/12] fix loop issue --- src/ansys/hps/client/client.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index 1310c7223..ee6661b68 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -255,7 +255,7 @@ def __init__( self._unauthorized_num_retry = 0 self._unauthorized_max_retry = 1 if self.token_refresh_date is not None: - self._start_token_refresh_thread(self.token_refresh_date) + self._start_token_refresh_thread() def exit_handler(): if self._dt_client is not None: @@ -356,14 +356,13 @@ def auth_api_url(self) -> str: log.error("auth_api not valid for non-keycloak implementation") return None - def _start_token_refresh_thread(self, refresh_date): + def _start_token_refresh_thread(self): """Start a background thread to refresh the access token.""" if self._token_refresh_thread is not None and self._token_refresh_thread.is_alive(): return self._token_refresh_thread = threading.Thread( target=self._periodically_refresh_token, - args=(refresh_date,), name="periodic_token_refresh", ) self._token_refresh_thread.daemon = True @@ -407,19 +406,19 @@ def _update_token_expiry(self, tokens): else: self.token_refresh_date = None - def _periodically_refresh_token(self, refresh_date): + def _periodically_refresh_token(self): """Periodically check if the token needs to be refreshed and refresh it.""" while True: - if refresh_date is None: + if self.token_refresh_date is None: time.sleep(self.loop_interval) continue now = arrow.now() - if now > refresh_date: + if now > self.token_refresh_date: log.debug("Attempting preemptive authentication token refresh") self.refresh_access_token() else: - diff = refresh_date - now + diff = self.token_refresh_date - now sleep_time = min(self.loop_interval, diff.total_seconds() * 0.25) sleep_time = max(0.1, sleep_time) if sleep_time >= 1.0: From 198285fab89b42f376acf37a688a5221204c2833 Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Wed, 3 Jun 2026 15:09:47 +0100 Subject: [PATCH 05/12] Use threading.Event --- src/ansys/hps/client/client.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index ee6661b68..ddadcdb05 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -28,7 +28,6 @@ import platform import tempfile import threading -import time import warnings import arrow @@ -167,6 +166,7 @@ def __init__( self.verify = verify self.data_transfer_url = url + "/dt/api/v1" self._token_refresh_thread = None + self._stop_event = threading.Event() # Set token_refresh_factor to 95%? self.token_refresh_factor = 0.95 self.loop_interval = 60 # Check every 60 seconds @@ -258,6 +258,9 @@ def __init__( self._start_token_refresh_thread() def exit_handler(): + self._stop_event.set() + if self._token_refresh_thread is not None: + self._token_refresh_thread.join(timeout=5) if self._dt_client is not None: log.info("Stopping the data transfer client gracefully.") self._dt_client.stop() @@ -408,23 +411,25 @@ def _update_token_expiry(self, tokens): def _periodically_refresh_token(self): """Periodically check if the token needs to be refreshed and refresh it.""" - while True: + while not self._stop_event.is_set(): if self.token_refresh_date is None: - time.sleep(self.loop_interval) + if self._stop_event.wait(self.loop_interval): + break continue now = arrow.now() if now > self.token_refresh_date: log.debug("Attempting preemptive authentication token refresh") self.refresh_access_token() - else: - diff = self.token_refresh_date - now - sleep_time = min(self.loop_interval, diff.total_seconds() * 0.25) - sleep_time = max(0.1, sleep_time) - if sleep_time >= 1.0: - # TODO: switch to trace level logging for token details - log.info(f"Token refresh in {humanfriendly.format_timespan(diff)}") - time.sleep(sleep_time) + continue + + diff = self.token_refresh_date - now + sleep_time = max(0.1, min(self.loop_interval, diff.total_seconds() * 0.25)) + if sleep_time >= 1.0: + # TODO: switch to trace level logging for token details + log.info(f"Token refresh in {humanfriendly.format_timespan(diff)}") + if self._stop_event.wait(sleep_time): + break log.debug("Token refresh thread stopped") def _auto_refresh_token(self, response, *args, **kwargs): From 8189b1d632c4e1aed3388f8309c1979270135ac6 Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Wed, 3 Jun 2026 15:27:27 +0100 Subject: [PATCH 06/12] Add tests --- src/ansys/hps/client/client.py | 15 +++------- tests/test_client.py | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index ddadcdb05..89f454560 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -376,8 +376,7 @@ def _update_token_expiry(self, tokens): expires_in = [] access_expires_in = tokens.get("expires_in", None) if access_expires_in is not None: - # TODO: switch to trace level logging for token details - log.info(f"Access token expires in {humanfriendly.format_timespan(access_expires_in)}") + log.debug(f"Access token expires in {humanfriendly.format_timespan(access_expires_in)}") expires_in.append(access_expires_in) refresh_expires_in = tokens.get("refresh_expires_in", None) if refresh_expires_in is not None: @@ -386,14 +385,12 @@ def _update_token_expiry(self, tokens): if refresh_expires_in == 0 and "offline_access" in self.scope else f"expires in {humanfriendly.format_timespan(refresh_expires_in)}" ) - # TODO: switch to trace level logging for token details - log.info(f"Refresh token {info}") + log.debug(f"Refresh token {info}") if refresh_expires_in > 0: expires_in.append(refresh_expires_in) self.token_expires_in = min(expires_in) if expires_in else None if self.token_expires_in is not None: - # TODO: switch to trace level logging for token details - log.info( + log.debug( f"Setting token expiry to {humanfriendly.format_timespan(self.token_expires_in)}" ) self.token_acquired_date = arrow.now() if self.token_expires_in is not None else None @@ -401,8 +398,7 @@ def _update_token_expiry(self, tokens): if self.token_expires_in is not None: offset = max(1, int(self.token_expires_in * self.token_refresh_factor)) self.token_refresh_date = self.token_acquired_date.shift(seconds=offset) - # TODO: switch to trace level logging for token details - log.info( + log.debug( "Refresh token set, auto refresh in " f"{humanfriendly.format_timespan(offset)} ({self.token_refresh_date})" ) @@ -425,9 +421,6 @@ def _periodically_refresh_token(self): diff = self.token_refresh_date - now sleep_time = max(0.1, min(self.loop_interval, diff.total_seconds() * 0.25)) - if sleep_time >= 1.0: - # TODO: switch to trace level logging for token details - log.info(f"Token refresh in {humanfriendly.format_timespan(diff)}") if self._stop_event.wait(sleep_time): break log.debug("Token refresh thread stopped") diff --git a/tests/test_client.py b/tests/test_client.py index a888b6106..bc6940e9a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -21,8 +21,10 @@ # SOFTWARE.get_projects import logging +import threading import time +import arrow import pytest import requests @@ -125,3 +127,52 @@ def test_dt_client(url, username, password): assert client.data_transfer_client == client._dt_client assert client.data_transfer_api == client._dt_api + + +def test_update_token_expiry_sets_refresh_date(url, username, password): + """After authentication, expiry-related fields must be populated.""" + client = Client(url, username, password) + + assert client.token_expires_in is not None + assert client.token_acquired_date is not None + assert client.token_refresh_date is not None + # refresh date should be in the future and within token lifetime + assert client.token_refresh_date > client.token_acquired_date + diff = (client.token_refresh_date - client.token_acquired_date).total_seconds() + assert 0 < diff <= client.token_expires_in + + +def test_update_token_expiry_updates_after_refresh(url, username, password): + """Calling refresh_access_token must move token_refresh_date forward.""" + client = Client(url, username, password) + first_refresh_date = client.token_refresh_date + + time.sleep(0.5) + client.refresh_access_token() + + assert client.token_refresh_date > first_refresh_date + + +def test_periodically_refresh_token_refreshes_preemptively(url, username, password): + """The background thread must refresh the access token before it expires.""" + client = Client(url, username, password) + initial_access_token = client.access_token + + # The background thread is already sleeping with the default loop_interval + # against a fresh token_refresh_date. Stop it, retune the schedule so a + # refresh is due immediately, then restart so the new values take effect. + client._stop_event.set() + client._token_refresh_thread.join(timeout=5) + client._stop_event = threading.Event() + client._token_refresh_thread = None + client.loop_interval = 0.1 + client.token_refresh_date = arrow.now().shift(seconds=-1) + client._start_token_refresh_thread() + + # Wait for the background thread to perform the refresh + deadline = time.time() + 5 + while time.time() < deadline and client.access_token == initial_access_token: + time.sleep(0.1) + + assert client.access_token != initial_access_token + assert client.token_refresh_date > arrow.now() From 163f2012b78634c6419732c925702a7a8af5536a Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Wed, 3 Jun 2026 15:37:56 +0100 Subject: [PATCH 07/12] Make auto refresh token configurable --- src/ansys/hps/client/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index 89f454560..3a6b2af73 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -135,6 +135,7 @@ def __init__( all_fields=True, verify: bool | str = None, disable_security_warnings: bool = True, + auto_refresh_token: bool = True, **kwargs, ): """Initialize the Client object.""" @@ -167,7 +168,7 @@ def __init__( self.data_transfer_url = url + "/dt/api/v1" self._token_refresh_thread = None self._stop_event = threading.Event() - # Set token_refresh_factor to 95%? + # Set token_refresh_factor to 95% self.token_refresh_factor = 0.95 self.loop_interval = 60 # Check every 60 seconds @@ -254,7 +255,7 @@ def __init__( self.session.hooks["response"] = [self._auto_refresh_token, raise_for_status] self._unauthorized_num_retry = 0 self._unauthorized_max_retry = 1 - if self.token_refresh_date is not None: + if auto_refresh_token and self.token_refresh_date is not None: self._start_token_refresh_thread() def exit_handler(): From 088394665dab57b571dc050b8a62717cc4d8bbd4 Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Wed, 3 Jun 2026 15:54:22 +0100 Subject: [PATCH 08/12] Add dependencies --- pyproject.toml | 2 ++ src/ansys/hps/client/client.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a3302a447..e914e7229 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ dependencies = [ "backoff>=2.0.0", "pydantic>=1.10.0", "PyJWT>=2.8.0", + "arrow>=1.2.0", + "humanfriendly>=10.0", "packaging", "ansys-hps-data-transfer-client@git+https://github.com/ansys/pyhps-data-transfer.git" ] diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index 3a6b2af73..6363960ee 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -97,6 +97,8 @@ class Client: disable_security_warnings : bool, optional Whether to disable urllib3 warnings about insecure HTTPS requests. The default is ``True``. For more information, see urllib3 documentation about TLS warnings. + auto_refresh_token : bool, optional + Whether to automatically refresh access token before it expires. The default is ``True``. Examples -------- From 713743c7eed451bfcb6316a680c0b44943a8aaf1 Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Wed, 3 Jun 2026 16:17:55 +0100 Subject: [PATCH 09/12] skip tests --- src/ansys/hps/client/client.py | 3 +++ tests/test_client.py | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index 6363960ee..bf12f0e77 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -173,6 +173,9 @@ def __init__( # Set token_refresh_factor to 95% self.token_refresh_factor = 0.95 self.loop_interval = 60 # Check every 60 seconds + self.token_expires_in = None + self.token_acquired_date = None + self.token_refresh_date = None self._dt_client: DataTransferClient | None = None self._dt_api: DataTransferApi | None = None diff --git a/tests/test_client.py b/tests/test_client.py index bc6940e9a..350d0bc0f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -129,8 +129,10 @@ def test_dt_client(url, username, password): assert client.data_transfer_api == client._dt_api -def test_update_token_expiry_sets_refresh_date(url, username, password): +def test_update_token_expiry_sets_refresh_date(url, username, password, has_hps_version_gt_1_4_10): """After authentication, expiry-related fields must be populated.""" + if not has_hps_version_gt_1_4_10: + pytest.skip("Preemptive token refresh requires HPS > 1.4.10.") client = Client(url, username, password) assert client.token_expires_in is not None @@ -142,8 +144,12 @@ def test_update_token_expiry_sets_refresh_date(url, username, password): assert 0 < diff <= client.token_expires_in -def test_update_token_expiry_updates_after_refresh(url, username, password): +def test_update_token_expiry_updates_after_refresh( + url, username, password, has_hps_version_gt_1_4_10 +): """Calling refresh_access_token must move token_refresh_date forward.""" + if not has_hps_version_gt_1_4_10: + pytest.skip("Preemptive token refresh requires HPS > 1.4.10.") client = Client(url, username, password) first_refresh_date = client.token_refresh_date @@ -153,8 +159,12 @@ def test_update_token_expiry_updates_after_refresh(url, username, password): assert client.token_refresh_date > first_refresh_date -def test_periodically_refresh_token_refreshes_preemptively(url, username, password): +def test_periodically_refresh_token_refreshes_preemptively( + url, username, password, has_hps_version_gt_1_4_10 +): """The background thread must refresh the access token before it expires.""" + if not has_hps_version_gt_1_4_10: + pytest.skip("Preemptive token refresh requires HPS > 1.4.10.") client = Client(url, username, password) initial_access_token = client.access_token From bc9f214c705ea547f2278ec1caf127f1633d681a Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Thu, 4 Jun 2026 15:47:50 +0100 Subject: [PATCH 10/12] retry on failure --- src/ansys/hps/client/client.py | 68 +++++++++++++++++++++++++++++++--- tests/test_client.py | 30 +++++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index bf12f0e77..a1fc5f6fe 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -99,6 +99,16 @@ class Client: For more information, see urllib3 documentation about TLS warnings. auto_refresh_token : bool, optional Whether to automatically refresh access token before it expires. The default is ``True``. + token_refresh_factor : float, optional + Fraction of the token lifetime at which the first preemptive refresh is + scheduled. Must be in the open interval ``(0, 1)``. The default is ``0.70``. + token_refresh_retry_factors : sequence of float, optional + Strictly increasing fractions in ``(token_refresh_factor, 1)`` used to + reschedule the refresh after a failed attempt. The default is + ``(0.80, 0.90, 0.95, 0.98)``. + token_refresh_loop_interval : float, optional + Maximum interval, in seconds, between checks of the background token refresh + loop. The default is ``300``. Examples -------- @@ -138,6 +148,9 @@ def __init__( verify: bool | str = None, disable_security_warnings: bool = True, auto_refresh_token: bool = True, + token_refresh_factor: float = 0.70, + token_refresh_retry_factors: tuple[float, ...] = (0.80, 0.90, 0.95, 0.98), + token_refresh_loop_interval: float = 300, **kwargs, ): """Initialize the Client object.""" @@ -170,9 +183,23 @@ def __init__( self.data_transfer_url = url + "/dt/api/v1" self._token_refresh_thread = None self._stop_event = threading.Event() - # Set token_refresh_factor to 95% - self.token_refresh_factor = 0.95 - self.loop_interval = 60 # Check every 60 seconds + if not 0 < token_refresh_factor < 1: + raise ValueError("token_refresh_factor must be in the open interval (0, 1).") + if token_refresh_loop_interval <= 0: + raise ValueError("token_refresh_loop_interval must be positive.") + retry_factors = tuple(token_refresh_retry_factors) + prev = token_refresh_factor + for f in retry_factors: + if not prev < f < 1: + raise ValueError( + "token_refresh_retry_factors must be strictly increasing and " + "in (token_refresh_factor, 1)." + ) + prev = f + self.token_refresh_factor = token_refresh_factor + self.token_refresh_retry_factors = retry_factors + self.loop_interval = token_refresh_loop_interval + self._refresh_attempt = 0 self.token_expires_in = None self.token_acquired_date = None self.token_refresh_date = None @@ -401,6 +428,7 @@ def _update_token_expiry(self, tokens): ) self.token_acquired_date = arrow.now() if self.token_expires_in is not None else None + self._refresh_attempt = 0 if self.token_expires_in is not None: offset = max(1, int(self.token_expires_in * self.token_refresh_factor)) self.token_refresh_date = self.token_acquired_date.shift(seconds=offset) @@ -422,15 +450,45 @@ def _periodically_refresh_token(self): now = arrow.now() if now > self.token_refresh_date: log.debug("Attempting preemptive authentication token refresh") - self.refresh_access_token() + try: + self.refresh_access_token() + except Exception as ex: + self._reschedule_after_failed_refresh(ex) continue diff = self.token_refresh_date - now - sleep_time = max(0.1, min(self.loop_interval, diff.total_seconds() * 0.25)) + sleep_time = max(0.1, min(self.loop_interval, diff.total_seconds())) if self._stop_event.wait(sleep_time): break log.debug("Token refresh thread stopped") + def _reschedule_after_failed_refresh(self, ex): + """Schedule the next refresh attempt after a failure, if any retries remain.""" + self._refresh_attempt += 1 + if ( + self._refresh_attempt > len(self.token_refresh_retry_factors) + or self.token_acquired_date is None + or self.token_expires_in is None + ): + log.error( + "Preemptive token refresh failed and no retries remain: %s. " + "Falling back to on-demand refresh on the next 401 response.", + ex, + ) + self.token_refresh_date = None + return + + factor = self.token_refresh_retry_factors[self._refresh_attempt - 1] + offset = max(1, int(self.token_expires_in * factor)) + self.token_refresh_date = self.token_acquired_date.shift(seconds=offset) + log.warning( + "Preemptive token refresh failed (%s); next attempt scheduled at %.0f%% " + "of token lifetime (%s).", + ex, + factor * 100, + self.token_refresh_date, + ) + def _auto_refresh_token(self, response, *args, **kwargs): """Provide a callback for refreshing an expired token. diff --git a/tests/test_client.py b/tests/test_client.py index 350d0bc0f..31f1cafd9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -159,6 +159,36 @@ def test_update_token_expiry_updates_after_refresh( assert client.token_refresh_date > first_refresh_date +def test_reschedule_after_failed_refresh(url, username, password, has_hps_version_gt_1_4_10): + """Failed refreshes must escalate through retry factors, then give up.""" + if not has_hps_version_gt_1_4_10: + pytest.skip("Preemptive token refresh requires HPS > 1.4.10.") + client = Client(url, username, password) + + # Stop the background thread so it doesn't race with our manipulations. + client._stop_event.set() + client._token_refresh_thread.join(timeout=5) + + acquired = client.token_acquired_date + expires_in = client.token_expires_in + retry_factors = client.token_refresh_retry_factors + assert len(retry_factors) > 0 + + err = RuntimeError("simulated refresh failure") + + # Each failure should reschedule at the next retry factor. + for i, factor in enumerate(retry_factors, start=1): + client._reschedule_after_failed_refresh(err) + assert client._refresh_attempt == i + expected_offset = max(1, int(expires_in * factor)) + expected_date = acquired.shift(seconds=expected_offset) + assert client.token_refresh_date == expected_date + + # One more failure exhausts the retries and disables preemptive refresh. + client._reschedule_after_failed_refresh(err) + assert client.token_refresh_date is None + + def test_periodically_refresh_token_refreshes_preemptively( url, username, password, has_hps_version_gt_1_4_10 ): From c36a4ce686bac458df915d881ba377a78776fe4d Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Mon, 8 Jun 2026 10:18:48 +0100 Subject: [PATCH 11/12] remove arrow --- src/ansys/hps/client/client.py | 23 +++++++++++------------ tests/test_client.py | 28 ++++++++-------------------- 2 files changed, 19 insertions(+), 32 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index a1fc5f6fe..8314df22c 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -29,9 +29,8 @@ import tempfile import threading import warnings +from datetime import datetime, timedelta, timezone -import arrow -import humanfriendly import jwt import requests from ansys.hps.data_transfer.client import Client as DataTransferClient @@ -409,32 +408,32 @@ def _update_token_expiry(self, tokens): expires_in = [] access_expires_in = tokens.get("expires_in", None) if access_expires_in is not None: - log.debug(f"Access token expires in {humanfriendly.format_timespan(access_expires_in)}") + log.debug(f"Access token expires in {timedelta(seconds=int(access_expires_in))}") expires_in.append(access_expires_in) refresh_expires_in = tokens.get("refresh_expires_in", None) if refresh_expires_in is not None: info = ( "offline" if refresh_expires_in == 0 and "offline_access" in self.scope - else f"expires in {humanfriendly.format_timespan(refresh_expires_in)}" + else f"expires in {timedelta(seconds=int(refresh_expires_in))}" ) log.debug(f"Refresh token {info}") if refresh_expires_in > 0: expires_in.append(refresh_expires_in) self.token_expires_in = min(expires_in) if expires_in else None if self.token_expires_in is not None: - log.debug( - f"Setting token expiry to {humanfriendly.format_timespan(self.token_expires_in)}" - ) - self.token_acquired_date = arrow.now() if self.token_expires_in is not None else None + log.debug(f"Setting token expiry to {timedelta(seconds=int(self.token_expires_in))}") + self.token_acquired_date = ( + datetime.now(timezone.utc) if self.token_expires_in is not None else None + ) self._refresh_attempt = 0 if self.token_expires_in is not None: offset = max(1, int(self.token_expires_in * self.token_refresh_factor)) - self.token_refresh_date = self.token_acquired_date.shift(seconds=offset) + self.token_refresh_date = self.token_acquired_date + timedelta(seconds=offset) log.debug( "Refresh token set, auto refresh in " - f"{humanfriendly.format_timespan(offset)} ({self.token_refresh_date})" + f"{timedelta(seconds=offset)} ({self.token_refresh_date})" ) else: self.token_refresh_date = None @@ -447,7 +446,7 @@ def _periodically_refresh_token(self): break continue - now = arrow.now() + now = datetime.now(timezone.utc) if now > self.token_refresh_date: log.debug("Attempting preemptive authentication token refresh") try: @@ -480,7 +479,7 @@ def _reschedule_after_failed_refresh(self, ex): factor = self.token_refresh_retry_factors[self._refresh_attempt - 1] offset = max(1, int(self.token_expires_in * factor)) - self.token_refresh_date = self.token_acquired_date.shift(seconds=offset) + self.token_refresh_date = self.token_acquired_date + timedelta(seconds=offset) log.warning( "Preemptive token refresh failed (%s); next attempt scheduled at %.0f%% " "of token lifetime (%s).", diff --git a/tests/test_client.py b/tests/test_client.py index 31f1cafd9..26229d683 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,8 +23,8 @@ import logging import threading import time +from datetime import datetime, timedelta, timezone -import arrow import pytest import requests @@ -129,10 +129,8 @@ def test_dt_client(url, username, password): assert client.data_transfer_api == client._dt_api -def test_update_token_expiry_sets_refresh_date(url, username, password, has_hps_version_gt_1_4_10): +def test_update_token_expiry_sets_refresh_date(url, username, password): """After authentication, expiry-related fields must be populated.""" - if not has_hps_version_gt_1_4_10: - pytest.skip("Preemptive token refresh requires HPS > 1.4.10.") client = Client(url, username, password) assert client.token_expires_in is not None @@ -144,12 +142,8 @@ def test_update_token_expiry_sets_refresh_date(url, username, password, has_hps_ assert 0 < diff <= client.token_expires_in -def test_update_token_expiry_updates_after_refresh( - url, username, password, has_hps_version_gt_1_4_10 -): +def test_update_token_expiry_updates_after_refresh(url, username, password): """Calling refresh_access_token must move token_refresh_date forward.""" - if not has_hps_version_gt_1_4_10: - pytest.skip("Preemptive token refresh requires HPS > 1.4.10.") client = Client(url, username, password) first_refresh_date = client.token_refresh_date @@ -159,10 +153,8 @@ def test_update_token_expiry_updates_after_refresh( assert client.token_refresh_date > first_refresh_date -def test_reschedule_after_failed_refresh(url, username, password, has_hps_version_gt_1_4_10): +def test_reschedule_after_failed_refresh(url, username, password): """Failed refreshes must escalate through retry factors, then give up.""" - if not has_hps_version_gt_1_4_10: - pytest.skip("Preemptive token refresh requires HPS > 1.4.10.") client = Client(url, username, password) # Stop the background thread so it doesn't race with our manipulations. @@ -181,7 +173,7 @@ def test_reschedule_after_failed_refresh(url, username, password, has_hps_versio client._reschedule_after_failed_refresh(err) assert client._refresh_attempt == i expected_offset = max(1, int(expires_in * factor)) - expected_date = acquired.shift(seconds=expected_offset) + expected_date = acquired + timedelta(seconds=expected_offset) assert client.token_refresh_date == expected_date # One more failure exhausts the retries and disables preemptive refresh. @@ -189,12 +181,8 @@ def test_reschedule_after_failed_refresh(url, username, password, has_hps_versio assert client.token_refresh_date is None -def test_periodically_refresh_token_refreshes_preemptively( - url, username, password, has_hps_version_gt_1_4_10 -): +def test_periodically_refresh_token_refreshes_preemptively(url, username, password): """The background thread must refresh the access token before it expires.""" - if not has_hps_version_gt_1_4_10: - pytest.skip("Preemptive token refresh requires HPS > 1.4.10.") client = Client(url, username, password) initial_access_token = client.access_token @@ -206,7 +194,7 @@ def test_periodically_refresh_token_refreshes_preemptively( client._stop_event = threading.Event() client._token_refresh_thread = None client.loop_interval = 0.1 - client.token_refresh_date = arrow.now().shift(seconds=-1) + client.token_refresh_date = datetime.now(timezone.utc) - timedelta(seconds=1) client._start_token_refresh_thread() # Wait for the background thread to perform the refresh @@ -215,4 +203,4 @@ def test_periodically_refresh_token_refreshes_preemptively( time.sleep(0.1) assert client.access_token != initial_access_token - assert client.token_refresh_date > arrow.now() + assert client.token_refresh_date > datetime.now(timezone.utc) From 810c82fb54acf58af0a2d2fce47fb2284c9117d1 Mon Sep 17 00:00:00 2001 From: Manikanth Guntupally Venkata Date: Mon, 8 Jun 2026 10:21:46 +0100 Subject: [PATCH 12/12] remove arrow --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e914e7229..a3302a447 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,6 @@ dependencies = [ "backoff>=2.0.0", "pydantic>=1.10.0", "PyJWT>=2.8.0", - "arrow>=1.2.0", - "humanfriendly>=10.0", "packaging", "ansys-hps-data-transfer-client@git+https://github.com/ansys/pyhps-data-transfer.git" ]