Skip to content

Commit b382f77

Browse files
committed
🥅 Reauthenticate when websocket token expires
1 parent 59aaf39 commit b382f77

8 files changed

Lines changed: 189 additions & 115 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## 1.10.1
9+
10+
### Fixed
11+
12+
- Websocket now gets a new token when the one it's trying expires.
13+
814
## 1.10.0
915

1016
### Added

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.10.0
1+
1.10.1

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hbnmigration",
3-
"version": "1.10.0",
3+
"version": "1.10.1",
44
"private": true,
55
"description": "HBN data migration monitoring infrastructure with Python and Node.js services",
66
"workspaces": [

python_jobs/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.10.0
1+
1.10.1

python_jobs/src/hbnmigration/from_curious/alerts_to_redcap.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ async def websocket_listener(
388388

389389

390390
async def main_with_reconnect(
391-
tokens: curious_variables.Tokens,
391+
applet_name: str,
392392
uri: str,
393393
partial_redcap_landing: bool = False,
394394
max_attempts: Optional[int] = WS_MAX_RECONNECT_ATTEMPTS,
@@ -398,8 +398,8 @@ async def main_with_reconnect(
398398
399399
Parameters
400400
----------
401-
tokens
402-
Authentication tokens for WebSocket connection
401+
applet_name
402+
Name of applet to authenticate to
403403
uri
404404
WebSocket URI to connect to
405405
partial_redcap_landing
@@ -409,6 +409,8 @@ async def main_with_reconnect(
409409
410410
"""
411411
attempt = 0
412+
tokens = curious_authenticate(applet_name)
413+
412414
while max_attempts is None or attempt < max_attempts:
413415
try:
414416
if attempt > 0:
@@ -418,36 +420,43 @@ async def main_with_reconnect(
418420
f" of {max_attempts}" if max_attempts else "",
419421
)
420422
async with connect_to_websocket(tokens.access, uri) as websocket:
421-
# Reset attempt counter on successful connection
422423
if attempt > 0:
423424
logger.info("Successfully reconnected to WebSocket")
424425
attempt = 0
425426
await websocket_listener(websocket, partial_redcap_landing)
426427
logger.info("WebSocket listener completed normally")
427428
break
429+
428430
except ConnectionClosedError:
429431
attempt += 1
430-
if max_attempts and attempt >= max_attempts:
432+
if max_attempts is not None and attempt >= max_attempts:
431433
logger.exception("Max reconnection attempts reached. Exiting.")
432434
raise
433435
logger.warning(
434436
"Connection lost. Reconnecting in %d seconds...", WS_RECONNECT_DELAY
435437
)
436438
await asyncio.sleep(WS_RECONNECT_DELAY)
439+
437440
except InvalidStatus as e:
438-
# Authentication or server errors
439-
logger.exception(
440-
"WebSocket connection failed with status %s", e.response.status_code
441-
)
442-
if e.response.status_code == requests.codes["unauthorized"]:
443-
logger.exception(
444-
"Authentication failed. Token may be invalid or expired."
445-
)
441+
status = e.response.status_code
442+
logger.exception("WebSocket connection failed with status %s", status)
443+
444+
if status == requests.codes["unauthorized"]:
445+
# ── Re-authenticate ──
446+
logger.warning("Token expired or invalid. Re-authenticating...")
447+
try:
448+
tokens = curious_authenticate(applet_name)
449+
logger.info("Re-authentication successful")
450+
except Exception:
451+
logger.exception("Re-authentication failed. Exiting.")
452+
raise
453+
446454
attempt += 1
447-
if max_attempts and attempt >= max_attempts:
455+
if max_attempts is not None and attempt >= max_attempts:
448456
logger.exception("Max reconnection attempts reached. Exiting.")
449457
raise
450458
await asyncio.sleep(WS_RECONNECT_DELAY)
459+
451460
except asyncio.CancelledError:
452461
logger.info("Operation cancelled")
453462
raise
@@ -485,10 +494,9 @@ async def main(
485494
486495
"""
487496
for applet_name in applet_names:
488-
tokens = curious_authenticate(applet_name)
489497
endpoints = curious_variables.Endpoints(protocol="wss")
490498
await main_with_reconnect(
491-
tokens=tokens,
499+
applet_name=applet_name,
492500
uri=endpoints.alerts,
493501
partial_redcap_landing=partial_redcap_landing,
494502
max_attempts=max_attempts,

python_jobs/src/tests/conftest.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import requests
1616
from websockets.exceptions import InvalidStatus
1717

18-
from hbnmigration._config_variables.curious_variables import Tokens
1918
from hbnmigration.from_curious.alerts_to_redcap import (
2019
parse_alert,
2120
synchronous_main,
@@ -698,16 +697,6 @@ def assert_pushed_data_contains_value(
698697
assert expected_value in pushed_data["value"].values
699698

700699

701-
def assert_reconnect_called_with_tokens_and_uri(
702-
mock_reconnect: Mock, tokens: Tokens, uri_fragment: str
703-
) -> None:
704-
"""Assert reconnect was called with expected token and URI."""
705-
assert mock_reconnect.called
706-
call_kwargs = mock_reconnect.call_args[1]
707-
assert call_kwargs["tokens"] == tokens
708-
assert uri_fragment in call_kwargs["uri"]
709-
710-
711700
def assert_reconnect_called_with_max_attempts(
712701
mock_reconnect: Mock, expected_attempts: int
713702
) -> None:
@@ -851,9 +840,13 @@ def setup_reconnect_mocks(
851840
new_callable=AsyncMock,
852841
)
853842
),
843+
"auth": stack.enter_context(
844+
patch("hbnmigration.from_curious.alerts_to_redcap.curious_authenticate")
845+
),
854846
}
855847
mock_websocket = AsyncMock()
856848
mocks["ws"].return_value.__aenter__.return_value = mock_websocket
849+
mocks["auth"].return_value = create_mock_tokens_ws()
857850
if listener_side_effect is not None:
858851
mocks["listener"].side_effect = listener_side_effect
859852
yield mocks

python_jobs/src/tests/test_alerts_curious_to_redcap.py

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import pandas as pd
88
import pytest
9+
import requests
910
from websockets.exceptions import ConnectionClosedError, InvalidStatus
1011

1112
from hbnmigration.from_curious.alerts_to_redcap import (
@@ -345,6 +346,72 @@ async def test_websocket_listener_skips_non_answer_messages(mock_alerts_dependen
345346
assert not mock_alerts_dependencies["push"].called
346347

347348

349+
@pytest.mark.asyncio
350+
async def test_main_with_reconnect_reauths_on_401_then_succeeds(caplog):
351+
"""Test that main_with_reconnect re-authenticates on 401 and succeeds."""
352+
with (
353+
patch(
354+
"hbnmigration.from_curious.alerts_to_redcap.connect_to_websocket"
355+
) as mock_ws,
356+
patch(
357+
"hbnmigration.from_curious.alerts_to_redcap.websocket_listener",
358+
new_callable=AsyncMock,
359+
) as mock_listener,
360+
patch(
361+
"hbnmigration.from_curious.alerts_to_redcap.asyncio.sleep",
362+
new_callable=AsyncMock,
363+
),
364+
patch(
365+
"hbnmigration.from_curious.alerts_to_redcap.curious_authenticate"
366+
) as mock_auth,
367+
):
368+
mock_auth.return_value = create_mock_tokens_ws()
369+
# First connect raises 401, second succeeds
370+
mock_ctx = AsyncMock()
371+
mock_ctx.__aenter__.return_value = AsyncMock()
372+
mock_ws.side_effect = [
373+
create_mock_invalid_status(requests.codes["unauthorized"]),
374+
mock_ctx,
375+
]
376+
377+
await main_with_reconnect(
378+
applet_name="test_applet", uri="wss://test.com/alerts", max_attempts=2
379+
)
380+
# Initial auth + re-auth on 401
381+
assert mock_auth.call_count == 2
382+
assert mock_listener.called
383+
assert "Re-authentication successful" in caplog.text
384+
385+
386+
@pytest.mark.asyncio
387+
async def test_main_with_reconnect_reauth_failure_raises(caplog):
388+
"""Test that main_with_reconnect raises when re-authentication itself fails."""
389+
with (
390+
patch(
391+
"hbnmigration.from_curious.alerts_to_redcap.connect_to_websocket"
392+
) as mock_ws,
393+
patch(
394+
"hbnmigration.from_curious.alerts_to_redcap.asyncio.sleep",
395+
new_callable=AsyncMock,
396+
),
397+
patch(
398+
"hbnmigration.from_curious.alerts_to_redcap.curious_authenticate"
399+
) as mock_auth,
400+
):
401+
# Initial auth succeeds, re-auth on 401 fails
402+
mock_auth.side_effect = [
403+
create_mock_tokens_ws(),
404+
Exception("Auth server down"),
405+
]
406+
mock_ws.side_effect = create_mock_invalid_status(requests.codes["unauthorized"])
407+
408+
with pytest.raises(Exception, match="Auth server down"):
409+
await main_with_reconnect(
410+
applet_name="test_applet", uri="wss://test.com/alerts", max_attempts=2
411+
)
412+
assert "Re-authentication failed" in caplog.text
413+
414+
348415
# ============================================================================
349416
# Tests - main_with_reconnect
350417
# ============================================================================
@@ -357,9 +424,8 @@ async def test_main_with_reconnect_successful_connection(
357424
"""Test that main_with_reconnect handles successful connection."""
358425
setup_standard_alert_mocks(mock_alerts_dependencies, redcap_alerts_metadata)
359426
with setup_reconnect_mocks() as mocks:
360-
tokens = create_mock_tokens_ws()
361427
await main_with_reconnect(
362-
tokens=tokens, uri="wss://test.com/alerts", max_attempts=1
428+
applet_name="test_applet", uri="wss://test.com/alerts", max_attempts=1
363429
)
364430
assert mocks["ws"].called
365431
assert mocks["listener"].called
@@ -369,9 +435,8 @@ async def test_main_with_reconnect_successful_connection(
369435
async def test_main_with_reconnect_handles_connection_error(caplog):
370436
"""Test that main_with_reconnect reconnects on ConnectionClosedError."""
371437
with setup_reconnect_mocks([ConnectionClosedError(None, None), None]) as mocks:
372-
tokens = create_mock_tokens_ws()
373438
await main_with_reconnect(
374-
tokens=tokens, uri="wss://test.com/alerts", max_attempts=2
439+
applet_name="test_applet", uri="wss://test.com/alerts", max_attempts=2
375440
)
376441
assert mocks["listener"].call_count == 2
377442
assert "Reconnecting in" in caplog.text
@@ -382,10 +447,9 @@ async def test_main_with_reconnect_handles_connection_error(caplog):
382447
async def test_main_with_reconnect_max_attempts_exceeded():
383448
"""Test that main_with_reconnect stops after max attempts."""
384449
with setup_reconnect_mocks(ConnectionClosedError(None, None)) as mocks:
385-
tokens = create_mock_tokens_ws()
386450
with pytest.raises(ConnectionClosedError):
387451
await main_with_reconnect(
388-
tokens=tokens, uri="wss://test.com/alerts", max_attempts=1
452+
applet_name="test_applet", uri="wss://test.com/alerts", max_attempts=1
389453
)
390454
assert mocks["listener"].call_count == 1
391455

@@ -398,25 +462,34 @@ async def test_main_with_reconnect_handles_auth_error(caplog):
398462
"hbnmigration.from_curious.alerts_to_redcap.connect_to_websocket"
399463
) as mock_ws,
400464
patch("hbnmigration.from_curious.alerts_to_redcap.asyncio.sleep") as mock_sleep,
465+
patch(
466+
"hbnmigration.from_curious.alerts_to_redcap.curious_authenticate"
467+
) as mock_auth,
401468
):
402-
mock_ws.side_effect = create_mock_invalid_status(401)
469+
mock_ws.side_effect = create_mock_invalid_status(requests.codes["unauthorized"])
403470
mock_sleep.return_value = None
404-
tokens = create_mock_tokens_ws()
471+
# First call is the initial auth; second call is the re-auth on 401
472+
mock_auth.side_effect = [
473+
create_mock_tokens_ws(),
474+
create_mock_tokens_ws(),
475+
]
405476
with pytest.raises(InvalidStatus):
406477
await main_with_reconnect(
407-
tokens=tokens, uri="wss://test.com/alerts", max_attempts=1
478+
applet_name="test_applet", uri="wss://test.com/alerts", max_attempts=1
408479
)
409-
assert "Authentication failed" in caplog.text
480+
assert (
481+
"Token expired or invalid" in caplog.text
482+
or "Authentication failed" in caplog.text
483+
)
410484

411485

412486
@pytest.mark.asyncio
413487
async def test_main_with_reconnect_infinite_retries():
414488
"""Test that main_with_reconnect runs indefinitely with None max_attempts."""
415489
side_effects = [ConnectionClosedError(None, None)] * 5 + [None]
416490
with setup_reconnect_mocks(side_effects) as mocks:
417-
tokens = create_mock_tokens_ws()
418491
await main_with_reconnect(
419-
tokens=tokens, uri="wss://test.com/alerts", max_attempts=None
492+
applet_name="test_applet", uri="wss://test.com/alerts", max_attempts=None
420493
)
421494
assert mocks["listener"].call_count == 6
422495
assert mocks["sleep"].call_count == 5
@@ -448,22 +521,16 @@ async def test_main_processes_websocket_messages(
448521
await main(applet_names=["Healthy Brain Network Questionnaires"])
449522
assert mock_reconnect.called
450523
call_kwargs = mock_reconnect.call_args[1]
451-
assert "tokens" in call_kwargs
524+
assert "applet_name" in call_kwargs
452525
assert "wss://" in call_kwargs["uri"]
453526

454527

455528
@pytest.mark.asyncio
456529
async def test_main_passes_max_attempts():
457530
"""Test that main() passes max_attempts parameter."""
458-
with (
459-
patch(
460-
"hbnmigration.from_curious.alerts_to_redcap.curious_authenticate"
461-
) as mock_auth,
462-
patch(
463-
"hbnmigration.from_curious.alerts_to_redcap.main_with_reconnect"
464-
) as mock_reconnect,
465-
):
466-
mock_auth.return_value = create_mock_tokens_ws()
531+
with patch(
532+
"hbnmigration.from_curious.alerts_to_redcap.main_with_reconnect"
533+
) as mock_reconnect:
467534
mock_reconnect.return_value = None
468535
await main(
469536
applet_names=["Healthy Brain Network Questionnaires"],

0 commit comments

Comments
 (0)