This is an automated email from the ASF dual-hosted git repository.

dabla pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 6fd1994ce28 refactor: Introduced CachedAsyncTokenCredential which 
keeps the session open during lifecycle of cached RequestAdapter (#69365)
6fd1994ce28 is described below

commit 6fd1994ce2878c7317aa8a2f9b334beda0041961
Author: David Blain <[email protected]>
AuthorDate: Tue Jul 7 15:27:27 2026 +0200

    refactor: Introduced CachedAsyncTokenCredential which keeps the session 
open during lifecycle of cached RequestAdapter (#69365)
---
 .../providers/microsoft/azure/hooks/msgraph.py     | 99 ++++++++++++++++------
 .../unit/microsoft/azure/hooks/test_msgraph.py     | 84 +++++++++---------
 .../azure/tests/unit/microsoft/azure/test_utils.py | 34 +++++++-
 3 files changed, 151 insertions(+), 66 deletions(-)

diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
index 50ad3cb1e2f..4952531dcc6 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/msgraph.py
@@ -27,10 +27,12 @@ from contextlib import suppress
 from http import HTTPStatus
 from io import BytesIO
 from json import JSONDecodeError
+from types import TracebackType
 from typing import TYPE_CHECKING, Any, cast
 from urllib.parse import quote, urljoin, urlparse
 
 import httpx
+from azure.core.credentials_async import AsyncTokenCredential
 from azure.identity.aio import CertificateCredential, ClientSecretCredential
 from httpx import AsyncHTTPTransport, Response, Timeout
 from kiota_abstractions.api_error import APIError
@@ -53,8 +55,7 @@ from airflow.providers.common.compat.connection import 
get_async_connection
 from airflow.providers.common.compat.sdk import AirflowException, 
AirflowNotFoundException, BaseHook, redact
 
 if TYPE_CHECKING:
-    from azure.core.credentials_async import AsyncTokenCredential
-    from azure.core.pipeline.transport._aiohttp import AioHttpTransport
+    from azure.core.pipeline.transport._requests_basic import RequestsTransport
     from kiota_abstractions.authentication import 
BaseBearerTokenAuthenticationProvider
     from kiota_abstractions.request_adapter import RequestAdapter
     from kiota_abstractions.response_handler import NativeResponseType
@@ -91,6 +92,57 @@ def execute_callable(func: Callable, *args: Any, **kwargs: 
Any) -> Any:
     return func(*args, **filtered_kwargs)
 
 
+class CachedAsyncTokenCredential(AsyncTokenCredential):  # type: ignore[misc]
+    """
+    Wraps an async Azure credential to prevent ``kiota`` from closing it after 
each token request.
+
+    ``kiota_authentication_azure`` calls ``await credential.close()`` after 
every successful
+    ``get_token`` call (see 
``AzureIdentityAccessTokenProvider.get_authorization_token``).  That
+    tears down the underlying ``AioHttpTransport`` session so the next request 
fails with
+    "HTTP transport has already been closed".  Suppressing ``close()`` keeps 
the session alive
+    for the lifetime of the cached ``RequestAdapter``.
+    """
+
+    def __init__(self, credential: ClientSecretCredential | 
CertificateCredential):
+        self._credential = credential
+
+    @property
+    def _transport(self) -> RequestsTransport:
+        return self._credential._client._pipeline._transport
+
+    @property
+    def closed(self) -> bool:
+        # _closed is set to True by AioHttpTransport.close(); check it first 
as it
+        # is authoritative even when _has_been_opened is still False.
+        if getattr(self._transport, "_closed", False):
+            return True
+        if not self._transport._has_been_opened and self._transport.session is 
None:
+            return False
+        if self._transport.session is not None:
+            return self._transport.session.closed
+        return True
+
+    async def __aenter__(self) -> AsyncTokenCredential:
+        return self
+
+    async def __aexit__(
+        self,
+        exc_type: type[BaseException] | None = None,
+        exc_value: BaseException | None = None,
+        traceback: TracebackType | None = None,
+    ) -> None:
+        await self.close()
+
+    async def get_token(self, *args: Any, **kwargs: Any) -> Any:
+        return await self._credential.get_token(*args, **kwargs)
+
+    async def get_token_info(self, *args: Any, **kwargs: Any) -> Any:
+        return await self._credential.get_token_info(*args, **kwargs)  # type: 
ignore[union-attr]
+
+    async def close(self) -> None:
+        """Intentionally a no-op — the credential session is closed when the 
adapter is evicted."""
+
+
 class DefaultResponseHandler(ResponseHandler):
     """DefaultResponseHandler returns JSON payload or content in bytes or 
response headers."""
 
@@ -413,16 +465,9 @@ class KiotaRequestAdapterHook(BaseHook):
 
         provider = cast("BaseBearerTokenAuthenticationProvider", 
adapter._authentication_provider)
         access_token_provider = cast("AzureIdentityAccessTokenProvider", 
provider.access_token_provider)
-        credential = cast(
-            "ClientSecretCredential | CertificateCredential", 
access_token_provider._credentials
-        )
-        transport = cast("AioHttpTransport", 
credential._client._pipeline._transport)
+        credential = cast("CachedAsyncTokenCredential", 
access_token_provider._credentials)
 
-        if not transport._has_been_opened and transport.session is None:
-            return False
-        if transport.session is not None:
-            return transport.session.closed
-        return False
+        return credential.closed
 
     async def get_async_conn(self) -> RequestAdapter:
         """Initiate a new RequestAdapter connection asynchronously."""
@@ -487,25 +532,29 @@ class KiotaRequestAdapterHook(BaseHook):
         self.log.info("Disable instance discovery: %s", 
disable_instance_discovery)
         self.log.info("MSAL Proxies: %s", redact(msal_proxies, name="proxies"))
         if certificate_path or certificate_data:
-            return CertificateCredential(
+            return CachedAsyncTokenCredential(
+                CertificateCredential(
+                    tenant_id=tenant_id,
+                    client_id=login,  # type: ignore
+                    password=password,
+                    certificate_path=certificate_path,
+                    certificate_data=certificate_data.encode() if 
certificate_data else None,
+                    authority=authority,
+                    proxies=msal_proxies,
+                    disable_instance_discovery=disable_instance_discovery,
+                    connection_verify=verify,
+                )
+            )
+        return CachedAsyncTokenCredential(
+            ClientSecretCredential(
                 tenant_id=tenant_id,
                 client_id=login,  # type: ignore
-                password=password,
-                certificate_path=certificate_path,
-                certificate_data=certificate_data.encode() if certificate_data 
else None,
+                client_secret=password,  # type: ignore
                 authority=authority,
                 proxies=msal_proxies,
                 disable_instance_discovery=disable_instance_discovery,
                 connection_verify=verify,
             )
-        return ClientSecretCredential(
-            tenant_id=tenant_id,
-            client_id=login,  # type: ignore
-            client_secret=password,  # type: ignore
-            authority=authority,
-            proxies=msal_proxies,
-            disable_instance_discovery=disable_instance_discovery,
-            connection_verify=verify,
         )
 
     def test_connection(self):
@@ -547,8 +596,6 @@ class KiotaRequestAdapterHook(BaseHook):
         headers: dict[str, str] | None = None,
         data: dict[str, Any] | str | BytesIO | None = None,
     ):
-        self.log.info("Executing url '%s' as '%s'", url, method)
-
         response = await self.send_request(
             request_info=self.request_information(
                 url=url,
@@ -624,6 +671,8 @@ class KiotaRequestAdapterHook(BaseHook):
         conn = await self.get_async_conn()
 
         try:
+            self.log.info("Executing url '%s' as '%s'", request_info.url, 
request_info.http_method)
+
             if response_type:
                 return await conn.send_primitive_async(
                     request_info=request_info,
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py
index ee9b0f87dfa..8eb60e91eed 100644
--- a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py
+++ b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_msgraph.py
@@ -25,16 +25,9 @@ from typing import cast
 from unittest.mock import AsyncMock, Mock, patch
 
 import pytest
-from aiohttp import ClientSession
-from azure.core import AsyncPipelineClient
-from azure.core.credentials_async import AsyncTokenCredential
-from azure.core.pipeline.transport._aiohttp import AioHttpTransport
-from azure.identity._internal import AadClient
 from httpx import AsyncClient, Response
 from httpx._utils import URLPattern
-from kiota_abstractions.authentication import AuthenticationProvider, 
BaseBearerTokenAuthenticationProvider
 from kiota_abstractions.request_information import RequestInformation
-from kiota_authentication_azure.azure_identity_access_token_provider import 
AzureIdentityAccessTokenProvider
 from kiota_http.httpx_request_adapter import HttpxRequestAdapter
 from kiota_serialization_json.json_parse_node import JsonParseNode
 from kiota_serialization_text.text_parse_node import TextParseNode
@@ -44,6 +37,7 @@ from opentelemetry.trace import Span
 from airflow.exceptions import AirflowBadRequest, AirflowConfigException, 
AirflowProviderDeprecationWarning
 from airflow.providers.common.compat.sdk import AirflowException, 
AirflowNotFoundException
 from airflow.providers.microsoft.azure.hooks.msgraph import (
+    CachedAsyncTokenCredential,
     DefaultResponseHandler,
     KiotaRequestAdapterHook,
     execute_callable,
@@ -53,14 +47,55 @@ from tests_common.test_utils.file_loading import 
load_file_from_resources, load_
 from tests_common.test_utils.providers import get_provider_min_airflow_version
 from unit.microsoft.azure.test_utils import (
     get_airflow_connection,
+    mock_authentication_provider,
     mock_connection,
     mock_json_response,
     mock_response,
+    mock_token_credentials,
     patch_hook,
     patch_hook_and_request_adapter,
 )
 
 
+class TestCachedAsyncTokenCredential:
+    @pytest.mark.parametrize(
+        ("closed", "expected"),
+        (
+            pytest.param(None, False),
+            pytest.param(False, False),
+            pytest.param(True, True),
+        ),
+    )
+    def test_closed(self, closed: bool | None, expected: bool):
+        actual = 
CachedAsyncTokenCredential(credential=mock_token_credentials(closed=closed))
+
+        assert actual.closed == expected
+
+    @pytest.mark.asyncio
+    async def test_close(self):
+        credential = mock_token_credentials()
+        actual = CachedAsyncTokenCredential(credential=credential)
+
+        await actual.close()
+        credential.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_get_token(self):
+        credential = mock_token_credentials()
+        actual = CachedAsyncTokenCredential(credential=credential)
+
+        await actual.get_token()
+        credential.get_token.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_get_token_info(self):
+        credential = mock_token_credentials()
+        actual = CachedAsyncTokenCredential(credential=credential)
+
+        await actual.get_token_info()
+        credential.get_token_info.assert_called_once()
+
+
 class TestKiotaRequestAdapterHook:
     def test_get_conn(self):
         with patch_hook():
@@ -75,35 +110,6 @@ class TestKiotaRequestAdapterHook:
             assert isinstance(actual, HttpxRequestAdapter)
             assert actual.base_url == "https://graph.microsoft.com/v1.0/";
 
-    @classmethod
-    def mock_authentication_provider_never_opened(self) -> 
AuthenticationProvider:
-        """Return an auth provider whose transport has never been opened 
(session is None)."""
-        transport = Mock(spec=AioHttpTransport, _has_been_opened=False)
-        transport.session = None
-        pipeline = Mock(spec=AsyncPipelineClient)
-        pipeline._transport = transport
-        client = Mock(spec=AadClient)
-        client._pipeline = pipeline
-        credentials = Mock(spec=AsyncTokenCredential)
-        credentials._client = client
-        access_token_provider = Mock(spec=AzureIdentityAccessTokenProvider)
-        access_token_provider._credentials = credentials
-        return 
BaseBearerTokenAuthenticationProvider(access_token_provider=access_token_provider)
-
-    @classmethod
-    def mock_authentication_provider(self, closed: bool) -> 
AuthenticationProvider:
-        transport = Mock(spec=AioHttpTransport, _has_been_opened=not closed)
-        transport.session = Mock(spec=ClientSession, closed=closed)
-        pipeline = Mock(spec=AsyncPipelineClient)
-        pipeline._transport = transport
-        client = Mock(spec=AadClient)
-        client._pipeline = pipeline
-        credentials = Mock(spec=AsyncTokenCredential)
-        credentials._client = client
-        access_token_provider = Mock(spec=AzureIdentityAccessTokenProvider)
-        access_token_provider._credentials = credentials
-        return 
BaseBearerTokenAuthenticationProvider(access_token_provider=access_token_provider)
-
     @pytest.mark.asyncio
     async def test_get_async_conn(self):
         with patch_hook():
@@ -596,7 +602,7 @@ class TestKiotaRequestAdapterHook:
             hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
             stale_adapter = Mock(spec=HttpxRequestAdapter)
             stale_adapter._http_client = Mock(spec=AsyncClient, 
is_closed=False)
-            stale_adapter._authentication_provider = 
self.mock_authentication_provider(closed=True)
+            stale_adapter._authentication_provider = 
mock_authentication_provider(closed=True)
             hook.cached_request_adapters[hook.conn_id] = (hook.api_version, 
stale_adapter)
 
             fresh_adapter = Mock(spec=HttpxRequestAdapter)
@@ -615,7 +621,7 @@ class TestKiotaRequestAdapterHook:
             hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
             adapter = Mock(spec=HttpxRequestAdapter)
             adapter._http_client = Mock(spec=AsyncClient, is_closed=False)
-            adapter._authentication_provider = 
self.mock_authentication_provider_never_opened()
+            adapter._authentication_provider = mock_authentication_provider()
             hook.cached_request_adapters[hook.conn_id] = (hook.api_version, 
adapter)
 
             result = await hook.get_async_conn()
@@ -630,7 +636,7 @@ class TestKiotaRequestAdapterHook:
 
             adapter = Mock(spec=HttpxRequestAdapter)
             adapter._http_client = Mock(spec=AsyncClient, is_closed=False)
-            adapter._authentication_provider = 
self.mock_authentication_provider(closed=False)
+            adapter._authentication_provider = 
mock_authentication_provider(closed=False)
             adapter.send_no_response_content_async = 
AsyncMock(side_effect=RuntimeError("some error"))
             hook.cached_request_adapters[hook.conn_id] = (hook.api_version, 
adapter)
 
diff --git a/providers/microsoft/azure/tests/unit/microsoft/azure/test_utils.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/test_utils.py
index 590aea1ac45..bc8577ba9aa 100644
--- a/providers/microsoft/azure/tests/unit/microsoft/azure/test_utils.py
+++ b/providers/microsoft/azure/tests/unit/microsoft/azure/test_utils.py
@@ -22,10 +22,17 @@ from contextlib import ExitStack, contextmanager
 from json import JSONDecodeError
 from typing import Any
 from unittest import mock
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
 
 import pytest
+from aiohttp import ClientSession
+from azure.core import AsyncPipelineClient
+from azure.core.credentials_async import AsyncTokenCredential
+from azure.core.pipeline.transport._aiohttp import AioHttpTransport
+from azure.identity.aio._internal import AadClient
 from httpx import Headers, Response
+from kiota_abstractions.authentication import AuthenticationProvider, 
BaseBearerTokenAuthenticationProvider
+from kiota_authentication_azure.azure_identity_access_token_provider import 
AzureIdentityAccessTokenProvider
 from kiota_http.httpx_request_adapter import HttpxRequestAdapter
 from msgraph_core import APIVersion
 
@@ -35,7 +42,6 @@ from airflow.providers.microsoft.azure.utils import (
     add_managed_identity_connection_widgets,
     get_async_default_azure_credential,
     get_field,
-    # _get_default_azure_credential
     get_sync_default_azure_credential,
     parse_blob_account_url,
 )
@@ -269,3 +275,27 @@ def patch_hook_and_request_adapter(response):
                 mock_get_http_response.return_value = response
 
             yield [*hook_mocks, mock_get_http_response]
+
+
+def mock_token_credentials(closed: bool | None = None):
+    _has_been_opened = False if closed is None else not closed
+    transport = Mock(spec=AioHttpTransport, _has_been_opened=_has_been_opened)
+    transport.session = None if closed is None else Mock(spec=ClientSession, 
closed=closed)
+    pipeline = Mock(spec=AsyncPipelineClient)
+    pipeline._transport = transport
+    client = Mock(spec=AadClient)
+    client._pipeline = pipeline
+    credentials = Mock(spec=AsyncTokenCredential)
+    credentials._client = client
+    credentials.get_token_info = AsyncMock()
+    return credentials
+
+
+def mock_authentication_provider(closed: bool | None = None) -> 
AuthenticationProvider:
+    from airflow.providers.microsoft.azure.hooks.msgraph import 
CachedAsyncTokenCredential
+
+    access_token_provider = Mock(spec=AzureIdentityAccessTokenProvider)
+    access_token_provider._credentials = CachedAsyncTokenCredential(
+        credential=mock_token_credentials(closed=closed)
+    )
+    return 
BaseBearerTokenAuthenticationProvider(access_token_provider=access_token_provider)

Reply via email to