diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 23a0eb6ebb63..4ef02106acf0 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -156,6 +156,8 @@ "spinup", "cibuildwheel", "aoai", + "aiotest", + "aiotests", "pyprojects", "certifi", "cffi", diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index b8f7e4dbe1ff..83eaa3394cbe 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -1,15 +1,15 @@ # Release History -## 1.35.0 (Unreleased) +## 1.35.0 (2025-06-05) ### Features Added - Added a `start_time` keyword argument to the `start_span` and `start_as_current_span` methods in the `OpenTelemetryTracer` class. This allows users to specify a custom start time for created spans. #41106 -### Breaking Changes - ### Bugs Fixed +- Reduce risk of hanging while closing aiohttp transport if server does not follow best practices. Fix #41363 + ### Other Changes - A timeout error when using the `aiohttp` transport (the default for async SDKs) will now be raised as a `azure.core.exceptions.ServiceResponseTimeoutError`, a subtype of the previously raised `ServiceResponseError`. diff --git a/sdk/core/azure-core/azure/core/pipeline/transport/_aiohttp.py b/sdk/core/azure-core/azure/core/pipeline/transport/_aiohttp.py index 922f08d1995f..4a7ab3054678 100644 --- a/sdk/core/azure-core/azure/core/pipeline/transport/_aiohttp.py +++ b/sdk/core/azure-core/azure/core/pipeline/transport/_aiohttp.py @@ -168,7 +168,10 @@ async def open(self): async def close(self): """Closes the connection.""" if self._session_owner and self.session: - await self.session.close() + try: + await asyncio.wait_for(self.session.close(), timeout=0.0001) # close immediately + except (asyncio.TimeoutError, TimeoutError): + pass self.session = None def _build_ssl_config(self, cert, verify): diff --git a/sdk/core/azure-core/tests/async_tests/test_aiohttp_blob_download_live_async.py b/sdk/core/azure-core/tests/async_tests/test_aiohttp_blob_download_live_async.py new file mode 100644 index 000000000000..028134a927fd --- /dev/null +++ b/sdk/core/azure-core/tests/async_tests/test_aiohttp_blob_download_live_async.py @@ -0,0 +1,33 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +import sys +import pytest +import logging +from azure.storage.blob.aio import BlobServiceClient + + +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="ssl_shutdown_timeout in aiohttp only takes effect on Python 3.11+" +) +@pytest.mark.live_test_only +@pytest.mark.asyncio +async def test_download_blob_aiohttp(caplog): + logger = logging.getLogger(__name__) + AZURE_STORAGE_CONTAINER_NAME = "aiotests" + account_url = "https://aiotests.blob.core.windows.net" + blob_service_client = BlobServiceClient(account_url=account_url) + read_path = "aiotest.txt" + + with caplog.at_level(logging.INFO): + async with blob_service_client: + blob_client = blob_service_client.get_blob_client(container=AZURE_STORAGE_CONTAINER_NAME, blob=read_path) + + async with blob_client: + stream = await blob_client.download_blob() + data = await stream.readall() + logger.info(f"Blob size: {len(data)}") + + assert all("Error" not in message for message in caplog.messages) diff --git a/sdk/core/azure-core/tests/async_tests/test_aiohttp_close_timeout.py b/sdk/core/azure-core/tests/async_tests/test_aiohttp_close_timeout.py new file mode 100644 index 000000000000..74b456ec51d5 --- /dev/null +++ b/sdk/core/azure-core/tests/async_tests/test_aiohttp_close_timeout.py @@ -0,0 +1,58 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE.txt in the project root for +# license information. +# ------------------------------------------------------------------------- +import asyncio +import time +import pytest +import types +from unittest.mock import Mock, patch + +from azure.core.pipeline.transport import ( + AioHttpTransport, +) + + +class MockClientSession: + """Mock aiohttp.ClientSession with a close method that sleeps for 30 seconds.""" + + def __init__(self): + self._closed = False + + async def __aenter__(self): + return self + + async def __aexit__(self, *args, **kwargs): + pass + + async def close(self): + """Simulate a slow closing session.""" + self._closed = True + await asyncio.sleep(30) # Simulate a very slow close operation + + def request(self, *args, **kwargs): + return asyncio.Future() # Just needs to be awaitable + + +@pytest.mark.asyncio +async def test_aiohttp_transport_close_timeout(): + """Test that AioHttpTransport.close() returns within 1 second even if session.close() takes 30 seconds.""" + + # Create transport with our mock session + mock_session = MockClientSession() + transport = AioHttpTransport(session=mock_session, session_owner=True) + + # Open the transport to initialize the session + await transport.open() + + # Time the close operation + start_time = time.time() + await transport.close() + end_time = time.time() + + # Verify close returned in a reasonable time (should be around 0.0001 seconds due to timeout) + assert end_time - start_time < 0.1, f"Transport close took {end_time - start_time} seconds, should be < 0.1 seconds" + + # Ensure transport's session was set to None + assert transport.session is None