From 6dbde790643e7879ca70ace317f49f4130cdd195 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Mon, 26 Oct 2026 15:36:30 -0400 Subject: [PATCH 01/13] implement network node discovery --- src/anyvlm/auth/token_provider.py | 9 ++-- src/anyvlm/config.py | 1 + src/anyvlm/outbound/client.py | 9 ++-- src/anyvlm/outbound/node_registry.py | 69 ++++++++++++++++++++++++++++ src/anyvlm/utils/exceptions.py | 4 ++ 5 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 src/anyvlm/outbound/node_registry.py diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index c684937..5110077 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -7,20 +7,21 @@ import requests from requests.models import Response +from anyvlm.config import Settings, get_config from anyvlm.utils.exceptions import TokenRetrievalError class TokenProvider: """Handles retrieving/caching valid bearer tokens""" - BASE_VLM_AUTH_URL: str = "https://vlm-auth.us.auth0.com" - AUDIENCE_URL: str = BASE_VLM_AUTH_URL + "/api/v2/" - TOKEN_REQUEST_URL: str = BASE_VLM_AUTH_URL + "/oauth/token" - _token: str = "" _token_expiry: datetime def __init__(self) -> None: # noqa: D107 + settings: Settings = get_config() + self.AUDIENCE_URL: str = settings.base_vlm_network_url + "/api/v2/" + self.TOKEN_REQUEST_URL: str = settings.base_vlm_network_url + "/oauth/token" + self.get_token() def get_token(self) -> str: diff --git a/src/anyvlm/config.py b/src/anyvlm/config.py index 3d1554d..6320f11 100644 --- a/src/anyvlm/config.py +++ b/src/anyvlm/config.py @@ -27,6 +27,7 @@ class Settings(BaseSettings): anyvar_uri: str | None = None storage_uri: str = "postgresql://postgres@localhost:5432/anyvlm" logging_config: FilePath | None = None + base_vlm_network_url: str = "https://vlm-auth.us.auth0.com" @cache diff --git a/src/anyvlm/outbound/client.py b/src/anyvlm/outbound/client.py index 016252d..31d1b8a 100644 --- a/src/anyvlm/outbound/client.py +++ b/src/anyvlm/outbound/client.py @@ -8,14 +8,17 @@ from anyvlm.auth.token_provider import TokenProvider -class Client: +class VlmClient: """Handles sending authenticated outbound requests to the VLM Network""" def __init__(self) -> None: # noqa: D107 self.token_provider: TokenProvider = TokenProvider() - async def request( - self, method_type: HTTPMethod, url: str, request_body: dict[Any, Any] | None + def request( + self, + method_type: HTTPMethod, + url: str, + request_body: dict[Any, Any] | None = None, ) -> Any: # noqa: ANN401 """Send an authenticated outbound request to the VLM Network""" return requests.request( diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py new file mode 100644 index 0000000..8e884f1 --- /dev/null +++ b/src/anyvlm/outbound/node_registry.py @@ -0,0 +1,69 @@ +"""Retrieves information about other nodes on the VLM Network""" + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from http import HTTPMethod, HTTPStatus + +from anyvlm.config import Settings, get_config +from anyvlm.outbound.client import VlmClient +from anyvlm.utils.exceptions import NodeRetrievalError + + +@dataclass +class VlmNode: + """Houses metadata about a VLM Network node""" + + client_id: str + client_name: str + match_url: str | None + + +class NodeRegistry: + """Manages the list of other known nodes on the VLM Network""" + + NODES_REFRESH_INTERVAL: timedelta = timedelta(hours=24) + + _nodes: list[VlmNode] + _nodes_expiry: datetime + _vlm_client: VlmClient + + def __init__(self) -> None: # noqa: D107 + self._vlm_client = VlmClient() + + settings: Settings = get_config() + self.NODES_REQUEST_URL: str = settings.base_vlm_network_url + "/api/v2/clients" + + self._refresh_nodes() + + def get_nodes(self) -> list[VlmNode]: + """Get a list of known nodes on the VLM network. Uses the cached list if cache is set & unexpired.""" + now: datetime = datetime.now(tz=UTC) + if (not self._nodes) or (now >= self._nodes_expiry): + self._refresh_nodes() + return self._nodes + + def _refresh_nodes(self) -> None: + """Retrieve an updated list of known nodes on the VLM Network + reset the expiry time for the next check""" + response = self._vlm_client.request( + method_type=HTTPMethod.GET, url=self.NODES_REQUEST_URL + ) + + if response.status_code == HTTPStatus.OK: + self._known_nodes = [] + else: + error_message: str = "Unable to fetch VLM Network nodes from Auth0" + raise NodeRetrievalError(error_message) + + data = response.json() + for entry in data: + self._known_nodes.append( + VlmNode( + client_id=entry.get("client_id", ""), + client_name=entry.get("name", ""), + match_url=entry.get("client_metadata", {}).get("match_url"), + ) + ) + + self._known_nodes_expiry: datetime = ( + datetime.now(tz=UTC) + self.NODES_REFRESH_INTERVAL + ) diff --git a/src/anyvlm/utils/exceptions.py b/src/anyvlm/utils/exceptions.py index a65365f..079fbc1 100644 --- a/src/anyvlm/utils/exceptions.py +++ b/src/anyvlm/utils/exceptions.py @@ -23,3 +23,7 @@ class VariantLookupError(Exception): class TokenRetrievalError(Exception): """Raised when a token cannot be retrieved""" + + +class NodeRetrievalError(Exception): + """Raised when the list of nodes on the VLM network cannot be retrieved""" From 702ca287879c2269c54c33fc07f6f897f831f5b4 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Mon, 26 Oct 2026 16:23:28 -0400 Subject: [PATCH 02/13] wip - inject dependencies into classes --- src/anyvlm/outbound/node_registry.py | 18 +++++++++--------- src/anyvlm/outbound/vlm_client.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index 8e884f1..b3a7137 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -4,8 +4,10 @@ from datetime import UTC, datetime, timedelta from http import HTTPMethod, HTTPStatus +from requests import Response + from anyvlm.config import Settings, get_config -from anyvlm.outbound.client import VlmClient +from anyvlm.outbound.vlm_client import VlmClient from anyvlm.utils.exceptions import NodeRetrievalError @@ -27,8 +29,8 @@ class NodeRegistry: _nodes_expiry: datetime _vlm_client: VlmClient - def __init__(self) -> None: # noqa: D107 - self._vlm_client = VlmClient() + def __init__(self, vlm_client: VlmClient) -> None: # noqa: D107 + self._vlm_client = vlm_client settings: Settings = get_config() self.NODES_REQUEST_URL: str = settings.base_vlm_network_url + "/api/v2/clients" @@ -44,19 +46,19 @@ def get_nodes(self) -> list[VlmNode]: def _refresh_nodes(self) -> None: """Retrieve an updated list of known nodes on the VLM Network + reset the expiry time for the next check""" - response = self._vlm_client.request( + response: Response = self._vlm_client.request( method_type=HTTPMethod.GET, url=self.NODES_REQUEST_URL ) if response.status_code == HTTPStatus.OK: - self._known_nodes = [] + self._nodes = [] else: error_message: str = "Unable to fetch VLM Network nodes from Auth0" raise NodeRetrievalError(error_message) data = response.json() for entry in data: - self._known_nodes.append( + self._nodes.append( VlmNode( client_id=entry.get("client_id", ""), client_name=entry.get("name", ""), @@ -64,6 +66,4 @@ def _refresh_nodes(self) -> None: ) ) - self._known_nodes_expiry: datetime = ( - datetime.now(tz=UTC) + self.NODES_REFRESH_INTERVAL - ) + self._nodes_expiry = datetime.now(tz=UTC) + self.NODES_REFRESH_INTERVAL diff --git a/src/anyvlm/outbound/vlm_client.py b/src/anyvlm/outbound/vlm_client.py index d056aa2..2fc06dc 100644 --- a/src/anyvlm/outbound/vlm_client.py +++ b/src/anyvlm/outbound/vlm_client.py @@ -15,7 +15,7 @@ class VlmClient: def __init__(self, token_provider: TokenProvider) -> None: # noqa: D107 self.token_provider: TokenProvider = token_provider - async def request( + def request( self, method_type: HTTPMethod, url: str, From e0d9de672bd55b1302e3d7da05f02a17209d5cfa Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 12:34:34 -0400 Subject: [PATCH 03/13] exclude our own node from the node registry --- src/anyvlm/outbound/node_registry.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index b3a7137..c8196b3 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -1,5 +1,6 @@ """Retrieves information about other nodes on the VLM Network""" +import os from dataclasses import dataclass from datetime import UTC, datetime, timedelta from http import HTTPMethod, HTTPStatus @@ -51,19 +52,22 @@ def _refresh_nodes(self) -> None: ) if response.status_code == HTTPStatus.OK: - self._nodes = [] + self._nodes = [] # blank out the existing node list, so we can repopulate it w/ fresh data else: error_message: str = "Unable to fetch VLM Network nodes from Auth0" raise NodeRetrievalError(error_message) data = response.json() for entry in data: - self._nodes.append( - VlmNode( - client_id=entry.get("client_id", ""), - client_name=entry.get("name", ""), - match_url=entry.get("client_metadata", {}).get("match_url"), + if entry.get("client_id") != os.environ.get( + "VLM_CLIENT_ID" + ): # exclude our own node from the registry + self._nodes.append( + VlmNode( + client_id=entry.get("client_id", ""), + client_name=entry.get("name", ""), + match_url=entry.get("client_metadata", {}).get("match_url"), + ) ) - ) self._nodes_expiry = datetime.now(tz=UTC) + self.NODES_REFRESH_INTERVAL From 67cf670a44a77c6c518c93cedb26d392f79b26cf Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 12:46:17 -0400 Subject: [PATCH 04/13] adds trailing slash to base VLM network URL + moves tests for the node registry to this PR --- src/anyvlm/auth/token_provider.py | 4 +- src/anyvlm/config.py | 2 +- src/anyvlm/outbound/node_registry.py | 8 +-- tests/unit/test_node_registry.py | 86 ++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_node_registry.py diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index 6e9df74..b2bf659 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -23,8 +23,8 @@ class TokenProvider: def __init__(self) -> None: # noqa: D107 settings: Settings = get_config() - self.AUDIENCE_URL: str = settings.base_vlm_network_url + "/api/v2/" - self.TOKEN_REQUEST_URL: str = settings.base_vlm_network_url + "/oauth/token" + self.AUDIENCE_URL: str = settings.base_vlm_network_url + "api/v2/" + self.TOKEN_REQUEST_URL: str = settings.base_vlm_network_url + "oauth/token" self.get_token() diff --git a/src/anyvlm/config.py b/src/anyvlm/config.py index 6320f11..3f85fce 100644 --- a/src/anyvlm/config.py +++ b/src/anyvlm/config.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): anyvar_uri: str | None = None storage_uri: str = "postgresql://postgres@localhost:5432/anyvlm" logging_config: FilePath | None = None - base_vlm_network_url: str = "https://vlm-auth.us.auth0.com" + base_vlm_network_url: str = "https://vlm-auth.us.auth0.com/" @cache diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index c8196b3..46d8643 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -34,18 +34,18 @@ def __init__(self, vlm_client: VlmClient) -> None: # noqa: D107 self._vlm_client = vlm_client settings: Settings = get_config() - self.NODES_REQUEST_URL: str = settings.base_vlm_network_url + "/api/v2/clients" + self.NODES_REQUEST_URL: str = settings.base_vlm_network_url + "api/v2/clients" - self._refresh_nodes() + self.refresh_nodes() def get_nodes(self) -> list[VlmNode]: """Get a list of known nodes on the VLM network. Uses the cached list if cache is set & unexpired.""" now: datetime = datetime.now(tz=UTC) if (not self._nodes) or (now >= self._nodes_expiry): - self._refresh_nodes() + self.refresh_nodes() return self._nodes - def _refresh_nodes(self) -> None: + def refresh_nodes(self) -> None: """Retrieve an updated list of known nodes on the VLM Network + reset the expiry time for the next check""" response: Response = self._vlm_client.request( method_type=HTTPMethod.GET, url=self.NODES_REQUEST_URL diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py new file mode 100644 index 0000000..1d32198 --- /dev/null +++ b/tests/unit/test_node_registry.py @@ -0,0 +1,86 @@ +from datetime import UTC, datetime, timedelta +from http import HTTPMethod, HTTPStatus +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from anyvlm.outbound.node_registry import NodeRegistry +from anyvlm.utils.exceptions import NodeRetrievalError + + +def _response( + status_code: HTTPStatus = HTTPStatus.OK, data: list[Any] | None = None +) -> MagicMock: + response: MagicMock = MagicMock() + response.status_code = status_code + response.json.return_value = data or [] + return response + + +def test_loads_nodes_and_exposes_client_ids() -> None: + client: MagicMock = MagicMock() + client.request.return_value = _response( + data=[ + { + "client_id": "client-one", + "name": "One", + "client_metadata": {"match_url": "https://one.example"}, + }, + {"client_id": "client-two", "name": "Two"}, + ] + ) + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example/" + registry: NodeRegistry = NodeRegistry(vlm_client=client) + + assert [node.client_id for node in registry.get_nodes()] == [ + "client-one", + "client-two", + ] + client.request.assert_called_once_with( + method_type=HTTPMethod.GET, + url="https://network.example/api/v2/clients", + ) + + +def test_refresh_nodes_replaces_cached_nodes() -> None: + client: MagicMock = MagicMock() + client.request.side_effect = [ + _response(data=[{"client_id": "old"}]), + _response(data=[{"client_id": "new"}]), + ] + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example/" + registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry.refresh_nodes() + + assert [node.client_id for node in registry.get_nodes()] == ["new"] + + +def test_refreshes_expired_cache_on_get_nodes() -> None: + client: MagicMock = MagicMock() + client.request.side_effect = [ + _response(data=[{"client_id": "initial"}]), + _response(data=[{"client_id": "refreshed"}]), + ] + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example/" + registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry._nodes_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage] + assert [node.client_id for node in registry.get_nodes()] == ["refreshed"] + + assert client.request.call_count == 2 + + +def test_raises_when_node_retrieval_fails() -> None: + client: MagicMock = MagicMock() + client.request.return_value = _response(status_code=HTTPStatus.SERVICE_UNAVAILABLE) + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example/" + with pytest.raises(NodeRetrievalError, match="Unable to fetch"): + NodeRegistry(vlm_client=client) From ce5eabadc1d96537f8867d44f4bdaf92995b8231 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 13:00:17 -0400 Subject: [PATCH 05/13] don't refresh nodes just because the registry doesn't contain any entries --- src/anyvlm/outbound/node_registry.py | 2 +- tests/unit/test_node_registry.py | 32 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index 46d8643..fdd9f0a 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -41,7 +41,7 @@ def __init__(self, vlm_client: VlmClient) -> None: # noqa: D107 def get_nodes(self) -> list[VlmNode]: """Get a list of known nodes on the VLM network. Uses the cached list if cache is set & unexpired.""" now: datetime = datetime.now(tz=UTC) - if (not self._nodes) or (now >= self._nodes_expiry): + if (not self._nodes_expiry) or (now >= self._nodes_expiry): self.refresh_nodes() return self._nodes diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py index 1d32198..cc71029 100644 --- a/tests/unit/test_node_registry.py +++ b/tests/unit/test_node_registry.py @@ -1,3 +1,4 @@ +import os from datetime import UTC, datetime, timedelta from http import HTTPMethod, HTTPStatus from typing import Any @@ -84,3 +85,34 @@ def test_raises_when_node_retrieval_fails() -> None: get_config.return_value.base_vlm_network_url = "https://network.example/" with pytest.raises(NodeRetrievalError, match="Unable to fetch"): NodeRegistry(vlm_client=client) + + +def test_node_registry_excludes_self() -> None: + # this is our own node's client_id + self_vlm_client_id: str = os.environ.get("VLM_CLIENT_ID", "") + + client: MagicMock = MagicMock() + client.request.return_value = _response( + data=[ + { + "client_id": "client-one", + "name": "One", + "client_metadata": {"match_url": "https://one.example"}, + }, + { + "client_id": self_vlm_client_id, + "name": "GenomicMedLabs' AnyVLM instance", + }, + ] + ) + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example/" + registry: NodeRegistry = NodeRegistry(vlm_client=client) + + assert [node.client_id for node in registry.get_nodes()] == [ + "client-one", + ] + client.request.assert_called_once_with( + method_type=HTTPMethod.GET, url="https://network.example/api/v2/clients" + ) From 95c31df1f044371d05513e1f3c1d6ea79d6d82a7 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 13:03:39 -0400 Subject: [PATCH 06/13] add test for case when only self exists during node discovery --- tests/unit/test_node_registry.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py index cc71029..0ce8065 100644 --- a/tests/unit/test_node_registry.py +++ b/tests/unit/test_node_registry.py @@ -87,7 +87,7 @@ def test_raises_when_node_retrieval_fails() -> None: NodeRegistry(vlm_client=client) -def test_node_registry_excludes_self() -> None: +def test_registry_excludes_self_when_other_nodes_exist() -> None: # this is our own node's client_id self_vlm_client_id: str = os.environ.get("VLM_CLIENT_ID", "") @@ -116,3 +116,27 @@ def test_node_registry_excludes_self() -> None: client.request.assert_called_once_with( method_type=HTTPMethod.GET, url="https://network.example/api/v2/clients" ) + + +def test_registry_returns_empty_list_when_only_self_exists() -> None: + # this is our own node's client_id + self_vlm_client_id: str = os.environ.get("VLM_CLIENT_ID", "") + + client: MagicMock = MagicMock() + client.request.return_value = _response( + data=[ + { + "client_id": self_vlm_client_id, + "name": "GenomicMedLabs' AnyVLM instance", + }, + ] + ) + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example/" + registry: NodeRegistry = NodeRegistry(vlm_client=client) + + assert [node.client_id for node in registry.get_nodes()] == [] + client.request.assert_called_once_with( + method_type=HTTPMethod.GET, url="https://network.example/api/v2/clients" + ) From cfffa0b2e8e101e247e757003fd643944f38e22a Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 13:05:33 -0400 Subject: [PATCH 07/13] whoops redo adding documentation to TokenProvider's '__init__' method --- src/anyvlm/auth/token_provider.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index b2bf659..15a5375 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -21,7 +21,8 @@ class TokenProvider: _token: str = "" _token_expiry: datetime - def __init__(self) -> None: # noqa: D107 + def __init__(self) -> None: + """Initializes a TokenProvider to obtain JWT tokens for outbound HTTP requests to other nodes on the VLM Network""" settings: Settings = get_config() self.AUDIENCE_URL: str = settings.base_vlm_network_url + "api/v2/" self.TOKEN_REQUEST_URL: str = settings.base_vlm_network_url + "oauth/token" From 754c1c1454a96596e1df4dfe6ad7805fbe06ae14 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 13:12:57 -0400 Subject: [PATCH 08/13] document possible Exceptions that may be raised when retrieving nodes in the 'NodeRegistry' method signatures --- src/anyvlm/outbound/node_registry.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index fdd9f0a..8200200 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -30,7 +30,11 @@ class NodeRegistry: _nodes_expiry: datetime _vlm_client: VlmClient - def __init__(self, vlm_client: VlmClient) -> None: # noqa: D107 + def __init__(self, vlm_client: VlmClient) -> None: + """Initializes a list of all other nodes currently on the VLM Network + + :param vlm_client: A 'VlmClient' instance used to send outbound HTTP requests to the VLM Network + """ self._vlm_client = vlm_client settings: Settings = get_config() @@ -39,14 +43,20 @@ def __init__(self, vlm_client: VlmClient) -> None: # noqa: D107 self.refresh_nodes() def get_nodes(self) -> list[VlmNode]: - """Get a list of known nodes on the VLM network. Uses the cached list if cache is set & unexpired.""" + """Get a list of known nodes on the VLM network. Uses the cached list if cache is set & unexpired. + See 'self.refresh_nodes()' for list of possible Exceptions that may be raised. + """ now: datetime = datetime.now(tz=UTC) if (not self._nodes_expiry) or (now >= self._nodes_expiry): self.refresh_nodes() return self._nodes def refresh_nodes(self) -> None: - """Retrieve an updated list of known nodes on the VLM Network + reset the expiry time for the next check""" + """Retrieve an updated list of known nodes on the VLM Network + reset the expiry time for the next check + + :raises: `NodeRetrievalError` if request to the VLM Network to obtain the list of registered nodes fails + :raises: `TokenRetrievalError` if unable to obtain a JWT token to send the request to the VLM Network + """ response: Response = self._vlm_client.request( method_type=HTTPMethod.GET, url=self.NODES_REQUEST_URL ) From e3abeb4fe9b0b79e5f7c3566ddd62dab85a54d27 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 13:24:52 -0400 Subject: [PATCH 09/13] add support for paginated results for node registry calls --- src/anyvlm/outbound/node_registry.py | 57 ++++++++++++++++++---------- tests/unit/test_node_registry.py | 36 ++++++++++++++++-- 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index 8200200..53570c6 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -25,6 +25,9 @@ class NodeRegistry: """Manages the list of other known nodes on the VLM Network""" NODES_REFRESH_INTERVAL: timedelta = timedelta(hours=24) + NODES_RESULTS_PAGE_SIZE: int = ( + 100 # Auth0's node list is paginated w/ a max of 100 results per page + ) _nodes: list[VlmNode] _nodes_expiry: datetime @@ -57,27 +60,39 @@ def refresh_nodes(self) -> None: :raises: `NodeRetrievalError` if request to the VLM Network to obtain the list of registered nodes fails :raises: `TokenRetrievalError` if unable to obtain a JWT token to send the request to the VLM Network """ - response: Response = self._vlm_client.request( - method_type=HTTPMethod.GET, url=self.NODES_REQUEST_URL - ) - - if response.status_code == HTTPStatus.OK: - self._nodes = [] # blank out the existing node list, so we can repopulate it w/ fresh data - else: - error_message: str = "Unable to fetch VLM Network nodes from Auth0" - raise NodeRetrievalError(error_message) - - data = response.json() - for entry in data: - if entry.get("client_id") != os.environ.get( - "VLM_CLIENT_ID" - ): # exclude our own node from the registry - self._nodes.append( - VlmNode( - client_id=entry.get("client_id", ""), - client_name=entry.get("name", ""), - match_url=entry.get("client_metadata", {}).get("match_url"), - ) + refreshed_nodes: list[VlmNode] = [] + page = 0 + while True: + response: Response = self._vlm_client.request( + method_type=HTTPMethod.GET, + url=( + f"{self.NODES_REQUEST_URL}?page={page}" + f"&per_page={self.NODES_RESULTS_PAGE_SIZE}" + ), + ) + + if response.status_code != HTTPStatus.OK: + error_message: str = "Unable to fetch VLM Network nodes from Auth0" + raise NodeRetrievalError(error_message) + + data = response.json() + refreshed_nodes.extend( + VlmNode( + client_id=entry.get("client_id", ""), + client_name=entry.get("name", ""), + match_url=entry.get("client_metadata", {}).get("match_url"), ) + for entry in data + if entry.get("client_id") + != os.environ.get("VLM_CLIENT_ID") # exclude our own node + ) + + # we know we've hit the last page if the number of results is less than our requested limit + if len(data) < self.NODES_RESULTS_PAGE_SIZE: + break + page += 1 + + # Update the cache only after all pages have been retrieved successfully. + self._nodes = refreshed_nodes self._nodes_expiry = datetime.now(tz=UTC) + self.NODES_REFRESH_INTERVAL diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py index 0ce8065..d9749c6 100644 --- a/tests/unit/test_node_registry.py +++ b/tests/unit/test_node_registry.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime, timedelta from http import HTTPMethod, HTTPStatus from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest @@ -42,10 +42,36 @@ def test_loads_nodes_and_exposes_client_ids() -> None: ] client.request.assert_called_once_with( method_type=HTTPMethod.GET, - url="https://network.example/api/v2/clients", + url="https://network.example/api/v2/clients?page=0&per_page=100", ) +def test_loads_nodes_from_multiple_pages() -> None: + client: MagicMock = MagicMock() + client.request.side_effect = [ + _response(data=[{"client_id": "client-one"}] * 100), + _response(data=[{"client_id": "client-two"}]), + ] + + with patch("anyvlm.outbound.node_registry.get_config") as get_config: + get_config.return_value.base_vlm_network_url = "https://network.example/" + registry: NodeRegistry = NodeRegistry(vlm_client=client) + + assert [node.client_id for node in registry.get_nodes()] == [ + "client-one", + ] * 100 + ["client-two"] + assert client.request.call_args_list == [ + call( + method_type=HTTPMethod.GET, + url="https://network.example/api/v2/clients?page=0&per_page=100", + ), + call( + method_type=HTTPMethod.GET, + url="https://network.example/api/v2/clients?page=1&per_page=100", + ), + ] + + def test_refresh_nodes_replaces_cached_nodes() -> None: client: MagicMock = MagicMock() client.request.side_effect = [ @@ -114,7 +140,8 @@ def test_registry_excludes_self_when_other_nodes_exist() -> None: "client-one", ] client.request.assert_called_once_with( - method_type=HTTPMethod.GET, url="https://network.example/api/v2/clients" + method_type=HTTPMethod.GET, + url="https://network.example/api/v2/clients?page=0&per_page=100", ) @@ -138,5 +165,6 @@ def test_registry_returns_empty_list_when_only_self_exists() -> None: assert [node.client_id for node in registry.get_nodes()] == [] client.request.assert_called_once_with( - method_type=HTTPMethod.GET, url="https://network.example/api/v2/clients" + method_type=HTTPMethod.GET, + url="https://network.example/api/v2/clients?page=0&per_page=100", ) From c2d1c64f63e7fce4fbfdd459ac9b99db0eaaf5f3 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 28 Oct 2026 13:30:31 -0400 Subject: [PATCH 10/13] fix failing test --- tests/unit/test_node_registry.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py index d9749c6..ccbb802 100644 --- a/tests/unit/test_node_registry.py +++ b/tests/unit/test_node_registry.py @@ -1,4 +1,3 @@ -import os from datetime import UTC, datetime, timedelta from http import HTTPMethod, HTTPStatus from typing import Any @@ -113,9 +112,12 @@ def test_raises_when_node_retrieval_fails() -> None: NodeRegistry(vlm_client=client) -def test_registry_excludes_self_when_other_nodes_exist() -> None: +def test_registry_excludes_self_when_other_nodes_exist( + monkeypatch: pytest.MonkeyPatch, +) -> None: # this is our own node's client_id - self_vlm_client_id: str = os.environ.get("VLM_CLIENT_ID", "") + self_vlm_client_id = "self-client" + monkeypatch.setenv("VLM_CLIENT_ID", self_vlm_client_id) client: MagicMock = MagicMock() client.request.return_value = _response( @@ -145,9 +147,12 @@ def test_registry_excludes_self_when_other_nodes_exist() -> None: ) -def test_registry_returns_empty_list_when_only_self_exists() -> None: +def test_registry_returns_empty_list_when_only_self_exists( + monkeypatch: pytest.MonkeyPatch, +) -> None: # this is our own node's client_id - self_vlm_client_id: str = os.environ.get("VLM_CLIENT_ID", "") + self_vlm_client_id = "self-client" + monkeypatch.setenv("VLM_CLIENT_ID", self_vlm_client_id) client: MagicMock = MagicMock() client.request.return_value = _response( From 2a51aff9cb810c6ba639c8ae0c73068660162a1e Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 13:42:14 -0400 Subject: [PATCH 11/13] include status code in response when request to Auth0 for node list fails --- src/anyvlm/outbound/node_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index 53570c6..4003bf5 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -72,7 +72,7 @@ def refresh_nodes(self) -> None: ) if response.status_code != HTTPStatus.OK: - error_message: str = "Unable to fetch VLM Network nodes from Auth0" + error_message: str = f"Unable to fetch VLM Network nodes from Auth0 - received {response.status_code} response" raise NodeRetrievalError(error_message) data = response.json() From 9ced5da3d93e98a535798d814f3930eea3068e82 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 13:44:40 -0400 Subject: [PATCH 12/13] mock vlm network cred env vars so tests don't fail --- tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 4c29665..f873101 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,8 @@ environ.setdefault("HANDOVER_TYPE_ID", "CUSTOM:test-id") environ.setdefault("HANDOVER_TYPE_LABEL", "Test Label") environ.setdefault("BEACON_HANDOVER_URL", "https://test.example.com") +environ.setdefault("VLM_CLIENT_ID", "example-client-id") +environ.setdefault("VLM_CLIENT_SECRET", "example-client-secret") @pytest.fixture(scope="session") From 91c843f396a14d7e9ec058c37f7908f577b7b2b8 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 29 Oct 2026 14:53:30 -0400 Subject: [PATCH 13/13] move non-configurable values out of 'Settings' --- src/anyvlm/auth/token_provider.py | 15 +++++---- src/anyvlm/config.py | 1 - src/anyvlm/outbound/node_registry.py | 8 ++--- src/anyvlm/utils/constants.py | 7 ++++ tests/unit/test_node_registry.py | 49 ++++++++++------------------ 5 files changed, 35 insertions(+), 45 deletions(-) create mode 100644 src/anyvlm/utils/constants.py diff --git a/src/anyvlm/auth/token_provider.py b/src/anyvlm/auth/token_provider.py index 4737d38..679e271 100644 --- a/src/anyvlm/auth/token_provider.py +++ b/src/anyvlm/auth/token_provider.py @@ -7,7 +7,10 @@ import requests from requests.models import Response -from anyvlm.config import Settings, get_config +from anyvlm.utils.constants import ( + VLM_NETWORK_AUDIENCE_URL, + VLM_NETWORK_TOKEN_REQUEST_URL, +) from anyvlm.utils.exceptions import TokenRetrievalError @@ -23,15 +26,13 @@ class TokenProvider: def __init__(self) -> None: """Initializes a TokenProvider to obtain JWT tokens for outbound HTTP requests to other nodes on the VLM Network""" - settings: Settings = get_config() - self.AUDIENCE_URL: str = settings.base_vlm_network_url + "api/v2/" - self.TOKEN_REQUEST_URL: str = settings.base_vlm_network_url + "oauth/token" - client_id: str | None = os.getenv("VLM_CLIENT_ID") client_secret: str | None = os.getenv("VLM_CLIENT_SECRET") + if not client_id or not client_secret: error_message: str = "Unable to retrieve token - missing 'VLM_CLIENT_ID' and/or 'VLM_CLIENT_SECRET' environment variables" raise TokenRetrievalError(error_message) + self.client_id: str = client_id self.client_secret: str = client_secret @@ -53,11 +54,11 @@ def _refresh_token(self) -> None: """Retrieves and sets a new JWT token and updates the token expiry time""" now: datetime = datetime.now(tz=UTC) response: Response = requests.post( - url=self.TOKEN_REQUEST_URL, + url=VLM_NETWORK_TOKEN_REQUEST_URL, json={ "client_id": self.client_id, "client_secret": self.client_secret, - "audience": self.AUDIENCE_URL, + "audience": VLM_NETWORK_AUDIENCE_URL, "grant_type": "client_credentials", }, timeout=10, diff --git a/src/anyvlm/config.py b/src/anyvlm/config.py index 3f85fce..3d1554d 100644 --- a/src/anyvlm/config.py +++ b/src/anyvlm/config.py @@ -27,7 +27,6 @@ class Settings(BaseSettings): anyvar_uri: str | None = None storage_uri: str = "postgresql://postgres@localhost:5432/anyvlm" logging_config: FilePath | None = None - base_vlm_network_url: str = "https://vlm-auth.us.auth0.com/" @cache diff --git a/src/anyvlm/outbound/node_registry.py b/src/anyvlm/outbound/node_registry.py index 4003bf5..eb5eea5 100644 --- a/src/anyvlm/outbound/node_registry.py +++ b/src/anyvlm/outbound/node_registry.py @@ -7,8 +7,8 @@ from requests import Response -from anyvlm.config import Settings, get_config from anyvlm.outbound.vlm_client import VlmClient +from anyvlm.utils.constants import VLM_NETWORK_NODES_REQUEST_URL from anyvlm.utils.exceptions import NodeRetrievalError @@ -39,10 +39,6 @@ def __init__(self, vlm_client: VlmClient) -> None: :param vlm_client: A 'VlmClient' instance used to send outbound HTTP requests to the VLM Network """ self._vlm_client = vlm_client - - settings: Settings = get_config() - self.NODES_REQUEST_URL: str = settings.base_vlm_network_url + "api/v2/clients" - self.refresh_nodes() def get_nodes(self) -> list[VlmNode]: @@ -66,7 +62,7 @@ def refresh_nodes(self) -> None: response: Response = self._vlm_client.request( method_type=HTTPMethod.GET, url=( - f"{self.NODES_REQUEST_URL}?page={page}" + f"{VLM_NETWORK_NODES_REQUEST_URL}?page={page}" f"&per_page={self.NODES_RESULTS_PAGE_SIZE}" ), ) diff --git a/src/anyvlm/utils/constants.py b/src/anyvlm/utils/constants.py new file mode 100644 index 0000000..7fbc354 --- /dev/null +++ b/src/anyvlm/utils/constants.py @@ -0,0 +1,7 @@ +"""Global constants""" + +# VLM Network URLs +VLM_NETWORK_ROOT_URL: str = "https://vlm-auth.us.auth0.com/" +VLM_NETWORK_AUDIENCE_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/" +VLM_NETWORK_TOKEN_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "oauth/token/" +VLM_NETWORK_NODES_REQUEST_URL: str = VLM_NETWORK_ROOT_URL + "api/v2/clients/" diff --git a/tests/unit/test_node_registry.py b/tests/unit/test_node_registry.py index ccbb802..00e05a0 100644 --- a/tests/unit/test_node_registry.py +++ b/tests/unit/test_node_registry.py @@ -1,11 +1,12 @@ from datetime import UTC, datetime, timedelta from http import HTTPMethod, HTTPStatus from typing import Any -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, call import pytest from anyvlm.outbound.node_registry import NodeRegistry +from anyvlm.utils.constants import VLM_NETWORK_NODES_REQUEST_URL from anyvlm.utils.exceptions import NodeRetrievalError @@ -31,9 +32,7 @@ def test_loads_nodes_and_exposes_client_ids() -> None: ] ) - with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" - registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry: NodeRegistry = NodeRegistry(vlm_client=client) assert [node.client_id for node in registry.get_nodes()] == [ "client-one", @@ -41,7 +40,7 @@ def test_loads_nodes_and_exposes_client_ids() -> None: ] client.request.assert_called_once_with( method_type=HTTPMethod.GET, - url="https://network.example/api/v2/clients?page=0&per_page=100", + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100", ) @@ -52,9 +51,7 @@ def test_loads_nodes_from_multiple_pages() -> None: _response(data=[{"client_id": "client-two"}]), ] - with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" - registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry: NodeRegistry = NodeRegistry(vlm_client=client) assert [node.client_id for node in registry.get_nodes()] == [ "client-one", @@ -62,11 +59,11 @@ def test_loads_nodes_from_multiple_pages() -> None: assert client.request.call_args_list == [ call( method_type=HTTPMethod.GET, - url="https://network.example/api/v2/clients?page=0&per_page=100", + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100", ), call( method_type=HTTPMethod.GET, - url="https://network.example/api/v2/clients?page=1&per_page=100", + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=1&per_page=100", ), ] @@ -78,10 +75,8 @@ def test_refresh_nodes_replaces_cached_nodes() -> None: _response(data=[{"client_id": "new"}]), ] - with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" - registry: NodeRegistry = NodeRegistry(vlm_client=client) - registry.refresh_nodes() + registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry.refresh_nodes() assert [node.client_id for node in registry.get_nodes()] == ["new"] @@ -93,11 +88,9 @@ def test_refreshes_expired_cache_on_get_nodes() -> None: _response(data=[{"client_id": "refreshed"}]), ] - with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" - registry: NodeRegistry = NodeRegistry(vlm_client=client) - registry._nodes_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage] - assert [node.client_id for node in registry.get_nodes()] == ["refreshed"] + registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry._nodes_expiry = datetime.now(tz=UTC) - timedelta(seconds=1) # pyright: ignore[reportPrivateUsage] + assert [node.client_id for node in registry.get_nodes()] == ["refreshed"] assert client.request.call_count == 2 @@ -106,10 +99,8 @@ def test_raises_when_node_retrieval_fails() -> None: client: MagicMock = MagicMock() client.request.return_value = _response(status_code=HTTPStatus.SERVICE_UNAVAILABLE) - with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" - with pytest.raises(NodeRetrievalError, match="Unable to fetch"): - NodeRegistry(vlm_client=client) + with pytest.raises(NodeRetrievalError, match="Unable to fetch"): + NodeRegistry(vlm_client=client) def test_registry_excludes_self_when_other_nodes_exist( @@ -134,16 +125,14 @@ def test_registry_excludes_self_when_other_nodes_exist( ] ) - with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" - registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry: NodeRegistry = NodeRegistry(vlm_client=client) assert [node.client_id for node in registry.get_nodes()] == [ "client-one", ] client.request.assert_called_once_with( method_type=HTTPMethod.GET, - url="https://network.example/api/v2/clients?page=0&per_page=100", + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100", ) @@ -164,12 +153,10 @@ def test_registry_returns_empty_list_when_only_self_exists( ] ) - with patch("anyvlm.outbound.node_registry.get_config") as get_config: - get_config.return_value.base_vlm_network_url = "https://network.example/" - registry: NodeRegistry = NodeRegistry(vlm_client=client) + registry: NodeRegistry = NodeRegistry(vlm_client=client) assert [node.client_id for node in registry.get_nodes()] == [] client.request.assert_called_once_with( method_type=HTTPMethod.GET, - url="https://network.example/api/v2/clients?page=0&per_page=100", + url=VLM_NETWORK_NODES_REQUEST_URL + "?page=0&per_page=100", )