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 12bc0146bb7 Invalidate cached Microsoft Graph request adapter on 401 
Unauthorized (#72688)
12bc0146bb7 is described below

commit 12bc0146bb795029830e10944be05fdfdbaca4b3
Author: David Blain <[email protected]>
AuthorDate: Tue Sep 8 17:35:02 2026 +0200

    Invalidate cached Microsoft Graph request adapter on 401 Unauthorized 
(#72688)
    
    * Invalidate cached Microsoft Graph request adapter on 401 Unauthorized
    
    * Close cached MSGraph request adapter and credential on request failure
---
 .../providers/microsoft/azure/hooks/msgraph.py     | 22 ++++++-
 .../unit/microsoft/azure/hooks/test_msgraph.py     | 69 +++++++++++++++++++++-
 2 files changed, 88 insertions(+), 3 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 db736169bc3..b81f1aacba5 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
@@ -171,6 +171,8 @@ class DefaultResponseHandler(ResponseHandler):
             status_code = HTTPStatus(resp.status_code)
             if status_code == HTTPStatus.BAD_REQUEST:
                 raise AirflowBadRequest(message)
+            if status_code == HTTPStatus.UNAUTHORIZED:
+                raise PermissionError(message)
             if status_code == HTTPStatus.NOT_FOUND:
                 raise AirflowNotFoundException(message)
             raise AirflowException(message)
@@ -720,15 +722,31 @@ class KiotaRequestAdapterHook(BaseHook):
                 request_info=request_info,
                 error_map=self.error_mapping(),
             )
-        except (RuntimeError, ValueError) as e:
+        except (PermissionError, RuntimeError, ValueError) as e:
             self.log.warning(
                 "Request failed for conn_id '%s': %s. Invalidating cached 
request adapter.",
                 self.conn_id,
                 e,
             )
-            self.cached_request_adapters.pop(self.conn_id, None)
+            await self.close()
             raise
 
+    async def close(self) -> None:
+        """Close the request adapter cached for this connection and evict it 
from the cache."""
+        _, request_adapter = self.cached_request_adapters.pop(self.conn_id, 
(None, None))
+
+        if not request_adapter:
+            return
+
+        try:
+            adapter = cast("HttpxRequestAdapter", request_adapter)
+            await adapter._http_client.aclose()
+        finally:
+            provider = cast("BaseBearerTokenAuthenticationProvider", 
adapter._authentication_provider)
+            access_token_provider = cast("AzureIdentityAccessTokenProvider", 
provider.access_token_provider)
+            credential = cast("CachedAsyncTokenCredential", 
access_token_provider._credentials)
+            await credential._credential.close()
+
     def request_information(
         self,
         url: str,
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 b505055ace4..0a8dead772b 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
@@ -679,7 +679,7 @@ class TestKiotaRequestAdapterHook:
 
     @pytest.mark.asyncio
     async def 
test_send_request_invalidates_cache_and_raises_on_any_error(self):
-        """send_request evicts the cached adapter and re-raises on any request 
error."""
+        """send_request evicts the cached adapter, closes it, and re-raises on 
any request error."""
         with patch_hook():
             hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
 
@@ -690,11 +690,72 @@ class TestKiotaRequestAdapterHook:
             adapter.send_no_response_content_async = 
AsyncMock(side_effect=RuntimeError("some error"))
             hook.cached_request_adapters[hook.conn_id] = (hook.api_version, 
adapter)
 
+            access_token_provider = 
adapter._authentication_provider.access_token_provider
+            credential = access_token_provider._credentials._credential
+
             with pytest.raises(RuntimeError, match="some error"):
                 await hook.run(url="users")
 
             adapter.send_no_response_content_async.assert_called_once()
             assert hook.conn_id not in hook.cached_request_adapters
+            adapter._http_client.aclose.assert_awaited_once()
+            credential.close.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def 
test_send_request_invalidates_cache_and_raises_on_unauthorized(self):
+        """send_request evicts the cached adapter, closes it, and re-raises 
when Microsoft Graph returns 401."""
+        with patch_hook():
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            adapter = Mock(spec=HttpxRequestAdapter)
+            adapter._http_client = Mock(spec=AsyncClient, is_closed=False)
+            adapter._authentication_provider = 
mock_authentication_provider(closed=False)
+            adapter.base_url = "https://graph.microsoft.com/v1.0";
+            adapter.send_no_response_content_async = AsyncMock(
+                side_effect=PermissionError("401 Unauthorized")
+            )
+            hook.cached_request_adapters[hook.conn_id] = (hook.api_version, 
adapter)
+
+            access_token_provider = 
adapter._authentication_provider.access_token_provider
+            credential = access_token_provider._credentials._credential
+
+            with pytest.raises(PermissionError, match="401 Unauthorized"):
+                await hook.run(url="users")
+
+            adapter.send_no_response_content_async.assert_called_once()
+            assert hook.conn_id not in hook.cached_request_adapters
+            adapter._http_client.aclose.assert_awaited_once()
+            credential.close.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_close_closes_http_client_and_credential(self):
+        """close() closes the cached HTTP client and the underlying 
credential, then evicts the cache."""
+        with patch_hook():
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            adapter = Mock(spec=HttpxRequestAdapter)
+            adapter._http_client = Mock(spec=AsyncClient, is_closed=False)
+            adapter._authentication_provider = 
mock_authentication_provider(closed=False)
+            hook.cached_request_adapters[hook.conn_id] = (hook.api_version, 
adapter)
+
+            access_token_provider = 
adapter._authentication_provider.access_token_provider
+            credential = access_token_provider._credentials._credential
+
+            await hook.close()
+
+            adapter._http_client.aclose.assert_awaited_once()
+            credential.close.assert_awaited_once()
+            assert hook.conn_id not in hook.cached_request_adapters
+
+    @pytest.mark.asyncio
+    async def test_close_is_a_no_op_when_nothing_is_cached(self):
+        """close() does nothing when there is no cached request adapter for 
the conn_id."""
+        with patch_hook():
+            hook = KiotaRequestAdapterHook(conn_id="msgraph_api")
+
+            await hook.close()
+
+            assert hook.conn_id not in hook.cached_request_adapters
 
     def test_allowed_hosts_is_empty_list_when_not_configured(self):
         """An unset allowed_hosts/authority must yield []."""
@@ -842,6 +903,12 @@ class TestResponseHandler:
         with pytest.raises(AirflowBadRequest):
             
asyncio.run(DefaultResponseHandler().handle_response_async(response, None))
 
+    def test_handle_response_async_when_unauthorized(self):
+        response = mock_json_response(401, {})
+
+        with pytest.raises(PermissionError):
+            
asyncio.run(DefaultResponseHandler().handle_response_async(response, None))
+
     def test_handle_response_async_when_not_found(self):
         response = mock_json_response(404, {})
 

Reply via email to