Skip to content

Commit d10022b

Browse files
philipjusherUsherCopilotpyansys-ci-bot
authored
Fix/token timeout 2 (#325)
Co-authored-by: Usher <usher@ansys.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com>
1 parent 06fecc4 commit d10022b

File tree

6 files changed

+119
-3
lines changed

6 files changed

+119
-3
lines changed

doc/changelog.d/325.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix/token timeout 2

src/ansys/conceptev/core/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def get_http_client(
136136
client.send = retry(
137137
retry=retry_if_result(is_gateway_error),
138138
wait=wait_random_exponential(multiplier=1, max=60),
139-
stop=stop_after_delay(10),
139+
stop=stop_after_delay(120),
140140
)(client.send)
141141
return client
142142

src/ansys/conceptev/core/auth.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,12 @@ def auth_flow(self, request):
100100
"""Send the request, with a custom `Authentication` header."""
101101
token = get_ansyId_token(self.app)
102102
request.headers["Authorization"] = token
103-
yield request
103+
response = yield request
104+
if response.status_code == 401:
105+
logger.info("Token expired or rejected (401). Refreshing token and retrying.")
106+
token = get_ansyId_token(self.app, force=True)
107+
request.headers["Authorization"] = token
108+
yield request
104109

105110

106111
def get_token(client: httpx.Client) -> str:

src/ansys/conceptev/core/progress.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from msal import PublicClientApplication
3232
from websockets.asyncio.client import connect
3333

34+
from ansys.conceptev.core.auth import get_ansyId_token
3435
from ansys.conceptev.core.settings import settings
3536

3637
if sys.version_info >= (3, 11):
@@ -163,7 +164,7 @@ def monitor_job_progress(
163164
if __name__ == "__main__":
164165
"""Monitor a single job progress."""
165166
from ansys.conceptev.core.app import get_user_id
166-
from ansys.conceptev.core.auth import create_msal_app, get_ansyId_token
167+
from ansys.conceptev.core.auth import create_msal_app
167168

168169
job_id = "ae3f3b4b-91d8-4cdd-8fa3-25eb202a561e" # Replace with your job ID
169170
msal_app = create_msal_app()

tests/test_auth.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,71 @@ def test_auth_flow_adds_authorization_header(mocker, httpx_mock: HTTPXMock):
123123
client = httpx.Client(auth=auth_instance)
124124
response = client.get("http://example.com")
125125
assert response.request.headers["Authorization"] == "auth_class_token"
126+
127+
128+
def test_auth_flow_retries_on_401_with_fresh_token(mocker, httpx_mock: HTTPXMock):
129+
"""When the server returns 401, auth_flow should force-refresh the token and retry."""
130+
mock_get_ansyId_token = mocker.patch(
131+
"ansys.conceptev.core.auth.get_ansyId_token",
132+
side_effect=["expired_token", "fresh_token"],
133+
)
134+
auth_instance = auth.AnsysIDAuth()
135+
httpx_mock.add_response(url="http://example.com", status_code=401)
136+
httpx_mock.add_response(url="http://example.com", status_code=200)
137+
138+
client = httpx.Client(auth=auth_instance)
139+
response = client.get("http://example.com")
140+
141+
assert response.status_code == 200
142+
assert mock_get_ansyId_token.call_count == 2
143+
# Second call must use force=True to bypass the MSAL cache
144+
assert mock_get_ansyId_token.call_args_list[1] == mocker.call(auth_instance.app, force=True)
145+
assert response.request.headers["Authorization"] == "fresh_token"
146+
147+
148+
def test_auth_flow_does_not_retry_on_other_errors(mocker, httpx_mock: HTTPXMock):
149+
"""Non-401 errors should not trigger a token refresh retry."""
150+
mock_get_ansyId_token = mocker.patch(
151+
"ansys.conceptev.core.auth.get_ansyId_token", return_value="auth_class_token"
152+
)
153+
auth_instance = auth.AnsysIDAuth()
154+
httpx_mock.add_response(url="http://example.com", status_code=403)
155+
156+
client = httpx.Client(auth=auth_instance)
157+
response = client.get("http://example.com")
158+
159+
assert response.status_code == 403
160+
assert mock_get_ansyId_token.call_count == 1
161+
162+
163+
def test_auth_flow_token_expires_mid_sequence(mocker, httpx_mock: HTTPXMock):
164+
"""When a token expires mid-sequence, the failing request retries with a fresh token
165+
and all subsequent requests in the sequence also use the fresh token."""
166+
# Requests 1 & 2 succeed with the original token.
167+
# Request 3 gets a 401 (token just expired), then retries with a fresh token.
168+
# Request 4 should use the now-cached fresh token.
169+
token_sequence = [
170+
"original_token", # req 1 – initial fetch
171+
"original_token", # req 2 – initial fetch
172+
"original_token", # req 3 – initial fetch (will be rejected)
173+
"fresh_token", # req 3 – force-refresh after 401
174+
"fresh_token", # req 4 – silent fetch (MSAL cache now holds fresh token)
175+
]
176+
mock_get_ansyId_token = mocker.patch(
177+
"ansys.conceptev.core.auth.get_ansyId_token", side_effect=token_sequence
178+
)
179+
auth_instance = auth.AnsysIDAuth()
180+
181+
httpx_mock.add_response(url="http://example.com", status_code=200) # req 1
182+
httpx_mock.add_response(url="http://example.com", status_code=200) # req 2
183+
httpx_mock.add_response(url="http://example.com", status_code=401) # req 3 – token expired
184+
httpx_mock.add_response(url="http://example.com", status_code=200) # req 3 – retry
185+
httpx_mock.add_response(url="http://example.com", status_code=200) # req 4
186+
187+
client = httpx.Client(auth=auth_instance)
188+
responses = [client.get("http://example.com") for _ in range(4)]
189+
190+
assert [r.status_code for r in responses] == [200, 200, 200, 200]
191+
assert mock_get_ansyId_token.call_count == 5
192+
# The force-refresh must have been triggered on the 4th call (req 3 retry)
193+
assert mock_get_ansyId_token.call_args_list[3] == mocker.call(auth_instance.app, force=True)

tests/test_progress.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,44 @@ def test_ssl_cert_custom():
150150
ssl_context = generate_ssl_context()
151151
assert ssl_context is not None
152152
assert ssl_context.verify_mode == ssl.CERT_REQUIRED
153+
154+
155+
@pytest.mark.asyncio
156+
async def test_token_refreshed_on_websocket_reconnect():
157+
"""When a long-running job causes a WebSocket disconnection, a fresh token should
158+
be fetched and used when reconnecting — simulating a mid-job token expiry."""
159+
job_id = "test_job"
160+
user_id = "test_user"
161+
initial_token = "initial_token"
162+
refreshed_token = "refreshed_token"
163+
app = PublicClientApplication("123")
164+
165+
progress_message = json.dumps({"jobId": job_id, "messagetype": "progress", "progress": 50})
166+
complete_message = json.dumps(
167+
{"jobId": job_id, "messagetype": "status", "status": STATUS_COMPLETE}
168+
)
169+
170+
# First connection: delivers a progress message then disconnects (simulates token expiry).
171+
# Second connection: delivers the completion message.
172+
connection_calls = []
173+
174+
def fake_connect_to_ocm(uid, token):
175+
connection_calls.append(token)
176+
if len(connection_calls) == 1:
177+
return AsyncContextManager([progress_message])
178+
return AsyncContextManager([complete_message])
179+
180+
with patch(
181+
"ansys.conceptev.core.progress.connect_to_ocm", side_effect=fake_connect_to_ocm
182+
), patch(
183+
"ansys.conceptev.core.progress.get_ansyId_token", return_value=refreshed_token
184+
) as mock_refresh:
185+
result = await monitor_job_messages(job_id, user_id, initial_token, app)
186+
187+
assert result == STATUS_COMPLETE
188+
# First connection used the original token passed in
189+
assert connection_calls[0] == initial_token
190+
# Second connection used the refreshed token
191+
assert connection_calls[1] == refreshed_token
192+
# get_ansyId_token was called once to refresh after the first WebSocket disconnected
193+
mock_refresh.assert_called_once_with(app)

0 commit comments

Comments
 (0)