From 35ac1738c38f4f4518a73a75c61296f043125a70 Mon Sep 17 00:00:00 2001 From: Jayaraj Tale Date: Tue, 22 Sep 2026 18:17:10 -0400 Subject: [PATCH] Reuse HTTP connections across Azure clients --- cfa/cloudops/_cloudclient.py | 31 +++++++++++++++++--- changelog.md | 5 ++++ pyproject.toml | 2 +- tests/test_cloudclient.py | 57 ++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/cfa/cloudops/_cloudclient.py b/cfa/cloudops/_cloudclient.py index dc633ebf..a87f8d20 100644 --- a/cfa/cloudops/_cloudclient.py +++ b/cfa/cloudops/_cloudclient.py @@ -14,6 +14,7 @@ BatchJobConstraints, BatchMetadataItem, ) +from azure.core.pipeline.transport import RequestsTransport from azure.keyvault.secrets import SecretClient from azure.mgmt.batch import models from azure.mgmt.resource.subscriptions import SubscriptionClient @@ -154,12 +155,30 @@ def __init__( **kwargs, ) + # Create a shared Azure HTTP transport so SDK clients can reuse connections. + self._http_transport = RequestsTransport() + self._http_transport.open() + + def _transport() -> RequestsTransport: + return RequestsTransport( + session=self._http_transport.session, + session_owner=False, + ) + # get clients logger.debug("Getting Azure clients and setting other attributes.") - self.batch_mgmt_client = get_batch_management_client(self.cred) - self.compute_mgmt_client = get_compute_management_client(self.cred) - self.batch_service_client = get_batch_service_client(self.cred) - self.blob_service_client = get_blob_service_client(self.cred) + self.batch_mgmt_client = get_batch_management_client( + self.cred, transport=_transport() + ) + self.compute_mgmt_client = get_compute_management_client( + self.cred, transport=_transport() + ) + self.batch_service_client = get_batch_service_client( + self.cred, transport=_transport() + ) + self.blob_service_client = get_blob_service_client( + self.cred, transport=_transport() + ) # set other defaults self.full_container_name = None @@ -168,6 +187,10 @@ def __init__( self.task_id_ints = False self.task_id_max = 0 + def close(self) -> None: + """Close the shared HTTP transport used by Azure SDK clients.""" + self._http_transport.close() + def check_credentials(self) -> pd.DataFrame: """Check credentials and return accessible Azure resource information. diff --git a/changelog.md b/changelog.md index c8dbbc61..1a6293dd 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). The versioning pattern is `major.minor.patch`. --- +## 1.2.0 + +- reuse HTTP connections across the core Azure SDK clients created by `CloudClient` +- add explicit cleanup for the shared HTTP transport + ## 1.1.0 - add type hint support for the public `cfa.cloudops` API diff --git a/pyproject.toml b/pyproject.toml index b8b5828c..c059de36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cfa.cloudops" -version = "1.1.0" +version = "1.2.0" description = "Cloud storage, batch, functions, MLOps assistance" authors = [ {name = "Ryan Raasch", email = "xng3@cdc.gov"} diff --git a/tests/test_cloudclient.py b/tests/test_cloudclient.py index 1666c382..bad014f8 100644 --- a/tests/test_cloudclient.py +++ b/tests/test_cloudclient.py @@ -433,6 +433,63 @@ def test_cloudclient_init_with_env_credentials( mock_compute_management_client.assert_called_once() +def test_cloudclient_reuses_http_session_across_azure_clients( + mock_env_vars, + mock_batch_service_client, + mock_batch_management_client, + mock_blob_service_client, + mock_compute_management_client, +): + with patch( + "cfa.cloudops._cloudclient.DefaultCredentialHandler" + ) as mock_cred_handler: + mock_cred_handler.return_value = MagicMock() + + client = CloudClient(dotenv_path=None, use_sp=False, use_federated=False) + + client_factories = [ + mock_batch_management_client, + mock_compute_management_client, + mock_batch_service_client, + mock_blob_service_client, + ] + + transports = [ + factory.call_args.kwargs["transport"] for factory in client_factories + ] + + sessions = [transport.session for transport in transports] + + assert all(session is client._http_transport.session for session in sessions) + + shared_session = client._http_transport.session + + with patch.object(shared_session, "close") as mock_close: + transports[0].close() + + mock_close.assert_not_called() + + +def test_cloudclient_close_closes_shared_http_session( + mock_env_vars, + mock_batch_service_client, + mock_batch_management_client, + mock_blob_service_client, + mock_compute_management_client, +): + with patch( + "cfa.cloudops._cloudclient.DefaultCredentialHandler" + ) as mock_cred_handler: + mock_cred_handler.return_value = MagicMock() + + client = CloudClient(dotenv_path=None, use_sp=False, use_federated=False) + + with patch.object(client._http_transport, "close") as mock_close: + client.close() + + mock_close.assert_called_once_with() + + def test_cloudclient_init_with_default_credentials( mock_env_vars, mock_batch_service_client, diff --git a/uv.lock b/uv.lock index 5de54f7b..87807b76 100644 --- a/uv.lock +++ b/uv.lock @@ -435,7 +435,7 @@ wheels = [ [[package]] name = "cfa-cloudops" -version = "1.1.0" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "anyio" },