From 13229a0422ab3fd43692143164e2e13a7da55865 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 9 Jun 2026 13:53:54 +0800 Subject: [PATCH 01/24] Fix DashScopeAPIResponse missing attribute handling --- dashscope/api_entities/dashscope_response.py | 7 ++- tests/unit/test_dashscope_response.py | 21 +++++++ tests/unit/test_oss_utils.py | 59 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_dashscope_response.py create mode 100644 tests/unit/test_oss_utils.py diff --git a/dashscope/api_entities/dashscope_response.py b/dashscope/api_entities/dashscope_response.py index 71f16b8..58ade6b 100644 --- a/dashscope/api_entities/dashscope_response.py +++ b/dashscope/api_entities/dashscope_response.py @@ -59,7 +59,12 @@ def setattr(self, attr, value): return super().__setitem__(attr, value) def __getattr__(self, attr): - return self[attr] + try: + return self[attr] + except KeyError: + raise AttributeError( + f"{type(self).__name__!r} object has no attribute {attr!r}", + ) from None def __setattr__(self, attr, value): self[attr] = value diff --git a/tests/unit/test_dashscope_response.py b/tests/unit/test_dashscope_response.py new file mode 100644 index 0000000..ed7a135 --- /dev/null +++ b/tests/unit/test_dashscope_response.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from dashscope.api_entities.dashscope_response import DictMixin + + +class TestDictMixin: + def test_getattr_missing_key_raises_attribute_error(self): + response = DictMixin(existing="value") + + try: + response.missing + except AttributeError: + return + + raise AssertionError("Missing attribute should raise AttributeError") + + def test_getattr_existing_key_returns_value(self): + response = DictMixin(existing="value") + + assert response.existing == "value" diff --git a/tests/unit/test_oss_utils.py b/tests/unit/test_oss_utils.py new file mode 100644 index 0000000..765f3b7 --- /dev/null +++ b/tests/unit/test_oss_utils.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from http import HTTPStatus + +from dashscope.utils import oss_utils +from dashscope.utils.oss_utils import OssUtils + + +class FakeUploadResponse: + status_code = HTTPStatus.OK + headers = {} + + +class FakeSession: + captured_file = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return False + + def post(self, url, files, data, headers, timeout): + assert url == "https://oss.example.com" + assert data["key"] == "test-dir/dogs.jpg" + assert headers["Accept"] == "application/json" + assert timeout == 3600 + + FakeSession.captured_file = files["file"] + assert not FakeSession.captured_file.closed + return FakeUploadResponse() + + +class TestOssUtils: + def test_upload_closes_opened_file(self, monkeypatch): + upload_certificate = { + "oss_access_key_id": "access-key-id", + "signature": "signature", + "policy": "policy", + "upload_dir": "test-dir", + "x_oss_object_acl": "private", + "x_oss_forbid_overwrite": "true", + "upload_host": "https://oss.example.com", + } + FakeSession.captured_file = None + monkeypatch.setattr(oss_utils.requests, "Session", FakeSession) + + file_url, returned_certificate = OssUtils.upload( + model="test-model", + file_path="tests/data/dogs.jpg", + api_key="test-api-key", + upload_certificate=upload_certificate, + ) + + assert file_url == "oss://test-dir/dogs.jpg" + assert returned_certificate is upload_certificate + assert FakeSession.captured_file is not None + assert FakeSession.captured_file.closed From b3f331b6f24489b54e4abc8819f6f5d3c902f445 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 9 Jun 2026 15:07:27 +0800 Subject: [PATCH 02/24] feat: add AioTextReRank async API --- dashscope/__init__.py | 3 +- dashscope/rerank/__init__.py | 6 ++ dashscope/rerank/text_rerank.py | 135 ++++++++++++++++++++++++++------ tests/unit/test_rerank.py | 61 ++++++++++++++- 4 files changed, 181 insertions(+), 24 deletions(-) diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 637d5de..92cd259 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -46,7 +46,7 @@ from dashscope.files import Files from dashscope.models import Models from dashscope.nlp.understanding import Understanding -from dashscope.rerank.text_rerank import TextReRank +from dashscope.rerank import AioTextReRank, TextReRank from dashscope.threads import ( MessageFile, Messages, @@ -106,6 +106,7 @@ "list_tokenizers", "Application", "TextReRank", + "AioTextReRank", "Assistants", "Threads", "Messages", diff --git a/dashscope/rerank/__init__.py b/dashscope/rerank/__init__.py index e69de29..2f0829c 100644 --- a/dashscope/rerank/__init__.py +++ b/dashscope/rerank/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from dashscope.rerank.text_rerank import AioTextReRank, TextReRank + +__all__ = ["AioTextReRank", "TextReRank"] diff --git a/dashscope/rerank/text_rerank.py b/dashscope/rerank/text_rerank.py index b152ae6..3a4b31e 100644 --- a/dashscope/rerank/text_rerank.py +++ b/dashscope/rerank/text_rerank.py @@ -1,13 +1,46 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from typing import List +from typing import Any, Dict, List, Tuple from dashscope.api_entities.dashscope_response import ReRankResponse -from dashscope.client.base_api import BaseApi +from dashscope.client.base_api import BaseAioApi, BaseApi from dashscope.common.error import InputRequired, ModelRequired from dashscope.common.utils import _get_task_group_and_task +__all__ = ["TextReRank", "AioTextReRank"] + + +def _build_rerank_request( + model: str, + query: str, + documents: List[str], + return_documents: bool = None, + top_n: int = None, + instruct: str = None, + **kwargs, +) -> Tuple[str, str, Dict[str, Any], Dict[str, Any]]: + if query is None or documents is None or not documents: + raise InputRequired("query and documents are required!") + if model is None or not model: + raise ModelRequired("Model is required!") + + task_group, function = _get_task_group_and_task(__name__) + rerank_input = { + "query": query, + "documents": documents, + } + parameters = {} + if return_documents is not None: + parameters["return_documents"] = return_documents + if top_n is not None: + parameters["top_n"] = top_n + if instruct is not None: + parameters["instruct"] = instruct + parameters = {**parameters, **kwargs} + + return task_group, function, rerank_input, parameters + class TextReRank(BaseApi): task = "text-rerank" @@ -41,8 +74,8 @@ def call( # type: ignore[override] # pylint: disable=arguments-renamed documents (List[str]): The documents to rank. return_documents(bool, `optional`): enable return origin documents, system default is false. - top_n(int, `optional`): how many documents to return, default return # noqa: E501 - all the documents. + top_n(int, `optional`): how many documents to return, + default return all the documents. api_key (str, optional): The DashScope api key. Defaults to None. instruct (str, optional): Custom task instruction to guide ranking strategy. English recommended. @@ -55,23 +88,15 @@ def call( # type: ignore[override] # pylint: disable=arguments-renamed RerankResponse: The rerank result. """ - if query is None or documents is None or not documents: - raise InputRequired("query and documents are required!") - if model is None or not model: - raise ModelRequired("Model is required!") - task_group, function = _get_task_group_and_task(__name__) - input = { # pylint: disable=redefined-builtin - "query": query, - "documents": documents, - } - parameters = {} - if return_documents is not None: - parameters["return_documents"] = return_documents - if top_n is not None: - parameters["top_n"] = top_n - if instruct is not None: - parameters["instruct"] = instruct - parameters = {**parameters, **kwargs} + task_group, function, rerank_input, parameters = _build_rerank_request( + model=model, + query=query, + documents=documents, + return_documents=return_documents, + top_n=top_n, + instruct=instruct, + **kwargs, + ) response = super().call( model=model, @@ -79,7 +104,73 @@ def call( # type: ignore[override] # pylint: disable=arguments-renamed task=TextReRank.task, function=function, api_key=api_key, - input=input, + input=rerank_input, + **parameters, # type: ignore[arg-type] + ) + + return ReRankResponse.from_api_response(response) + + +class AioTextReRank(BaseAioApi): + task = "text-rerank" + """Async API for rerank models.""" + + Models = TextReRank.Models + + @classmethod + # pylint: disable=arguments-renamed + async def call( # type: ignore[override] + cls, + model: str, + query: str, + documents: List[str], + return_documents: bool = None, + top_n: int = None, + api_key: str = None, + workspace: str = None, + instruct: str = None, + **kwargs, + ) -> ReRankResponse: + """Calling rerank service asynchronously. + + Args: + model (str): The model to use. + query (str): The query string. + documents (List[str]): The documents to rank. + return_documents(bool, `optional`): enable return origin documents, + system default is false. + top_n(int, `optional`): how many documents to return, + default return all the documents. + api_key (str, optional): The DashScope api key. Defaults to None. + workspace (str, optional): The DashScope workspace id. + instruct (str, optional): Custom task instruction to guide + ranking strategy. English recommended. + + Raises: + InputRequired: The query and documents are required. + ModelRequired: The model is required. + + Returns: + RerankResponse: The rerank result. + """ + task_group, function, rerank_input, parameters = _build_rerank_request( + model=model, + query=query, + documents=documents, + return_documents=return_documents, + top_n=top_n, + instruct=instruct, + **kwargs, + ) + + response = await super().call( + model=model, + task_group=task_group, + task=AioTextReRank.task, + function=function, + api_key=api_key, + workspace=workspace, + input=rerank_input, **parameters, # type: ignore[arg-type] ) diff --git a/tests/unit/test_rerank.py b/tests/unit/test_rerank.py index d2afd34..b742794 100644 --- a/tests/unit/test_rerank.py +++ b/tests/unit/test_rerank.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. +import asyncio import json import uuid -from dashscope import TextReRank +from dashscope import AioTextReRank, TextReRank from tests.unit.mock_request_base import MockServerBase from tests.unit.mock_server import MockServer @@ -62,3 +63,61 @@ def test_call(self, mock_server: MockServer): assert len(response.output["results"]) == 2 assert response.output["results"][0]["index"] == 1 assert response.output["results"][1]["document"]["text"] == "黑龙江离俄罗斯很近" + + def test_aio_call(self, mock_server: MockServer): + response_body = { + "output": { + "results": [ + { + "index": 1, + "relevance_score": 0.987654, + "document": { + "text": "哈尔滨是中国黑龙江省的省会,位于中国东北", + }, + }, + { + "index": 0, + "relevance_score": 0.876543, + "document": { + "text": "黑龙江离俄罗斯很近", + }, + }, + ], + }, + "usage": { + "input_tokens": 1279, + }, + "request_id": "b042e72d-7994-97dd-b3d2-7ee7e0140525", + } + mock_server.responses.put(json.dumps(response_body)) + model = str(uuid.uuid4()) + query = str(uuid.uuid4()) + documents = [ + str(uuid.uuid4()), + str(uuid.uuid4()), + str(uuid.uuid4()), + str(uuid.uuid4()), + ] + + response = asyncio.run( + AioTextReRank.call( + model=model, + query=query, + documents=documents, + return_documents=True, + top_n=2, + instruct="Rank the documents by relevance.", + ), + ) + + req = mock_server.requests.get(block=True) + assert req["path"] == "/api/v1/services/rerank/text-rerank/text-rerank" + assert req["body"]["parameters"] == { + "return_documents": True, + "top_n": 2, + "instruct": "Rank the documents by relevance.", + } + assert req["body"]["input"] == {"query": query, "documents": documents} + assert response.usage["input_tokens"] == 1279 + assert len(response.output["results"]) == 2 + assert response.output["results"][0]["index"] == 1 From 2676efd9902899885a7ae9c3672c6388f580b98e Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 9 Jun 2026 16:16:48 +0800 Subject: [PATCH 03/24] fix: add timeout guard for async task polling --- dashscope/client/base_api.py | 37 ++++- tests/unit/test_async_task_wait_timeout.py | 164 +++++++++++++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_async_task_wait_timeout.py diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index cc52fa2..3f8bde1 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -20,7 +20,12 @@ TaskStatus, HTTPMethod, ) -from dashscope.common.error import InvalidParameter, InvalidTask, ModelRequired +from dashscope.common.error import ( + InvalidParameter, + InvalidTask, + ModelRequired, + TimeoutException, +) from dashscope.common.logging import logger from dashscope.common.utils import ( _handle_http_failed_response, @@ -148,6 +153,7 @@ async def call( workspace: str = None, **kwargs, ) -> DashScopeAPIResponse: + wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) # call request service. response = await BaseAsyncAioApi.async_call( model, @@ -163,6 +169,7 @@ async def call( response, api_key=api_key, workspace=workspace, + wait_timeout_seconds=wait_timeout_seconds, **kwargs, ) return response @@ -202,6 +209,8 @@ async def wait( Returns: DashScopeAPIResponse: The async task information. """ + wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) + start_time = time.monotonic() task_id = cls._get_task_id(task) wait_seconds = 1 max_wait_seconds = 5 @@ -236,6 +245,12 @@ async def wait( return rsp else: logger.info("The task %s is %s", task_id, task_status) + if ( + wait_timeout_seconds is not None + and time.monotonic() - start_time + >= wait_timeout_seconds + ): + raise TimeoutException(f"Wait task {task_id} timeout.") await asyncio.sleep(wait_seconds) # 异步等待 elif rsp.status_code in REPEATABLE_STATUS: logger.warning( @@ -246,6 +261,11 @@ async def wait( rsp.code, rsp.message, ) + if ( + wait_timeout_seconds is not None + and time.monotonic() - start_time >= wait_timeout_seconds + ): + raise TimeoutException(f"Wait task {task_id} timeout.") await asyncio.sleep(wait_seconds) # 异步等待 else: return rsp @@ -599,6 +619,7 @@ def call( **kwargs, ) -> DashScopeAPIResponse: """Call service and get result.""" + wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) task_response = cls.async_call( # type: ignore[misc] *args, api_key=api_key, @@ -609,6 +630,7 @@ def call( task_response, api_key=api_key, workspace=workspace, + wait_timeout_seconds=wait_timeout_seconds, ) return response @@ -778,6 +800,8 @@ def wait( Returns: DashScopeAPIResponse: The async task information. """ + wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) + start_time = time.monotonic() task_id = cls._get_task_id(task) wait_seconds = 1 max_wait_seconds = 5 @@ -808,6 +832,12 @@ def wait( return rsp else: logger.info("The task %s is %s", task_id, task_status) + if ( + wait_timeout_seconds is not None + and time.monotonic() - start_time + >= wait_timeout_seconds + ): + raise TimeoutException(f"Wait task {task_id} timeout.") time.sleep(wait_seconds) elif rsp.status_code in REPEATABLE_STATUS: logger.warning( @@ -818,6 +848,11 @@ def wait( rsp.code, rsp.message, ) + if ( + wait_timeout_seconds is not None + and time.monotonic() - start_time >= wait_timeout_seconds + ): + raise TimeoutException(f"Wait task {task_id} timeout.") time.sleep(wait_seconds) else: return rsp diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py new file mode 100644 index 0000000..8f18152 --- /dev/null +++ b/tests/unit/test_async_task_wait_timeout.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest + +from dashscope.api_entities.dashscope_response import DashScopeAPIResponse +from dashscope.client.base_api import BaseAsyncAioApi, BaseAsyncApi +from dashscope.common.constants import TaskStatus +from dashscope.common.error import TimeoutException + + +class TimeoutWaitTestAsyncApi(BaseAsyncApi): + pass + + +class TimeoutCallTestAsyncApi(BaseAsyncApi): + captured_async_call_kwargs = {} + captured_wait_kwargs = {} + + @classmethod + def async_call(cls, *_args, **kwargs): + cls.captured_async_call_kwargs = kwargs + return DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_id": "task-id"}, + usage=None, + message="", + ) + + @classmethod + def wait(cls, task, api_key=None, workspace=None, **kwargs): + cls.captured_wait_kwargs = kwargs + return task + + +class TimeoutTestAsyncAioApi(BaseAsyncAioApi): + pass + + +@pytest.fixture(autouse=True) +def reset_timeout_test_api(): + TimeoutCallTestAsyncApi.captured_async_call_kwargs = {} + TimeoutCallTestAsyncApi.captured_wait_kwargs = {} + + +class TestAsyncTaskWaitTimeout: + def test_base_async_api_wait_raises_timeout(self): + response = DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_status": TaskStatus.RUNNING}, + usage=None, + message="", + ) + + with patch.object( + TimeoutWaitTestAsyncApi, + "_get", + return_value=response, + ): + with pytest.raises(TimeoutException): + TimeoutWaitTestAsyncApi.wait("task-id", wait_timeout_seconds=0) + + @pytest.mark.asyncio + async def test_base_async_aio_api_wait_raises_timeout(self): + response = DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_status": TaskStatus.RUNNING}, + usage=None, + message="", + ) + + with patch.object( + TimeoutTestAsyncAioApi, + "_get", + AsyncMock(return_value=response), + ): + with pytest.raises(TimeoutException): + await TimeoutTestAsyncAioApi.wait( + "task-id", + wait_timeout_seconds=0, + ) + + def test_base_async_call_excludes_wait_timeout_from_request( + self, + ): + response = TimeoutCallTestAsyncApi.call( + "model", + "input", + api_key="api-key", + wait_timeout_seconds=10, + custom_param="custom-value", + ) + + assert response.output["task_id"] == "task-id" + assert ( + "wait_timeout_seconds" + not in TimeoutCallTestAsyncApi.captured_async_call_kwargs + ) + assert ( + TimeoutCallTestAsyncApi.captured_async_call_kwargs["custom_param"] + == "custom-value" + ) + assert ( + TimeoutCallTestAsyncApi.captured_wait_kwargs[ + "wait_timeout_seconds" + ] + == 10 + ) + + @pytest.mark.asyncio + async def test_base_async_aio_call_excludes_wait_timeout_from_request( + self, + ): + async_call_response = DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_id": "task-id"}, + usage=None, + message="", + ) + wait_response = DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_status": TaskStatus.SUCCEEDED}, + usage=None, + message="", + ) + + with patch.object( + BaseAsyncAioApi, + "async_call", + AsyncMock(return_value=async_call_response), + ) as async_call_mock: + with patch.object( + BaseAsyncAioApi, + "wait", + AsyncMock(return_value=wait_response), + ) as wait_mock: + response = await BaseAsyncAioApi.call( + "model", + "input", + "task-group", + api_key="api-key", + wait_timeout_seconds=10, + custom_param="custom-value", + ) + + assert response is wait_response + assert "wait_timeout_seconds" not in async_call_mock.call_args.kwargs + assert ( + async_call_mock.call_args.kwargs["custom_param"] == "custom-value" + ) + assert wait_mock.call_args.kwargs["wait_timeout_seconds"] == 10 From 151436200c8307d5f4fa23b1f00065d9d0104267 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 9 Jun 2026 16:46:24 +0800 Subject: [PATCH 04/24] fix: support Windows file URI upload for multimodal inputs --- dashscope/utils/oss_utils.py | 34 +++++++++----- tests/unit/test_oss_utils.py | 88 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/dashscope/utils/oss_utils.py b/dashscope/utils/oss_utils.py index 216333e..6b5e476 100644 --- a/dashscope/utils/oss_utils.py +++ b/dashscope/utils/oss_utils.py @@ -129,6 +129,23 @@ def get_upload_certificate( return super().get(None, api_key, params=params, **kwargs) # type: ignore[return-value] # pylint: disable=line-too-long # noqa: E501 +def _resolve_file_uri_path(file_uri: str): + parse_result = urlparse(file_uri) + if parse_result.netloc: + file_path = parse_result.netloc + unquote_plus(parse_result.path) + else: + file_path = unquote_plus(parse_result.path) + + if ( + file_path.startswith("/") + and len(file_path) > 2 + and file_path[2] == ":" + ): + file_path = file_path[1:] + + return os.path.expanduser(file_path) + + def upload_file( model: str, upload_path: str, @@ -136,11 +153,7 @@ def upload_file( upload_certificate: dict = None, ): if upload_path.startswith(FILE_PATH_SCHEMA): - parse_result = urlparse(upload_path) - if parse_result.netloc: - file_path = parse_result.netloc + unquote_plus(parse_result.path) - else: - file_path = unquote_plus(parse_result.path) + file_path = _resolve_file_uri_path(upload_path) if os.path.exists(file_path): file_url, _ = OssUtils.upload( model=model, @@ -184,11 +197,7 @@ def check_and_upload_local( is the certificate (newly obtained or passed in) """ if content.startswith(FILE_PATH_SCHEMA): - parse_result = urlparse(content) - if parse_result.netloc: - file_path = parse_result.netloc + unquote_plus(parse_result.path) - else: - file_path = unquote_plus(parse_result.path) + file_path = _resolve_file_uri_path(content) if os.path.isfile(file_path): file_url, cert = OssUtils.upload( model=model, @@ -201,9 +210,10 @@ def check_and_upload_local( f"Uploading file: {content} failed", ) return True, file_url, cert - elif content.startswith("oss://"): + raise InvalidInput(f"The file: {file_path} is not exists!") + if content.startswith("oss://"): return True, content, upload_certificate - elif not content.startswith("http"): + if not content.startswith("http"): content = os.path.expanduser(content) if os.path.isfile(content): file_url, cert = OssUtils.upload( diff --git a/tests/unit/test_oss_utils.py b/tests/unit/test_oss_utils.py index 765f3b7..505d384 100644 --- a/tests/unit/test_oss_utils.py +++ b/tests/unit/test_oss_utils.py @@ -3,6 +3,9 @@ from http import HTTPStatus +import pytest + +from dashscope.common.error import InvalidInput from dashscope.utils import oss_utils from dashscope.utils.oss_utils import OssUtils @@ -57,3 +60,88 @@ def test_upload_closes_opened_file(self, monkeypatch): assert returned_certificate is upload_certificate assert FakeSession.captured_file is not None assert FakeSession.captured_file.closed + + def test_check_and_upload_local_uploads_relative_file_uri( + self, + monkeypatch, + ): + captured_file_path = {} + + def fake_isfile(file_path): + captured_file_path["value"] = file_path + return True + + def fake_upload(model, file_path, api_key, upload_certificate): + assert model == "test-model" + assert api_key == "test-api-key" + assert upload_certificate == {"cert": "value"} + assert file_path == "test_video_frames/frame_0000.jpg" + return "oss://test-dir/frame_0000.jpg", {"cert": "value"} + + monkeypatch.setattr(oss_utils.os.path, "isfile", fake_isfile) + monkeypatch.setattr(OssUtils, "upload", fake_upload) + + is_upload, file_url, certificate = oss_utils.check_and_upload_local( + model="test-model", + content="file://test_video_frames/frame_0000.jpg", + api_key="test-api-key", + upload_certificate={"cert": "value"}, + ) + + assert is_upload + assert file_url == "oss://test-dir/frame_0000.jpg" + assert certificate == {"cert": "value"} + assert ( + captured_file_path["value"] == "test_video_frames/frame_0000.jpg" + ) + + def test_check_and_upload_local_supports_windows_absolute_file_uri( + self, + monkeypatch, + ): + captured_file_path = {} + + def fake_isfile(file_path): + captured_file_path["value"] = file_path + return True + + def fake_upload( + model, + file_path, + api_key, + upload_certificate, + ): + assert model == "test-model" + assert file_path == "C:/Users/test/frame_0000.jpg" + assert api_key == "test-api-key" + return "oss://test-dir/frame_0000.jpg", upload_certificate + + monkeypatch.setattr(oss_utils.os.path, "isfile", fake_isfile) + monkeypatch.setattr(OssUtils, "upload", fake_upload) + + is_upload, file_url, _ = oss_utils.check_and_upload_local( + model="test-model", + content="file:///C:/Users/test/frame_0000.jpg", + api_key="test-api-key", + ) + + assert is_upload + assert file_url == "oss://test-dir/frame_0000.jpg" + assert captured_file_path["value"] == "C:/Users/test/frame_0000.jpg" + + def test_check_and_upload_local_raises_when_file_uri_not_found( + self, + monkeypatch, + ): + monkeypatch.setattr( + oss_utils.os.path, + "isfile", + lambda file_path: False, + ) + + with pytest.raises(InvalidInput): + oss_utils.check_and_upload_local( + model="test-model", + content="file://missing/frame_0000.jpg", + api_key="test-api-key", + ) From 842f1105ce6d1396473898e2ae46e3f5d9315261 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 9 Jun 2026 18:15:02 +0800 Subject: [PATCH 05/24] fix: avoid passing default wait timeout to legacy wait methods --- dashscope/aigc/image_synthesis.py | 8 ++- dashscope/aigc/video_synthesis.py | 8 ++- dashscope/client/base_api.py | 11 +++- dashscope/embeddings/batch_text_embedding.py | 8 ++- tests/unit/test_async_task_wait_timeout.py | 62 ++++++++++++++++++++ 5 files changed, 91 insertions(+), 6 deletions(-) diff --git a/dashscope/aigc/image_synthesis.py b/dashscope/aigc/image_synthesis.py index 41bd060..0491d7e 100644 --- a/dashscope/aigc/image_synthesis.py +++ b/dashscope/aigc/image_synthesis.py @@ -387,6 +387,7 @@ def wait( # type: ignore[override] task: Union[str, ImageSynthesisResponse], api_key: str = None, workspace: str = None, + **kwargs, ) -> ImageSynthesisResponse: """Wait for image(s) synthesis task to complete, and return the result. @@ -399,7 +400,12 @@ def wait( # type: ignore[override] Returns: ImageSynthesisResponse: The task result. """ - response = super().wait(task, api_key, workspace=workspace) + response = super().wait( + task, + api_key, + workspace=workspace, + **kwargs, + ) return ImageSynthesisResponse.from_api_response(response) @classmethod diff --git a/dashscope/aigc/video_synthesis.py b/dashscope/aigc/video_synthesis.py index 0bc5032..b976799 100644 --- a/dashscope/aigc/video_synthesis.py +++ b/dashscope/aigc/video_synthesis.py @@ -509,6 +509,7 @@ def wait( # type: ignore[override] task: Union[str, VideoSynthesisResponse], api_key: str = None, workspace: str = None, + **kwargs, ) -> VideoSynthesisResponse: """Wait for video synthesis task to complete, and return the result. @@ -521,7 +522,12 @@ def wait( # type: ignore[override] Returns: VideoSynthesisResponse: The task result. """ - response = super().wait(task, api_key, workspace=workspace) + response = super().wait( + task, + api_key, + workspace=workspace, + **kwargs, + ) return VideoSynthesisResponse.from_api_response(response) @classmethod diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 3f8bde1..38a0c35 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -165,12 +165,14 @@ async def call( workspace, **kwargs, ) + wait_kwargs = kwargs.copy() + if wait_timeout_seconds is not None: + wait_kwargs["wait_timeout_seconds"] = wait_timeout_seconds response = await BaseAsyncAioApi.wait( response, api_key=api_key, workspace=workspace, - wait_timeout_seconds=wait_timeout_seconds, - **kwargs, + **wait_kwargs, ) return response @@ -626,11 +628,14 @@ def call( workspace=workspace, **kwargs, ) + wait_kwargs = {} + if wait_timeout_seconds is not None: + wait_kwargs["wait_timeout_seconds"] = wait_timeout_seconds response = cls.wait( task_response, api_key=api_key, workspace=workspace, - wait_timeout_seconds=wait_timeout_seconds, + **wait_kwargs, ) return response diff --git a/dashscope/embeddings/batch_text_embedding.py b/dashscope/embeddings/batch_text_embedding.py index d3af2d1..c2c9b9c 100644 --- a/dashscope/embeddings/batch_text_embedding.py +++ b/dashscope/embeddings/batch_text_embedding.py @@ -143,6 +143,7 @@ def wait( # type: ignore[override] task: Union[str, BatchTextEmbeddingResponse], api_key: str = None, workspace: str = None, + **kwargs, ) -> BatchTextEmbeddingResponse: """Wait for async text embedding task to complete, and return the result. # noqa: E501 @@ -155,7 +156,12 @@ def wait( # type: ignore[override] Returns: AsyncTextEmbeddingResponse: The task result. """ - response = super().wait(task, api_key, workspace=workspace) + response = super().wait( + task, + api_key, + workspace=workspace, + **kwargs, + ) return BatchTextEmbeddingResponse.from_api_response(response) @classmethod diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py index 8f18152..f9e4bb8 100644 --- a/tests/unit/test_async_task_wait_timeout.py +++ b/tests/unit/test_async_task_wait_timeout.py @@ -6,8 +6,11 @@ import pytest +from dashscope.aigc.image_synthesis import ImageSynthesis +from dashscope.aigc.video_synthesis import VideoSynthesis from dashscope.api_entities.dashscope_response import DashScopeAPIResponse from dashscope.client.base_api import BaseAsyncAioApi, BaseAsyncApi +from dashscope.embeddings.batch_text_embedding import BatchTextEmbedding from dashscope.common.constants import TaskStatus from dashscope.common.error import TimeoutException @@ -38,6 +41,23 @@ def wait(cls, task, api_key=None, workspace=None, **kwargs): return task +class LegacyWaitSignatureTestAsyncApi(BaseAsyncApi): + @classmethod + def async_call(cls, *_args, **_kwargs): + return DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_id": "task-id"}, + usage=None, + message="", + ) + + @classmethod + def wait(cls, task, api_key=None, workspace=None): + return task + + class TimeoutTestAsyncAioApi(BaseAsyncAioApi): pass @@ -89,6 +109,17 @@ async def test_base_async_aio_api_wait_raises_timeout(self): wait_timeout_seconds=0, ) + def test_base_async_call_does_not_pass_default_wait_timeout( + self, + ): + response = LegacyWaitSignatureTestAsyncApi.call( + "model", + "input", + api_key="api-key", + ) + + assert response.output["task_id"] == "task-id" + def test_base_async_call_excludes_wait_timeout_from_request( self, ): @@ -162,3 +193,34 @@ async def test_base_async_aio_call_excludes_wait_timeout_from_request( async_call_mock.call_args.kwargs["custom_param"] == "custom-value" ) assert wait_mock.call_args.kwargs["wait_timeout_seconds"] == 10 + + @pytest.mark.parametrize( + "api_class", + [ImageSynthesis, VideoSynthesis, BatchTextEmbedding], + ) + def test_overridden_wait_accepts_wait_timeout( + self, + api_class, + ): + wait_response = DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.BAD_REQUEST, + code="InvalidParameter", + output=None, + usage=None, + message="invalid parameter", + ) + + with patch.object( + BaseAsyncApi, + "wait", + return_value=wait_response, + ) as wait_mock: + response = api_class.wait( + "task-id", + api_key="api-key", + wait_timeout_seconds=10, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert wait_mock.call_args.kwargs["wait_timeout_seconds"] == 10 From dbbe5b27505a0508e691b479e42bf7802310ccb5 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 10 Jun 2026 16:28:50 +0800 Subject: [PATCH 06/24] feat: add global trust_env config for aiohttp sessions --- dashscope/__init__.py | 2 + dashscope/api_entities/aiohttp_request.py | 2 + dashscope/api_entities/http_request.py | 2 + dashscope/common/env.py | 12 ++++++ tests/unit/test_async_custom_session.py | 52 +++++++++++++++++++++++ 5 files changed, 70 insertions(+) diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 92cd259..19a32ca 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -28,6 +28,7 @@ base_compatible_api_url, base_http_api_url, base_websocket_api_url, + trust_env, ) from dashscope.finetune.deployments import Deployments from dashscope.finetune.finetunes import FineTunes @@ -74,6 +75,7 @@ "base_websocket_api_url", "api_key", "api_key_file_path", + "trust_env", "save_api_key", "AioGeneration", "Conversation", diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index 75b3965..275115f 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -13,6 +13,7 @@ SSE_CONTENT_TYPE, HTTPMethod, ) +from dashscope.common.env import get_trust_env from dashscope.common.error import UnsupportedHTTPMethod from dashscope.common.logging import logger from dashscope.common.utils import async_to_sync @@ -249,6 +250,7 @@ async def _handle_request(self): async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=self.timeout), headers=self.headers, + trust_env=get_trust_env(), ) as session: logger.debug("Starting request: %s", self.url) if self.method == HTTPMethod.POST: diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 85ee959..8506b2c 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -18,6 +18,7 @@ HTTPMethod, ) from dashscope.common.error import UnsupportedHTTPMethod +from dashscope.common.env import get_trust_env from dashscope.common.logging import logger from dashscope.common.utils import ( _handle_aio_stream, @@ -176,6 +177,7 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches connector=connector, timeout=aiohttp.ClientTimeout(total=self.timeout), headers=self.headers, + trust_env=get_trust_env(), ) should_close = True diff --git a/dashscope/common/env.py b/dashscope/common/env.py index bf9a0f9..8498cc6 100644 --- a/dashscope/common/env.py +++ b/dashscope/common/env.py @@ -2,6 +2,7 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import os +import sys from dashscope.common.constants import ( DASHSCOPE_API_KEY_ENV, @@ -15,6 +16,12 @@ # read the api key from env api_key = os.environ.get(DASHSCOPE_API_KEY_ENV) api_key_file_path = os.environ.get(DASHSCOPE_API_KEY_FILE_PATH_ENV) +trust_env = os.environ.get("DASHSCOPE_TRUST_ENV", "true").lower() in ( + "true", + "1", + "yes", +) + # define api base url, ensure end / base_http_api_url = os.environ.get( @@ -29,3 +36,8 @@ "DASHSCOPE_COMPATIBLE_BASE_URL", f"https://dashscope.aliyuncs.com/compatible-mode/{api_version}", ) + + +def get_trust_env() -> bool: + dashscope_module = sys.modules.get("dashscope") + return bool(getattr(dashscope_module, "trust_env", trust_env)) diff --git a/tests/unit/test_async_custom_session.py b/tests/unit/test_async_custom_session.py index 180b478..012b77e 100644 --- a/tests/unit/test_async_custom_session.py +++ b/tests/unit/test_async_custom_session.py @@ -24,6 +24,7 @@ import certifi import pytest +import dashscope from dashscope.api_entities.http_request import HttpRequest from dashscope.api_entities.api_request_data import ApiRequestData from dashscope.common.constants import ApiProtocol, HTTPMethod @@ -577,6 +578,57 @@ async def mock_handle_response(_response): # 验证临时 aio_session 被关闭(原有行为) mock_session.close.assert_called_once() + @pytest.mark.asyncio + async def test_temporary_aio_session_uses_global_trust_env(self): + """测试临时 aio_session 会使用全局 trust_env 配置""" + mock_session = AsyncMock() + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.__aenter__ = AsyncMock(return_value=mock_response) + mock_response.__aexit__ = AsyncMock(return_value=None) + mock_session.request.return_value = mock_response + + http_request = HttpRequest( + url="http://example.com/api", + api_key="fake-api-key", + http_method=HTTPMethod.POST, + stream=False, + ) + http_request.data = ApiRequestData( + model="test-model", + task_group="test", + task="test", + function="test", + input_data={"test": "data"}, + form=None, + is_binary_input=False, + api_protocol=ApiProtocol.HTTPS, + ) + + original_trust_env = dashscope.trust_env + dashscope.trust_env = False + try: + + async def mock_handle_response(_response): + yield mock_response + + with patch( + "aiohttp.ClientSession", + return_value=mock_session, + ) as session_class: + with patch.object( + http_request, + "_handle_aio_response", + side_effect=mock_handle_response, + ): + _ = await http_request.aio_call() + + session_class.assert_called_once() + assert session_class.call_args.kwargs["trust_env"] is False + finally: + dashscope.trust_env = original_trust_env + class TestAsyncSessionLifecycle: """测试异步 Session 生命周期""" From cfa1adba317b5475dd95f1f6708036daede67797 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 11 Jun 2026 11:45:18 +0800 Subject: [PATCH 07/24] fix: improve streaming interruption diagnostics - Add detailed exception logging for sync and async HTTP streaming requests - Include request context such as url, method, stream, timeout, status code, and request id - Handle SSE done events consistently across streaming parsers - Avoid swallowing stream read failures while preserving original exception behavior - Fix pylint warning by avoiding redefinition of built-in id --- README.md | 31 ++++++- dashscope/api_entities/aiohttp_request.py | 74 ++++++++++----- dashscope/api_entities/http_request.py | 49 ++++++++-- dashscope/client/base_api.py | 77 ++++++++------- dashscope/common/utils.py | 108 +++++++++++++--------- 5 files changed, 229 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 6190b4e..38946fb 100644 --- a/README.md +++ b/README.md @@ -241,18 +241,35 @@ response = TextReRank.call( ### Image Generation +Image and video generation APIs use server-side asynchronous tasks: + +- `async_call()` submits a task and returns task information immediately. It is not a Python `async` coroutine. +- `call()` submits a task and blocks by polling task status until the task finishes. +- Use `fetch()` to query task status manually, or `wait()` to block until completion. +- Use `wait_timeout_seconds` with blocking calls to limit the maximum wait time. + ```python from dashscope import ImageSynthesis -# Async task pattern +# Submit a server-side async task response = ImageSynthesis.async_call( model="wanx-v1", prompt="A serene mountain landscape at sunset", ) -# Wait for result +# Query task status manually +status = ImageSynthesis.fetch(response) + +# Or wait for result result = ImageSynthesis.wait(response) +# Blocking call with timeout +result = ImageSynthesis.call( + model="wanx-v1", + prompt="A serene mountain landscape at sunset", + wait_timeout_seconds=60, +) + # Sync call (for wan2.2-t2i-flash/plus) result = ImageSynthesis.sync_call( model="wan2.2-t2i-flash", @@ -265,13 +282,21 @@ result = ImageSynthesis.sync_call( ```python from dashscope import VideoSynthesis -# Text-to-video +# Submit a server-side async task response = VideoSynthesis.async_call( model="wan2.7-t2v", prompt="A cat playing with a ball of yarn", ) +# Wait for result result = VideoSynthesis.wait(response) + +# Blocking call with timeout +result = VideoSynthesis.call( + model="wan2.7-t2v", + prompt="A cat playing with a ball of yarn", + wait_timeout_seconds=60, +) ``` ### Speech Synthesis (TTS) diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index 275115f..008bfa5 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. +import asyncio import json from http import HTTPStatus @@ -108,25 +109,38 @@ async def aio_call(self): return result async def _handle_stream(self, response): - # TODO define done message. is_error = False status_code = HTTPStatus.BAD_REQUEST - async for line in response.content: - if line: - line = line.decode("utf8") - line = line.rstrip("\n").rstrip("\r") - if line.startswith("event:error"): - is_error = True - elif line.startswith("status:"): - status_code = line[len("status:") :] - status_code = int(status_code.strip()) - elif line.startswith("data:"): - line = line[len("data:") :] - yield (is_error, status_code, line) - if is_error: - break - else: - continue # ignore heartbeat... + event_type = None + try: + async for line in response.content: + if line: + line = line.decode("utf8") + line = line.rstrip("\n").rstrip("\r") + if line.startswith("event:"): + event_type = line[len("event:") :].strip() + if event_type == "error": + is_error = True + elif line.startswith("status:"): + status_code = line[len("status:") :] + status_code = int(status_code.strip()) + elif line.startswith("data:"): + line = line[len("data:") :] + if event_type == "done": + continue + yield (is_error, status_code, line) + if is_error: + break + else: + continue # ignore heartbeat... + except (aiohttp.ClientError, asyncio.TimeoutError): + logger.exception( + "Stream response interrupted while reading aiohttp SSE " + "response, status_code=%s, request_id=%s", + response.status, + response.headers.get("X-Request-Id"), + ) + raise # pylint: disable=too-many-statements async def _handle_response( # pylint: disable=too-many-branches @@ -283,9 +297,23 @@ async def _handle_request(self): async with response: async for rsp in self._handle_response(response): yield rsp - except aiohttp.ClientConnectorError as e: - logger.error(e) - raise e - except Exception as e: - logger.error(e) - raise e + except (aiohttp.ClientError, asyncio.TimeoutError): + logger.exception( + "Aio HTTP request failed, url=%s, method=%s, stream=%s, " + "timeout=%s", + self.url, + self.method, + self.stream, + self.timeout, + ) + raise + except Exception: + logger.exception( + "Unexpected aio HTTP request error, url=%s, method=%s, " + "stream=%s, timeout=%s", + self.url, + self.method, + self.stream, + self.timeout, + ) + raise diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 8506b2c..203092c 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -225,12 +225,26 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches # Only close if we created the session if should_close: await session.close() - except aiohttp.ClientConnectorError as e: - logger.error(e) - raise e - except BaseException as e: - logger.error(e) - raise e + except aiohttp.ClientError: + logger.exception( + "Aio HTTP request failed, url=%s, method=%s, stream=%s, " + "timeout=%s", + self.url, + self.method, + self.stream, + self.timeout, + ) + raise + except Exception: + logger.exception( + "Unexpected aio HTTP request error, url=%s, method=%s, " + "stream=%s, timeout=%s", + self.url, + self.method, + self.stream, + self.timeout, + ) + raise @staticmethod def __handle_parameters(params: dict) -> dict: @@ -509,6 +523,23 @@ def _handle_request(self): # Only close if we created the session if should_close: session.close() - except BaseException as e: - logger.error(e) - raise e + except requests.exceptions.RequestException: + logger.exception( + "HTTP request failed, url=%s, method=%s, stream=%s, " + "timeout=%s", + self.url, + self.method, + self.stream, + self.timeout, + ) + raise + except Exception: + logger.exception( + "Unexpected HTTP request error, url=%s, method=%s, " + "stream=%s, timeout=%s", + self.url, + self.method, + self.stream, + self.timeout, + ) + raise diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 38a0c35..cd5b736 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -454,7 +454,7 @@ async def call( function (str, optional): The function of the task. Defaults to None. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. api_protocol (str, optional): Api protocol websocket or http. Defaults to None. ws_stream_mode (str, optional): websocket stream mode, @@ -520,7 +520,7 @@ def call( function (str, optional): The function of the task. Defaults to None. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. api_protocol (str, optional): Api protocol websocket or http. Defaults to None. ws_stream_mode (str, optional): websocket stream mode, @@ -818,8 +818,8 @@ def wait( # the query interval after every 3(increment_steps) # intervals, until we hit the max waiting interval # of 5(seconds) - # TODO: investigate if we can use long-poll - # (server side return immediately when ready) + # Polling is used here because the task status API returns the + # current state for each request. if wait_seconds < max_wait_seconds and step % increment_steps == 0: wait_seconds = min(wait_seconds * 2, max_wait_seconds) rsp = cls._get(task_id, api_key, workspace=workspace, **kwargs) @@ -884,7 +884,7 @@ def async_call( function (str, optional): The function of the task. Defaults to None. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The async task information, @@ -1027,7 +1027,7 @@ def list( Args: api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. path (str, optional): The path of the api, if not default. page_no (int, optional): Page number. Defaults to 1. page_size (int, optional): Items per page. Defaults to 10. @@ -1103,7 +1103,7 @@ def get( Args: target (str): The target to get, such as model_id. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The object information in output. @@ -1144,7 +1144,7 @@ def get( Args: target (str): The target to get, such as model_id. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The object information in output. @@ -1184,7 +1184,7 @@ def delete( Args: target (str): The object to delete, . api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The delete result. @@ -1233,7 +1233,7 @@ def call( Args: data (object): The create request json body. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The created object in output. @@ -1292,7 +1292,7 @@ def update( target (str): The target to update. json (object): The create request json body. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The updated object information in output. @@ -1356,7 +1356,7 @@ def put( target (str): The target to update. json (object): The create request json body. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The updated object information in output. @@ -1408,7 +1408,7 @@ def upload( # pylint: disable=unused-argument descriptions (list[str]): The file description messages. params (dict): The parameters api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The uploaded file information in the output. @@ -1458,7 +1458,7 @@ def cancel( Args: target (str): The request params, key/value map. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The cancel result. @@ -1495,25 +1495,38 @@ def cancel( class StreamEventMixin: @classmethod def _handle_stream(cls, response: requests.Response): - # TODO define done message. is_error = False status_code = HTTPStatus.INTERNAL_SERVER_ERROR - for line in response.iter_lines(): - if line: - line = line.decode("utf8") - line = line.rstrip("\n").rstrip("\r") - if line.startswith("event:error"): - is_error = True - elif line.startswith("status:"): - status_code = line[len("status:") :] - status_code = int(status_code.strip()) - elif line.startswith("data:"): - line = line[len("data:") :] - yield (is_error, status_code, line) - if is_error: - break - else: - continue # ignore heartbeat... + event_type = None + try: + for line in response.iter_lines(): + if line: + line = line.decode("utf8") + line = line.rstrip("\n").rstrip("\r") + if line.startswith("event:"): + event_type = line[len("event:") :].strip() + if event_type == "error": + is_error = True + elif line.startswith("status:"): + status_code = line[len("status:") :] + status_code = int(status_code.strip()) + elif line.startswith("data:"): + line = line[len("data:") :] + if event_type == "done": + continue + yield (is_error, status_code, line) + if is_error: + break + else: + continue # ignore heartbeat... + except requests.exceptions.RequestException: + logger.exception( + "Stream response interrupted while reading SSE response, " + "status_code=%s, request_id=%s", + response.status_code, + response.headers.get("X-Request-Id"), + ) + raise @classmethod def _handle_response(cls, response: requests.Response): @@ -1569,7 +1582,7 @@ def stream_events( Args: target (str): The target to get, such as model_id. api_key (str, optional): The api api_key, if not present, - will get by default rule(TODO: api key doc). Defaults to None. + will use the default API key resolution rule. Defaults to None. Returns: DashScopeAPIResponse: The target outputs. diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index d568446..f740c9b 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -229,36 +229,45 @@ def __init__( # pylint: disable=redefined-builtin def _handle_stream(response: requests.Response): - # TODO define done message. is_error = False status_code = HTTPStatus.BAD_REQUEST event = SSEEvent(None, None, None) # type: ignore[arg-type] eventType = None - for line in response.iter_lines(): - if line: - line = line.decode("utf8") - line = line.rstrip("\n").rstrip("\r") - if line.startswith("id:"): - id = line[len("id:") :] # pylint: disable=redefined-builtin - event.id = id.strip() - elif line.startswith("event:"): - eventType = line[len("event:") :] - event.eventType = eventType.strip() - if eventType == "error": - is_error = True - elif line.startswith("status:"): - status_code = line[len("status:") :] - status_code = int(status_code.strip()) - elif line.startswith("data:"): - line = line[len("data:") :] - event.data = line.strip() - if eventType is not None and eventType == "done": - continue - yield (is_error, status_code, event) - if is_error: - break - else: - continue # ignore heartbeat... + + try: + for line in response.iter_lines(): + if line: + line = line.decode("utf8") + line = line.rstrip("\n").rstrip("\r") + if line.startswith("id:"): + event_id = line[len("id:") :] + event.id = event_id.strip() + elif line.startswith("event:"): + eventType = line[len("event:") :].strip() + event.eventType = eventType + if eventType == "error": + is_error = True + elif line.startswith("status:"): + status_code = line[len("status:") :] + status_code = int(status_code.strip()) + elif line.startswith("data:"): + line = line[len("data:") :] + event.data = line.strip() + if eventType is not None and eventType == "done": + continue + yield (is_error, status_code, event) + if is_error: + break + else: + continue # ignore heartbeat... + except requests.exceptions.RequestException: + logger.exception( + "Stream response interrupted while reading SSE response, " + "status_code=%s, request_id=%s", + response.status_code, + response.headers.get("X-Request-Id"), + ) + raise def _handle_error_message(error, status_code, flattened_output, headers): @@ -331,25 +340,38 @@ def _handle_http_failed_response( async def _handle_aio_stream(response): - # TODO define done message. is_error = False status_code = HTTPStatus.BAD_REQUEST - async for line in response.content: - if line: - line = line.decode("utf8") - line = line.rstrip("\n").rstrip("\r") - if line.startswith("event:error"): - is_error = True - elif line.startswith("status:"): - status_code = line[len("status:") :] - status_code = int(status_code.strip()) - elif line.startswith("data:"): - line = line[len("data:") :] - yield (is_error, status_code, line) - if is_error: - break - else: - continue # ignore heartbeat... + event_type = None + try: + async for line in response.content: + if line: + line = line.decode("utf8") + line = line.rstrip("\n").rstrip("\r") + if line.startswith("event:"): + event_type = line[len("event:") :].strip() + if event_type == "error": + is_error = True + elif line.startswith("status:"): + status_code = line[len("status:") :] + status_code = int(status_code.strip()) + elif line.startswith("data:"): + line = line[len("data:") :] + if event_type == "done": + continue + yield (is_error, status_code, line) + if is_error: + break + else: + continue # ignore heartbeat... + except (aiohttp.ClientError, asyncio.TimeoutError): + logger.exception( + "Stream response interrupted while reading aiohttp SSE " + "response, status_code=%s, request_id=%s", + response.status, + response.headers.get("X-Request-Id"), + ) + raise async def _handle_aiohttp_failed_response( From 652f5e9514b31d92579a3a1ee958c68c53de0bd7 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 11 Jun 2026 13:26:38 +0800 Subject: [PATCH 08/24] fix: improve qwen tts realtime websocket error handling --- .../qwen_tts_realtime/qwen_tts_realtime.py | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py index 7142a66..87fa290 100644 --- a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py +++ b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py @@ -97,13 +97,19 @@ def __init__( self.user_workspace = workspace self.model = model self.config = {} - self.callback = callback + self.callback = callback or QwenTtsRealtimeCallback() self.ws = None + self.thread = None self.session_id = None self.last_message = None self.last_response_id = None self.last_first_text_time = None self.last_first_audio_delay = None + self.last_error = None + self.close_status_code = None + self.close_msg = None + self.session_created_event = threading.Event() + self.websocket_closed_event = threading.Event() self.metrics = [] def _generate_event_id(self): @@ -131,6 +137,11 @@ def connect(self) -> None: """ connect to server, create session and return default session configuration # noqa: E501 """ + self.last_error = None + self.close_status_code = None + self.close_msg = None + self.session_created_event.clear() + self.websocket_closed_event.clear() self.ws = websocket.WebSocketApp( self.url, header=self._get_websocket_header(), @@ -141,23 +152,45 @@ def connect(self) -> None: self.thread = threading.Thread(target=self.ws.run_forever) self.thread.daemon = True self.thread.start() - timeout = 5 # 最长等待时间(秒) + timeout = 5 start_time = time.time() while ( not (self.ws.sock and self.ws.sock.connected) + and not self.websocket_closed_event.is_set() and (time.time() - start_time) < timeout ): - time.sleep(0.1) # 短暂休眠,避免密集轮询 - if not (self.ws.sock and self.ws.sock.connected): + time.sleep(0.1) + if not self._is_websocket_connected(): raise TimeoutError( "websocket connection could not established within 5s. " - "Please check your network connection, firewall settings, or server status.", # noqa: E501 # pylint: disable=line-too-long + f"{self._build_connection_state_message()}", + ) + if not self.session_created_event.wait(timeout): + raise TimeoutError( + "websocket session could not be created within 5s. " + f"{self._build_connection_state_message()}", ) self.callback.on_open() + def _is_websocket_connected(self): + return bool(self.ws and self.ws.sock and self.ws.sock.connected) + + def _build_connection_state_message(self): + return ( + f"close_status_code: {self.close_status_code}, " + f"close_msg: {self.close_msg}, " + f"last_error: {self.last_error}, " + f"last_message: {self.last_message}" + ) + def __send_str(self, data: str, enable_log: bool = True): if enable_log: logger.debug("[qwen tts realtime] send string: %s", data) + if not self._is_websocket_connected(): + raise ConnectionError( + "qwen tts realtime websocket connection is closed. " + f"{self._build_connection_state_message()}", + ) self.ws.send(data) def update_session( @@ -351,6 +384,7 @@ def on_message( # pylint: disable=unused-argument if "type" in message: if "session.created" == json_data["type"]: self.session_id = json_data["session"]["id"] + self.session_created_event.set() if "response.created" == json_data["type"]: self.last_response_id = json_data["response"]["id"] elif "response.audio.delta" == json_data["type"]: @@ -387,8 +421,11 @@ def on_close( # pylint: disable=unused-argument close_status_code, close_msg, ): + self.close_status_code = close_status_code + self.close_msg = close_msg + self.websocket_closed_event.set() logger.debug( - "[omni realtime] connection closed with code %s and message %s", # noqa: E501 + "[qwen tts realtime] connection closed with code %s and message %s", # noqa: E501 close_status_code, close_msg, ) @@ -396,9 +433,8 @@ def on_close( # pylint: disable=unused-argument # WebSocket发生错误的回调函数 def on_error(self, ws, error): # pylint: disable=unused-argument - print(f"websocket closed due to {error}") - # pylint: disable=broad-exception-raised - raise Exception(f"websocket closed due to {error}") + self.last_error = error + logger.error("[qwen tts realtime] websocket closed due to %s", error) # 获取上一个任务的taskId def get_session_id(self): From f3f0929ff1b7797eb904e84a45b4750969c866e1 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 11 Jun 2026 14:16:48 +0800 Subject: [PATCH 09/24] fix: handle local file URI hosts and string wait timeout --- dashscope/client/base_api.py | 4 ++ dashscope/utils/oss_utils.py | 12 ++++-- tests/unit/test_async_task_wait_timeout.py | 43 ++++++++++++++++++++++ tests/unit/test_oss_utils.py | 42 +++++++++++++++++++++ 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index cd5b736..4ad3192 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -212,6 +212,8 @@ async def wait( DashScopeAPIResponse: The async task information. """ wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) + if wait_timeout_seconds is not None: + wait_timeout_seconds = float(wait_timeout_seconds) start_time = time.monotonic() task_id = cls._get_task_id(task) wait_seconds = 1 @@ -806,6 +808,8 @@ def wait( DashScopeAPIResponse: The async task information. """ wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) + if wait_timeout_seconds is not None: + wait_timeout_seconds = float(wait_timeout_seconds) start_time = time.monotonic() task_id = cls._get_task_id(task) wait_seconds = 1 diff --git a/dashscope/utils/oss_utils.py b/dashscope/utils/oss_utils.py index 6b5e476..27d2683 100644 --- a/dashscope/utils/oss_utils.py +++ b/dashscope/utils/oss_utils.py @@ -131,8 +131,12 @@ def get_upload_certificate( def _resolve_file_uri_path(file_uri: str): parse_result = urlparse(file_uri) - if parse_result.netloc: - file_path = parse_result.netloc + unquote_plus(parse_result.path) + netloc = parse_result.netloc + if netloc.lower() in ("localhost", "127.0.0.1"): + netloc = "" + + if netloc: + file_path = netloc + unquote_plus(parse_result.path) else: file_path = unquote_plus(parse_result.path) @@ -167,7 +171,7 @@ def upload_file( ) return file_url else: - raise InvalidInput(f"The file: {file_path} is not exists!") + raise InvalidInput(f"The file: {file_path} does not exist!") return None @@ -210,7 +214,7 @@ def check_and_upload_local( f"Uploading file: {content} failed", ) return True, file_url, cert - raise InvalidInput(f"The file: {file_path} is not exists!") + raise InvalidInput(f"The file: {file_path} does not exist!") if content.startswith("oss://"): return True, content, upload_certificate if not content.startswith("http"): diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py index f9e4bb8..5421905 100644 --- a/tests/unit/test_async_task_wait_timeout.py +++ b/tests/unit/test_async_task_wait_timeout.py @@ -87,6 +87,27 @@ def test_base_async_api_wait_raises_timeout(self): with pytest.raises(TimeoutException): TimeoutWaitTestAsyncApi.wait("task-id", wait_timeout_seconds=0) + def test_base_async_api_wait_accepts_string_timeout(self): + response = DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_status": TaskStatus.RUNNING}, + usage=None, + message="", + ) + + with patch.object( + TimeoutWaitTestAsyncApi, + "_get", + return_value=response, + ): + with pytest.raises(TimeoutException): + TimeoutWaitTestAsyncApi.wait( + "task-id", + wait_timeout_seconds="0", + ) + @pytest.mark.asyncio async def test_base_async_aio_api_wait_raises_timeout(self): response = DashScopeAPIResponse( @@ -109,6 +130,28 @@ async def test_base_async_aio_api_wait_raises_timeout(self): wait_timeout_seconds=0, ) + @pytest.mark.asyncio + async def test_base_async_aio_api_wait_accepts_string_timeout(self): + response = DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_status": TaskStatus.RUNNING}, + usage=None, + message="", + ) + + with patch.object( + TimeoutTestAsyncAioApi, + "_get", + AsyncMock(return_value=response), + ): + with pytest.raises(TimeoutException): + await TimeoutTestAsyncAioApi.wait( + "task-id", + wait_timeout_seconds="0", + ) + def test_base_async_call_does_not_pass_default_wait_timeout( self, ): diff --git a/tests/unit/test_oss_utils.py b/tests/unit/test_oss_utils.py index 505d384..2d740ce 100644 --- a/tests/unit/test_oss_utils.py +++ b/tests/unit/test_oss_utils.py @@ -129,6 +129,48 @@ def fake_upload( assert file_url == "oss://test-dir/frame_0000.jpg" assert captured_file_path["value"] == "C:/Users/test/frame_0000.jpg" + @pytest.mark.parametrize( + "file_uri", + [ + "file://localhost/home/user/frame_0000.jpg", + "file://127.0.0.1/home/user/frame_0000.jpg", + ], + ) + def test_check_and_upload_local_treats_loopback_host_as_local_path( + self, + monkeypatch, + file_uri, + ): + captured_file_path = {} + + def fake_isfile(file_path): + captured_file_path["value"] = file_path + return True + + def fake_upload( + model, + file_path, + api_key, + upload_certificate, + ): + assert model == "test-model" + assert file_path == "/home/user/frame_0000.jpg" + assert api_key == "test-api-key" + return "oss://test-dir/frame_0000.jpg", upload_certificate + + monkeypatch.setattr(oss_utils.os.path, "isfile", fake_isfile) + monkeypatch.setattr(OssUtils, "upload", fake_upload) + + is_upload, file_url, _ = oss_utils.check_and_upload_local( + model="test-model", + content=file_uri, + api_key="test-api-key", + ) + + assert is_upload + assert file_url == "oss://test-dir/frame_0000.jpg" + assert captured_file_path["value"] == "/home/user/frame_0000.jpg" + def test_check_and_upload_local_raises_when_file_uri_not_found( self, monkeypatch, From 7f90814f0f676d54e7d1e82899fb371725a08c5e Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 11 Jun 2026 15:05:11 +0800 Subject: [PATCH 10/24] fix: respect remaining wait timeout and reset SSE event type --- dashscope/api_entities/aiohttp_request.py | 5 + dashscope/client/base_api.py | 80 ++++++++++------ dashscope/common/utils.py | 12 ++- tests/unit/test_async_task_wait_timeout.py | 74 +++++++++++++++ tests/unit/test_sse_stream_event_type.py | 101 +++++++++++++++++++++ 5 files changed, 244 insertions(+), 28 deletions(-) create mode 100644 tests/unit/test_sse_stream_event_type.py diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index 008bfa5..65f9d1c 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -117,6 +117,9 @@ async def _handle_stream(self, response): if line: line = line.decode("utf8") line = line.rstrip("\n").rstrip("\r") + if not line: + event_type = None + continue if line.startswith("event:"): event_type = line[len("event:") :].strip() if event_type == "error": @@ -133,6 +136,8 @@ async def _handle_stream(self, response): break else: continue # ignore heartbeat... + else: + event_type = None except (aiohttp.ClientError, asyncio.TimeoutError): logger.exception( "Stream response interrupted while reading aiohttp SSE " diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 4ad3192..80dfa41 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -36,6 +36,23 @@ ) +def _get_wait_sleep_seconds( + task_id: str, + wait_seconds: int, + wait_timeout_seconds, + start_time: float, +): + if wait_timeout_seconds is None: + return wait_seconds + + remaining_timeout_seconds = wait_timeout_seconds - ( + time.monotonic() - start_time + ) + if remaining_timeout_seconds <= 0: + raise TimeoutException(f"Wait task {task_id} timeout.") + return min(wait_seconds, remaining_timeout_seconds) + + class AsyncAioTaskGetMixin: @classmethod async def _get( @@ -249,13 +266,14 @@ async def wait( return rsp else: logger.info("The task %s is %s", task_id, task_status) - if ( - wait_timeout_seconds is not None - and time.monotonic() - start_time - >= wait_timeout_seconds - ): - raise TimeoutException(f"Wait task {task_id} timeout.") - await asyncio.sleep(wait_seconds) # 异步等待 + await asyncio.sleep( + _get_wait_sleep_seconds( + task_id, + wait_seconds, + wait_timeout_seconds, + start_time, + ), + ) # 异步等待 elif rsp.status_code in REPEATABLE_STATUS: logger.warning( "Get task: %s temporary failure, " @@ -265,12 +283,14 @@ async def wait( rsp.code, rsp.message, ) - if ( - wait_timeout_seconds is not None - and time.monotonic() - start_time >= wait_timeout_seconds - ): - raise TimeoutException(f"Wait task {task_id} timeout.") - await asyncio.sleep(wait_seconds) # 异步等待 + await asyncio.sleep( + _get_wait_sleep_seconds( + task_id, + wait_seconds, + wait_timeout_seconds, + start_time, + ), + ) # 异步等待 else: return rsp @@ -841,13 +861,14 @@ def wait( return rsp else: logger.info("The task %s is %s", task_id, task_status) - if ( - wait_timeout_seconds is not None - and time.monotonic() - start_time - >= wait_timeout_seconds - ): - raise TimeoutException(f"Wait task {task_id} timeout.") - time.sleep(wait_seconds) + time.sleep( + _get_wait_sleep_seconds( + task_id, + wait_seconds, + wait_timeout_seconds, + start_time, + ), + ) elif rsp.status_code in REPEATABLE_STATUS: logger.warning( "Get task: %s temporary failure, " @@ -857,12 +878,14 @@ def wait( rsp.code, rsp.message, ) - if ( - wait_timeout_seconds is not None - and time.monotonic() - start_time >= wait_timeout_seconds - ): - raise TimeoutException(f"Wait task {task_id} timeout.") - time.sleep(wait_seconds) + time.sleep( + _get_wait_sleep_seconds( + task_id, + wait_seconds, + wait_timeout_seconds, + start_time, + ), + ) else: return rsp @@ -1507,6 +1530,9 @@ def _handle_stream(cls, response: requests.Response): if line: line = line.decode("utf8") line = line.rstrip("\n").rstrip("\r") + if not line: + event_type = None + continue if line.startswith("event:"): event_type = line[len("event:") :].strip() if event_type == "error": @@ -1523,6 +1549,8 @@ def _handle_stream(cls, response: requests.Response): break else: continue # ignore heartbeat... + else: + event_type = None except requests.exceptions.RequestException: logger.exception( "Stream response interrupted while reading SSE response, " diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index f740c9b..51ce30a 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -239,6 +239,9 @@ def _handle_stream(response: requests.Response): if line: line = line.decode("utf8") line = line.rstrip("\n").rstrip("\r") + if not line: + eventType = None + continue if line.startswith("id:"): event_id = line[len("id:") :] event.id = event_id.strip() @@ -258,8 +261,8 @@ def _handle_stream(response: requests.Response): yield (is_error, status_code, event) if is_error: break - else: - continue # ignore heartbeat... + else: + eventType = None except requests.exceptions.RequestException: logger.exception( "Stream response interrupted while reading SSE response, " @@ -348,6 +351,9 @@ async def _handle_aio_stream(response): if line: line = line.decode("utf8") line = line.rstrip("\n").rstrip("\r") + if not line: + event_type = None + continue if line.startswith("event:"): event_type = line[len("event:") :].strip() if event_type == "error": @@ -364,6 +370,8 @@ async def _handle_aio_stream(response): break else: continue # ignore heartbeat... + else: + event_type = None except (aiohttp.ClientError, asyncio.TimeoutError): logger.exception( "Stream response interrupted while reading aiohttp SSE " diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py index 5421905..e97a9a6 100644 --- a/tests/unit/test_async_task_wait_timeout.py +++ b/tests/unit/test_async_task_wait_timeout.py @@ -9,6 +9,7 @@ from dashscope.aigc.image_synthesis import ImageSynthesis from dashscope.aigc.video_synthesis import VideoSynthesis from dashscope.api_entities.dashscope_response import DashScopeAPIResponse +from dashscope.client import base_api from dashscope.client.base_api import BaseAsyncAioApi, BaseAsyncApi from dashscope.embeddings.batch_text_embedding import BatchTextEmbedding from dashscope.common.constants import TaskStatus @@ -108,6 +109,42 @@ def test_base_async_api_wait_accepts_string_timeout(self): wait_timeout_seconds="0", ) + def test_base_async_api_wait_sleep_does_not_exceed_remaining_timeout( + self, + ): + response = DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_status": TaskStatus.RUNNING}, + usage=None, + message="", + ) + + with patch.object( + TimeoutWaitTestAsyncApi, + "_get", + return_value=response, + ): + with patch.object( + base_api.time, + "monotonic", + side_effect=[100.0, 100.2, 100.2], + ): + with patch.object( + base_api.time, + "sleep", + side_effect=TimeoutException("stop test"), + ) as sleep_mock: + with pytest.raises(TimeoutException): + TimeoutWaitTestAsyncApi.wait( + "task-id", + wait_timeout_seconds=0.5, + ) + + sleep_mock.assert_called_once() + assert sleep_mock.call_args.args[0] == pytest.approx(0.3) + @pytest.mark.asyncio async def test_base_async_aio_api_wait_raises_timeout(self): response = DashScopeAPIResponse( @@ -152,6 +189,43 @@ async def test_base_async_aio_api_wait_accepts_string_timeout(self): wait_timeout_seconds="0", ) + @pytest.mark.asyncio + async def test_async_aio_wait_sleep_uses_remaining_timeout( + self, + ): + response = DashScopeAPIResponse( + request_id="request-id", + status_code=HTTPStatus.OK, + code=None, + output={"task_status": TaskStatus.RUNNING}, + usage=None, + message="", + ) + + with patch.object( + TimeoutTestAsyncAioApi, + "_get", + AsyncMock(return_value=response), + ): + with patch.object( + base_api.time, + "monotonic", + side_effect=[100.0, 100.2, 100.2], + ): + with patch.object( + base_api.asyncio, + "sleep", + AsyncMock(side_effect=TimeoutException("stop test")), + ) as sleep_mock: + with pytest.raises(TimeoutException): + await TimeoutTestAsyncAioApi.wait( + "task-id", + wait_timeout_seconds=0.5, + ) + + sleep_mock.assert_awaited_once() + assert sleep_mock.await_args.args[0] == pytest.approx(0.3) + def test_base_async_call_does_not_pass_default_wait_timeout( self, ): diff --git a/tests/unit/test_sse_stream_event_type.py b/tests/unit/test_sse_stream_event_type.py new file mode 100644 index 0000000..e8265ba --- /dev/null +++ b/tests/unit/test_sse_stream_event_type.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +# pylint: disable=protected-access + +from http import HTTPStatus + +import pytest + +from dashscope.api_entities.aiohttp_request import AioHttpRequest +from dashscope.client.base_api import StreamEventMixin +from dashscope.common.utils import _handle_aio_stream, _handle_stream + + +class FakeSyncSseResponse: + status_code = HTTPStatus.OK + headers = {} + + def iter_lines(self): + return iter( + [ + b"event: done", + b"data: ignored", + b"\n", + b"data: delivered", + ], + ) + + +class FakeAsyncSseContent: + def __init__(self): + self.lines = iter( + [ + b"event: done", + b"data: ignored", + b"\n", + b"data: delivered", + ], + ) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.lines) + except StopIteration as stop_iteration: + raise StopAsyncIteration from stop_iteration + + +class FakeAsyncSseResponse: + status = HTTPStatus.OK + headers = {} + + def __init__(self): + self.content = FakeAsyncSseContent() + + +class TestSseStreamEventType: + def test_common_sync_stream_resets_event_type_after_empty_line(self): + response = FakeSyncSseResponse() + + stream_items = list(_handle_stream(response)) + + assert len(stream_items) == 1 + assert stream_items[0][0] is False + assert stream_items[0][1] == HTTPStatus.BAD_REQUEST + assert stream_items[0][2].data == "delivered" + + def test_sync_stream_resets_event_type_after_empty_line(self): + response = FakeSyncSseResponse() + + stream_items = list(StreamEventMixin._handle_stream(response)) + + assert stream_items == [ + (False, HTTPStatus.INTERNAL_SERVER_ERROR, " delivered"), + ] + + @pytest.mark.asyncio + async def test_common_aio_stream_resets_event_type_after_empty_line(self): + response = FakeAsyncSseResponse() + + stream_items = [item async for item in _handle_aio_stream(response)] + + assert stream_items == [ + (False, HTTPStatus.BAD_REQUEST, " delivered"), + ] + + @pytest.mark.asyncio + async def test_aiohttp_request_stream_resets_event_type_after_empty_line( + self, + ): + response = FakeAsyncSseResponse() + + stream_items = [ + item + async for item in AioHttpRequest._handle_stream(None, response) + ] + + assert stream_items == [ + (False, HTTPStatus.BAD_REQUEST, " delivered"), + ] From d6de0423edab3cd2a62136d1a87011678f32b575 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 11 Jun 2026 16:18:46 +0800 Subject: [PATCH 11/24] fix: preserve async task kwargs and tighten realtime connect timeout --- .../qwen_tts_realtime/qwen_tts_realtime.py | 18 +++- dashscope/client/base_api.py | 2 +- tests/unit/test_async_task_wait_timeout.py | 4 + tests/unit/test_qwen_tts_realtime.py | 96 +++++++++++++++++++ 4 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_qwen_tts_realtime.py diff --git a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py index 87fa290..2e46a6f 100644 --- a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py +++ b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py @@ -153,19 +153,29 @@ def connect(self) -> None: self.thread.daemon = True self.thread.start() timeout = 5 - start_time = time.time() + start_time = time.monotonic() while ( not (self.ws.sock and self.ws.sock.connected) and not self.websocket_closed_event.is_set() - and (time.time() - start_time) < timeout ): - time.sleep(0.1) + elapsed_seconds = time.monotonic() - start_time + if elapsed_seconds >= timeout: + break + time.sleep(min(0.1, timeout - elapsed_seconds)) if not self._is_websocket_connected(): raise TimeoutError( "websocket connection could not established within 5s. " f"{self._build_connection_state_message()}", ) - if not self.session_created_event.wait(timeout): + while ( + not self.session_created_event.is_set() + and not self.websocket_closed_event.is_set() + ): + elapsed_seconds = time.monotonic() - start_time + if elapsed_seconds >= timeout: + break + time.sleep(min(0.1, timeout - elapsed_seconds)) + if not self.session_created_event.is_set(): raise TimeoutError( "websocket session could not be created within 5s. " f"{self._build_connection_state_message()}", diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 80dfa41..7fa0b8b 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -650,7 +650,7 @@ def call( workspace=workspace, **kwargs, ) - wait_kwargs = {} + wait_kwargs = kwargs.copy() if wait_timeout_seconds is not None: wait_kwargs["wait_timeout_seconds"] = wait_timeout_seconds response = cls.wait( diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py index e97a9a6..e6c0bd6 100644 --- a/tests/unit/test_async_task_wait_timeout.py +++ b/tests/unit/test_async_task_wait_timeout.py @@ -263,6 +263,10 @@ def test_base_async_call_excludes_wait_timeout_from_request( ] == 10 ) + assert ( + TimeoutCallTestAsyncApi.captured_wait_kwargs["custom_param"] + == "custom-value" + ) @pytest.mark.asyncio async def test_base_async_aio_call_excludes_wait_timeout_from_request( diff --git a/tests/unit/test_qwen_tts_realtime.py b/tests/unit/test_qwen_tts_realtime.py new file mode 100644 index 0000000..1320372 --- /dev/null +++ b/tests/unit/test_qwen_tts_realtime.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from unittest.mock import patch + +import pytest + +import dashscope +from dashscope.audio.qwen_tts_realtime.qwen_tts_realtime import ( + QwenTtsRealtime, +) + + +class FakeConnectedSock: + connected = True + + +class FakeWebSocketApp: + def __init__(self, *_args, on_close=None, **_kwargs): + self.sock = FakeConnectedSock() + self.on_close = on_close + + def run_forever(self): + pass + + +class FakeClosingWebSocketApp(FakeWebSocketApp): + def run_forever(self): + self.on_close(self, 401, "unauthorized") + + +class ImmediateThread: + def __init__(self, target): + self.target = target + self.daemon = False + + def start(self): + self.target() + + +class NoopThread: + def __init__(self, target): + self.target = target + self.daemon = False + + def start(self): + pass + + +@pytest.fixture(autouse=True) +def set_api_key(): + dashscope.api_key = "test-api-key" + + +class TestQwenTtsRealtimeConnect: + def test_connect_stops_waiting_when_websocket_closes(self): + realtime_client = QwenTtsRealtime(model="qwen-tts-realtime") + + with patch( + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.websocket.WebSocketApp", + FakeClosingWebSocketApp, + ), patch( + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.threading.Thread", + ImmediateThread, + ), patch( + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.sleep", + ) as sleep_mock: + with pytest.raises(TimeoutError): + realtime_client.connect() + + sleep_mock.assert_not_called() + + def test_connect_session_wait_uses_remaining_timeout(self): + realtime_client = QwenTtsRealtime(model="qwen-tts-realtime") + sleep_durations = [] + + def record_sleep(duration): + sleep_durations.append(duration) + + with patch( + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.websocket.WebSocketApp", + FakeWebSocketApp, + ), patch( + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.threading.Thread", + NoopThread, + ), patch( + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.monotonic", + side_effect=[100.0, 104.95, 105.0], + ), patch( + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.sleep", + side_effect=record_sleep, + ): + with pytest.raises(TimeoutError): + realtime_client.connect() + + assert sleep_durations == [pytest.approx(0.05)] From 82d726a7421e3eed767a64d2782d9ded8fca39f0 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 11 Jun 2026 16:31:49 +0800 Subject: [PATCH 12/24] test: fix qwen tts realtime test lint issues --- tests/unit/test_qwen_tts_realtime.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_qwen_tts_realtime.py b/tests/unit/test_qwen_tts_realtime.py index 1320372..0229b19 100644 --- a/tests/unit/test_qwen_tts_realtime.py +++ b/tests/unit/test_qwen_tts_realtime.py @@ -11,6 +11,11 @@ ) +QWEN_TTS_REALTIME_MODULE = ( + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime" +) + + class FakeConnectedSock: connected = True @@ -49,7 +54,10 @@ def start(self): @pytest.fixture(autouse=True) def set_api_key(): + original_api_key = dashscope.api_key dashscope.api_key = "test-api-key" + yield + dashscope.api_key = original_api_key class TestQwenTtsRealtimeConnect: @@ -57,13 +65,13 @@ def test_connect_stops_waiting_when_websocket_closes(self): realtime_client = QwenTtsRealtime(model="qwen-tts-realtime") with patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.websocket.WebSocketApp", + f"{QWEN_TTS_REALTIME_MODULE}.websocket.WebSocketApp", FakeClosingWebSocketApp, ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.threading.Thread", + f"{QWEN_TTS_REALTIME_MODULE}.threading.Thread", ImmediateThread, ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.sleep", + f"{QWEN_TTS_REALTIME_MODULE}.time.sleep", ) as sleep_mock: with pytest.raises(TimeoutError): realtime_client.connect() @@ -78,16 +86,16 @@ def record_sleep(duration): sleep_durations.append(duration) with patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.websocket.WebSocketApp", + f"{QWEN_TTS_REALTIME_MODULE}.websocket.WebSocketApp", FakeWebSocketApp, ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.threading.Thread", + f"{QWEN_TTS_REALTIME_MODULE}.threading.Thread", NoopThread, ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.monotonic", + f"{QWEN_TTS_REALTIME_MODULE}.time.monotonic", side_effect=[100.0, 104.95, 105.0], ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.sleep", + f"{QWEN_TTS_REALTIME_MODULE}.time.sleep", side_effect=record_sleep, ): with pytest.raises(TimeoutError): From 54dc3af78afaed88affb1d0fdb3c5e43f9bcea0a Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 11 Jun 2026 16:40:59 +0800 Subject: [PATCH 13/24] fix: handle aiohttp timeout as request failure --- dashscope/api_entities/http_request.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 203092c..df5ae34 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. +import asyncio import datetime import json import ssl @@ -225,7 +226,7 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches # Only close if we created the session if should_close: await session.close() - except aiohttp.ClientError: + except (aiohttp.ClientError, asyncio.TimeoutError): logger.exception( "Aio HTTP request failed, url=%s, method=%s, stream=%s, " "timeout=%s", From 10b3fdaf36349218f887a270408a983eada32c6e Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 11 Jun 2026 16:54:25 +0800 Subject: [PATCH 14/24] fix: avoid forwarding task kwarg to async wait --- dashscope/client/base_api.py | 1 + tests/unit/test_async_task_wait_timeout.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 7fa0b8b..9817757 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -651,6 +651,7 @@ def call( **kwargs, ) wait_kwargs = kwargs.copy() + wait_kwargs.pop("task", None) if wait_timeout_seconds is not None: wait_kwargs["wait_timeout_seconds"] = wait_timeout_seconds response = cls.wait( diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py index e6c0bd6..8841412 100644 --- a/tests/unit/test_async_task_wait_timeout.py +++ b/tests/unit/test_async_task_wait_timeout.py @@ -246,6 +246,7 @@ def test_base_async_call_excludes_wait_timeout_from_request( api_key="api-key", wait_timeout_seconds=10, custom_param="custom-value", + task="custom-task", ) assert response.output["task_id"] == "task-id" @@ -257,6 +258,11 @@ def test_base_async_call_excludes_wait_timeout_from_request( TimeoutCallTestAsyncApi.captured_async_call_kwargs["custom_param"] == "custom-value" ) + assert ( + TimeoutCallTestAsyncApi.captured_async_call_kwargs["task"] + == "custom-task" + ) + assert "task" not in TimeoutCallTestAsyncApi.captured_wait_kwargs assert ( TimeoutCallTestAsyncApi.captured_wait_kwargs[ "wait_timeout_seconds" From 5ee87454b71bc6c200566ec3078a25409e8c8fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:31:51 +0800 Subject: [PATCH 15/24] Revert "fix: avoid forwarding task kwarg to async wait" This reverts commit 10b3fdaf36349218f887a270408a983eada32c6e. --- dashscope/client/base_api.py | 1 - tests/unit/test_async_task_wait_timeout.py | 6 ------ 2 files changed, 7 deletions(-) diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 9817757..7fa0b8b 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -651,7 +651,6 @@ def call( **kwargs, ) wait_kwargs = kwargs.copy() - wait_kwargs.pop("task", None) if wait_timeout_seconds is not None: wait_kwargs["wait_timeout_seconds"] = wait_timeout_seconds response = cls.wait( diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py index 8841412..e6c0bd6 100644 --- a/tests/unit/test_async_task_wait_timeout.py +++ b/tests/unit/test_async_task_wait_timeout.py @@ -246,7 +246,6 @@ def test_base_async_call_excludes_wait_timeout_from_request( api_key="api-key", wait_timeout_seconds=10, custom_param="custom-value", - task="custom-task", ) assert response.output["task_id"] == "task-id" @@ -258,11 +257,6 @@ def test_base_async_call_excludes_wait_timeout_from_request( TimeoutCallTestAsyncApi.captured_async_call_kwargs["custom_param"] == "custom-value" ) - assert ( - TimeoutCallTestAsyncApi.captured_async_call_kwargs["task"] - == "custom-task" - ) - assert "task" not in TimeoutCallTestAsyncApi.captured_wait_kwargs assert ( TimeoutCallTestAsyncApi.captured_wait_kwargs[ "wait_timeout_seconds" From f80a1b2657334c07f8632a0bf4b93b94f90352fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:31:51 +0800 Subject: [PATCH 16/24] Revert "fix: handle aiohttp timeout as request failure" This reverts commit 54dc3af78afaed88affb1d0fdb3c5e43f9bcea0a. --- dashscope/api_entities/http_request.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index df5ae34..203092c 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -import asyncio import datetime import json import ssl @@ -226,7 +225,7 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches # Only close if we created the session if should_close: await session.close() - except (aiohttp.ClientError, asyncio.TimeoutError): + except aiohttp.ClientError: logger.exception( "Aio HTTP request failed, url=%s, method=%s, stream=%s, " "timeout=%s", From 1c1470d2d227c4b34b8a38bd0985ed6e1aec70c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:31:52 +0800 Subject: [PATCH 17/24] Revert "test: fix qwen tts realtime test lint issues" This reverts commit 82d726a7421e3eed767a64d2782d9ded8fca39f0. --- tests/unit/test_qwen_tts_realtime.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/tests/unit/test_qwen_tts_realtime.py b/tests/unit/test_qwen_tts_realtime.py index 0229b19..1320372 100644 --- a/tests/unit/test_qwen_tts_realtime.py +++ b/tests/unit/test_qwen_tts_realtime.py @@ -11,11 +11,6 @@ ) -QWEN_TTS_REALTIME_MODULE = ( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime" -) - - class FakeConnectedSock: connected = True @@ -54,10 +49,7 @@ def start(self): @pytest.fixture(autouse=True) def set_api_key(): - original_api_key = dashscope.api_key dashscope.api_key = "test-api-key" - yield - dashscope.api_key = original_api_key class TestQwenTtsRealtimeConnect: @@ -65,13 +57,13 @@ def test_connect_stops_waiting_when_websocket_closes(self): realtime_client = QwenTtsRealtime(model="qwen-tts-realtime") with patch( - f"{QWEN_TTS_REALTIME_MODULE}.websocket.WebSocketApp", + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.websocket.WebSocketApp", FakeClosingWebSocketApp, ), patch( - f"{QWEN_TTS_REALTIME_MODULE}.threading.Thread", + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.threading.Thread", ImmediateThread, ), patch( - f"{QWEN_TTS_REALTIME_MODULE}.time.sleep", + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.sleep", ) as sleep_mock: with pytest.raises(TimeoutError): realtime_client.connect() @@ -86,16 +78,16 @@ def record_sleep(duration): sleep_durations.append(duration) with patch( - f"{QWEN_TTS_REALTIME_MODULE}.websocket.WebSocketApp", + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.websocket.WebSocketApp", FakeWebSocketApp, ), patch( - f"{QWEN_TTS_REALTIME_MODULE}.threading.Thread", + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.threading.Thread", NoopThread, ), patch( - f"{QWEN_TTS_REALTIME_MODULE}.time.monotonic", + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.monotonic", side_effect=[100.0, 104.95, 105.0], ), patch( - f"{QWEN_TTS_REALTIME_MODULE}.time.sleep", + "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.sleep", side_effect=record_sleep, ): with pytest.raises(TimeoutError): From e1ae587eb27c08511c694549069c73a330902626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:31:52 +0800 Subject: [PATCH 18/24] Revert "fix: preserve async task kwargs and tighten realtime connect timeout" This reverts commit d6de0423edab3cd2a62136d1a87011678f32b575. --- .../qwen_tts_realtime/qwen_tts_realtime.py | 18 +--- dashscope/client/base_api.py | 2 +- tests/unit/test_async_task_wait_timeout.py | 4 - tests/unit/test_qwen_tts_realtime.py | 96 ------------------- 4 files changed, 5 insertions(+), 115 deletions(-) delete mode 100644 tests/unit/test_qwen_tts_realtime.py diff --git a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py index 2e46a6f..87fa290 100644 --- a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py +++ b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py @@ -153,29 +153,19 @@ def connect(self) -> None: self.thread.daemon = True self.thread.start() timeout = 5 - start_time = time.monotonic() + start_time = time.time() while ( not (self.ws.sock and self.ws.sock.connected) and not self.websocket_closed_event.is_set() + and (time.time() - start_time) < timeout ): - elapsed_seconds = time.monotonic() - start_time - if elapsed_seconds >= timeout: - break - time.sleep(min(0.1, timeout - elapsed_seconds)) + time.sleep(0.1) if not self._is_websocket_connected(): raise TimeoutError( "websocket connection could not established within 5s. " f"{self._build_connection_state_message()}", ) - while ( - not self.session_created_event.is_set() - and not self.websocket_closed_event.is_set() - ): - elapsed_seconds = time.monotonic() - start_time - if elapsed_seconds >= timeout: - break - time.sleep(min(0.1, timeout - elapsed_seconds)) - if not self.session_created_event.is_set(): + if not self.session_created_event.wait(timeout): raise TimeoutError( "websocket session could not be created within 5s. " f"{self._build_connection_state_message()}", diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 7fa0b8b..80dfa41 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -650,7 +650,7 @@ def call( workspace=workspace, **kwargs, ) - wait_kwargs = kwargs.copy() + wait_kwargs = {} if wait_timeout_seconds is not None: wait_kwargs["wait_timeout_seconds"] = wait_timeout_seconds response = cls.wait( diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py index e6c0bd6..e97a9a6 100644 --- a/tests/unit/test_async_task_wait_timeout.py +++ b/tests/unit/test_async_task_wait_timeout.py @@ -263,10 +263,6 @@ def test_base_async_call_excludes_wait_timeout_from_request( ] == 10 ) - assert ( - TimeoutCallTestAsyncApi.captured_wait_kwargs["custom_param"] - == "custom-value" - ) @pytest.mark.asyncio async def test_base_async_aio_call_excludes_wait_timeout_from_request( diff --git a/tests/unit/test_qwen_tts_realtime.py b/tests/unit/test_qwen_tts_realtime.py deleted file mode 100644 index 1320372..0000000 --- a/tests/unit/test_qwen_tts_realtime.py +++ /dev/null @@ -1,96 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -from unittest.mock import patch - -import pytest - -import dashscope -from dashscope.audio.qwen_tts_realtime.qwen_tts_realtime import ( - QwenTtsRealtime, -) - - -class FakeConnectedSock: - connected = True - - -class FakeWebSocketApp: - def __init__(self, *_args, on_close=None, **_kwargs): - self.sock = FakeConnectedSock() - self.on_close = on_close - - def run_forever(self): - pass - - -class FakeClosingWebSocketApp(FakeWebSocketApp): - def run_forever(self): - self.on_close(self, 401, "unauthorized") - - -class ImmediateThread: - def __init__(self, target): - self.target = target - self.daemon = False - - def start(self): - self.target() - - -class NoopThread: - def __init__(self, target): - self.target = target - self.daemon = False - - def start(self): - pass - - -@pytest.fixture(autouse=True) -def set_api_key(): - dashscope.api_key = "test-api-key" - - -class TestQwenTtsRealtimeConnect: - def test_connect_stops_waiting_when_websocket_closes(self): - realtime_client = QwenTtsRealtime(model="qwen-tts-realtime") - - with patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.websocket.WebSocketApp", - FakeClosingWebSocketApp, - ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.threading.Thread", - ImmediateThread, - ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.sleep", - ) as sleep_mock: - with pytest.raises(TimeoutError): - realtime_client.connect() - - sleep_mock.assert_not_called() - - def test_connect_session_wait_uses_remaining_timeout(self): - realtime_client = QwenTtsRealtime(model="qwen-tts-realtime") - sleep_durations = [] - - def record_sleep(duration): - sleep_durations.append(duration) - - with patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.websocket.WebSocketApp", - FakeWebSocketApp, - ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.threading.Thread", - NoopThread, - ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.monotonic", - side_effect=[100.0, 104.95, 105.0], - ), patch( - "dashscope.audio.qwen_tts_realtime.qwen_tts_realtime.time.sleep", - side_effect=record_sleep, - ): - with pytest.raises(TimeoutError): - realtime_client.connect() - - assert sleep_durations == [pytest.approx(0.05)] From 2cf91dfb61f6a043a5f786eda18794aa34881d94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:31:52 +0800 Subject: [PATCH 19/24] Revert "fix: respect remaining wait timeout and reset SSE event type" This reverts commit 7f90814f0f676d54e7d1e82899fb371725a08c5e. --- dashscope/api_entities/aiohttp_request.py | 5 - dashscope/client/base_api.py | 80 ++++++---------- dashscope/common/utils.py | 12 +-- tests/unit/test_async_task_wait_timeout.py | 74 --------------- tests/unit/test_sse_stream_event_type.py | 101 --------------------- 5 files changed, 28 insertions(+), 244 deletions(-) delete mode 100644 tests/unit/test_sse_stream_event_type.py diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index 65f9d1c..008bfa5 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -117,9 +117,6 @@ async def _handle_stream(self, response): if line: line = line.decode("utf8") line = line.rstrip("\n").rstrip("\r") - if not line: - event_type = None - continue if line.startswith("event:"): event_type = line[len("event:") :].strip() if event_type == "error": @@ -136,8 +133,6 @@ async def _handle_stream(self, response): break else: continue # ignore heartbeat... - else: - event_type = None except (aiohttp.ClientError, asyncio.TimeoutError): logger.exception( "Stream response interrupted while reading aiohttp SSE " diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 80dfa41..4ad3192 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -36,23 +36,6 @@ ) -def _get_wait_sleep_seconds( - task_id: str, - wait_seconds: int, - wait_timeout_seconds, - start_time: float, -): - if wait_timeout_seconds is None: - return wait_seconds - - remaining_timeout_seconds = wait_timeout_seconds - ( - time.monotonic() - start_time - ) - if remaining_timeout_seconds <= 0: - raise TimeoutException(f"Wait task {task_id} timeout.") - return min(wait_seconds, remaining_timeout_seconds) - - class AsyncAioTaskGetMixin: @classmethod async def _get( @@ -266,14 +249,13 @@ async def wait( return rsp else: logger.info("The task %s is %s", task_id, task_status) - await asyncio.sleep( - _get_wait_sleep_seconds( - task_id, - wait_seconds, - wait_timeout_seconds, - start_time, - ), - ) # 异步等待 + if ( + wait_timeout_seconds is not None + and time.monotonic() - start_time + >= wait_timeout_seconds + ): + raise TimeoutException(f"Wait task {task_id} timeout.") + await asyncio.sleep(wait_seconds) # 异步等待 elif rsp.status_code in REPEATABLE_STATUS: logger.warning( "Get task: %s temporary failure, " @@ -283,14 +265,12 @@ async def wait( rsp.code, rsp.message, ) - await asyncio.sleep( - _get_wait_sleep_seconds( - task_id, - wait_seconds, - wait_timeout_seconds, - start_time, - ), - ) # 异步等待 + if ( + wait_timeout_seconds is not None + and time.monotonic() - start_time >= wait_timeout_seconds + ): + raise TimeoutException(f"Wait task {task_id} timeout.") + await asyncio.sleep(wait_seconds) # 异步等待 else: return rsp @@ -861,14 +841,13 @@ def wait( return rsp else: logger.info("The task %s is %s", task_id, task_status) - time.sleep( - _get_wait_sleep_seconds( - task_id, - wait_seconds, - wait_timeout_seconds, - start_time, - ), - ) + if ( + wait_timeout_seconds is not None + and time.monotonic() - start_time + >= wait_timeout_seconds + ): + raise TimeoutException(f"Wait task {task_id} timeout.") + time.sleep(wait_seconds) elif rsp.status_code in REPEATABLE_STATUS: logger.warning( "Get task: %s temporary failure, " @@ -878,14 +857,12 @@ def wait( rsp.code, rsp.message, ) - time.sleep( - _get_wait_sleep_seconds( - task_id, - wait_seconds, - wait_timeout_seconds, - start_time, - ), - ) + if ( + wait_timeout_seconds is not None + and time.monotonic() - start_time >= wait_timeout_seconds + ): + raise TimeoutException(f"Wait task {task_id} timeout.") + time.sleep(wait_seconds) else: return rsp @@ -1530,9 +1507,6 @@ def _handle_stream(cls, response: requests.Response): if line: line = line.decode("utf8") line = line.rstrip("\n").rstrip("\r") - if not line: - event_type = None - continue if line.startswith("event:"): event_type = line[len("event:") :].strip() if event_type == "error": @@ -1549,8 +1523,6 @@ def _handle_stream(cls, response: requests.Response): break else: continue # ignore heartbeat... - else: - event_type = None except requests.exceptions.RequestException: logger.exception( "Stream response interrupted while reading SSE response, " diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index 51ce30a..f740c9b 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -239,9 +239,6 @@ def _handle_stream(response: requests.Response): if line: line = line.decode("utf8") line = line.rstrip("\n").rstrip("\r") - if not line: - eventType = None - continue if line.startswith("id:"): event_id = line[len("id:") :] event.id = event_id.strip() @@ -261,8 +258,8 @@ def _handle_stream(response: requests.Response): yield (is_error, status_code, event) if is_error: break - else: - eventType = None + else: + continue # ignore heartbeat... except requests.exceptions.RequestException: logger.exception( "Stream response interrupted while reading SSE response, " @@ -351,9 +348,6 @@ async def _handle_aio_stream(response): if line: line = line.decode("utf8") line = line.rstrip("\n").rstrip("\r") - if not line: - event_type = None - continue if line.startswith("event:"): event_type = line[len("event:") :].strip() if event_type == "error": @@ -370,8 +364,6 @@ async def _handle_aio_stream(response): break else: continue # ignore heartbeat... - else: - event_type = None except (aiohttp.ClientError, asyncio.TimeoutError): logger.exception( "Stream response interrupted while reading aiohttp SSE " diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py index e97a9a6..5421905 100644 --- a/tests/unit/test_async_task_wait_timeout.py +++ b/tests/unit/test_async_task_wait_timeout.py @@ -9,7 +9,6 @@ from dashscope.aigc.image_synthesis import ImageSynthesis from dashscope.aigc.video_synthesis import VideoSynthesis from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client import base_api from dashscope.client.base_api import BaseAsyncAioApi, BaseAsyncApi from dashscope.embeddings.batch_text_embedding import BatchTextEmbedding from dashscope.common.constants import TaskStatus @@ -109,42 +108,6 @@ def test_base_async_api_wait_accepts_string_timeout(self): wait_timeout_seconds="0", ) - def test_base_async_api_wait_sleep_does_not_exceed_remaining_timeout( - self, - ): - response = DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_status": TaskStatus.RUNNING}, - usage=None, - message="", - ) - - with patch.object( - TimeoutWaitTestAsyncApi, - "_get", - return_value=response, - ): - with patch.object( - base_api.time, - "monotonic", - side_effect=[100.0, 100.2, 100.2], - ): - with patch.object( - base_api.time, - "sleep", - side_effect=TimeoutException("stop test"), - ) as sleep_mock: - with pytest.raises(TimeoutException): - TimeoutWaitTestAsyncApi.wait( - "task-id", - wait_timeout_seconds=0.5, - ) - - sleep_mock.assert_called_once() - assert sleep_mock.call_args.args[0] == pytest.approx(0.3) - @pytest.mark.asyncio async def test_base_async_aio_api_wait_raises_timeout(self): response = DashScopeAPIResponse( @@ -189,43 +152,6 @@ async def test_base_async_aio_api_wait_accepts_string_timeout(self): wait_timeout_seconds="0", ) - @pytest.mark.asyncio - async def test_async_aio_wait_sleep_uses_remaining_timeout( - self, - ): - response = DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_status": TaskStatus.RUNNING}, - usage=None, - message="", - ) - - with patch.object( - TimeoutTestAsyncAioApi, - "_get", - AsyncMock(return_value=response), - ): - with patch.object( - base_api.time, - "monotonic", - side_effect=[100.0, 100.2, 100.2], - ): - with patch.object( - base_api.asyncio, - "sleep", - AsyncMock(side_effect=TimeoutException("stop test")), - ) as sleep_mock: - with pytest.raises(TimeoutException): - await TimeoutTestAsyncAioApi.wait( - "task-id", - wait_timeout_seconds=0.5, - ) - - sleep_mock.assert_awaited_once() - assert sleep_mock.await_args.args[0] == pytest.approx(0.3) - def test_base_async_call_does_not_pass_default_wait_timeout( self, ): diff --git a/tests/unit/test_sse_stream_event_type.py b/tests/unit/test_sse_stream_event_type.py deleted file mode 100644 index e8265ba..0000000 --- a/tests/unit/test_sse_stream_event_type.py +++ /dev/null @@ -1,101 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -# pylint: disable=protected-access - -from http import HTTPStatus - -import pytest - -from dashscope.api_entities.aiohttp_request import AioHttpRequest -from dashscope.client.base_api import StreamEventMixin -from dashscope.common.utils import _handle_aio_stream, _handle_stream - - -class FakeSyncSseResponse: - status_code = HTTPStatus.OK - headers = {} - - def iter_lines(self): - return iter( - [ - b"event: done", - b"data: ignored", - b"\n", - b"data: delivered", - ], - ) - - -class FakeAsyncSseContent: - def __init__(self): - self.lines = iter( - [ - b"event: done", - b"data: ignored", - b"\n", - b"data: delivered", - ], - ) - - def __aiter__(self): - return self - - async def __anext__(self): - try: - return next(self.lines) - except StopIteration as stop_iteration: - raise StopAsyncIteration from stop_iteration - - -class FakeAsyncSseResponse: - status = HTTPStatus.OK - headers = {} - - def __init__(self): - self.content = FakeAsyncSseContent() - - -class TestSseStreamEventType: - def test_common_sync_stream_resets_event_type_after_empty_line(self): - response = FakeSyncSseResponse() - - stream_items = list(_handle_stream(response)) - - assert len(stream_items) == 1 - assert stream_items[0][0] is False - assert stream_items[0][1] == HTTPStatus.BAD_REQUEST - assert stream_items[0][2].data == "delivered" - - def test_sync_stream_resets_event_type_after_empty_line(self): - response = FakeSyncSseResponse() - - stream_items = list(StreamEventMixin._handle_stream(response)) - - assert stream_items == [ - (False, HTTPStatus.INTERNAL_SERVER_ERROR, " delivered"), - ] - - @pytest.mark.asyncio - async def test_common_aio_stream_resets_event_type_after_empty_line(self): - response = FakeAsyncSseResponse() - - stream_items = [item async for item in _handle_aio_stream(response)] - - assert stream_items == [ - (False, HTTPStatus.BAD_REQUEST, " delivered"), - ] - - @pytest.mark.asyncio - async def test_aiohttp_request_stream_resets_event_type_after_empty_line( - self, - ): - response = FakeAsyncSseResponse() - - stream_items = [ - item - async for item in AioHttpRequest._handle_stream(None, response) - ] - - assert stream_items == [ - (False, HTTPStatus.BAD_REQUEST, " delivered"), - ] From 8cba4145dda075626e62df98999945175902e2fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:31:52 +0800 Subject: [PATCH 20/24] Revert "fix: handle local file URI hosts and string wait timeout" This reverts commit f3f0929ff1b7797eb904e84a45b4750969c866e1. --- dashscope/client/base_api.py | 4 -- dashscope/utils/oss_utils.py | 12 ++---- tests/unit/test_async_task_wait_timeout.py | 43 ---------------------- tests/unit/test_oss_utils.py | 42 --------------------- 4 files changed, 4 insertions(+), 97 deletions(-) diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 4ad3192..cd5b736 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -212,8 +212,6 @@ async def wait( DashScopeAPIResponse: The async task information. """ wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) - if wait_timeout_seconds is not None: - wait_timeout_seconds = float(wait_timeout_seconds) start_time = time.monotonic() task_id = cls._get_task_id(task) wait_seconds = 1 @@ -808,8 +806,6 @@ def wait( DashScopeAPIResponse: The async task information. """ wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) - if wait_timeout_seconds is not None: - wait_timeout_seconds = float(wait_timeout_seconds) start_time = time.monotonic() task_id = cls._get_task_id(task) wait_seconds = 1 diff --git a/dashscope/utils/oss_utils.py b/dashscope/utils/oss_utils.py index 27d2683..6b5e476 100644 --- a/dashscope/utils/oss_utils.py +++ b/dashscope/utils/oss_utils.py @@ -131,12 +131,8 @@ def get_upload_certificate( def _resolve_file_uri_path(file_uri: str): parse_result = urlparse(file_uri) - netloc = parse_result.netloc - if netloc.lower() in ("localhost", "127.0.0.1"): - netloc = "" - - if netloc: - file_path = netloc + unquote_plus(parse_result.path) + if parse_result.netloc: + file_path = parse_result.netloc + unquote_plus(parse_result.path) else: file_path = unquote_plus(parse_result.path) @@ -171,7 +167,7 @@ def upload_file( ) return file_url else: - raise InvalidInput(f"The file: {file_path} does not exist!") + raise InvalidInput(f"The file: {file_path} is not exists!") return None @@ -214,7 +210,7 @@ def check_and_upload_local( f"Uploading file: {content} failed", ) return True, file_url, cert - raise InvalidInput(f"The file: {file_path} does not exist!") + raise InvalidInput(f"The file: {file_path} is not exists!") if content.startswith("oss://"): return True, content, upload_certificate if not content.startswith("http"): diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py index 5421905..f9e4bb8 100644 --- a/tests/unit/test_async_task_wait_timeout.py +++ b/tests/unit/test_async_task_wait_timeout.py @@ -87,27 +87,6 @@ def test_base_async_api_wait_raises_timeout(self): with pytest.raises(TimeoutException): TimeoutWaitTestAsyncApi.wait("task-id", wait_timeout_seconds=0) - def test_base_async_api_wait_accepts_string_timeout(self): - response = DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_status": TaskStatus.RUNNING}, - usage=None, - message="", - ) - - with patch.object( - TimeoutWaitTestAsyncApi, - "_get", - return_value=response, - ): - with pytest.raises(TimeoutException): - TimeoutWaitTestAsyncApi.wait( - "task-id", - wait_timeout_seconds="0", - ) - @pytest.mark.asyncio async def test_base_async_aio_api_wait_raises_timeout(self): response = DashScopeAPIResponse( @@ -130,28 +109,6 @@ async def test_base_async_aio_api_wait_raises_timeout(self): wait_timeout_seconds=0, ) - @pytest.mark.asyncio - async def test_base_async_aio_api_wait_accepts_string_timeout(self): - response = DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_status": TaskStatus.RUNNING}, - usage=None, - message="", - ) - - with patch.object( - TimeoutTestAsyncAioApi, - "_get", - AsyncMock(return_value=response), - ): - with pytest.raises(TimeoutException): - await TimeoutTestAsyncAioApi.wait( - "task-id", - wait_timeout_seconds="0", - ) - def test_base_async_call_does_not_pass_default_wait_timeout( self, ): diff --git a/tests/unit/test_oss_utils.py b/tests/unit/test_oss_utils.py index 2d740ce..505d384 100644 --- a/tests/unit/test_oss_utils.py +++ b/tests/unit/test_oss_utils.py @@ -129,48 +129,6 @@ def fake_upload( assert file_url == "oss://test-dir/frame_0000.jpg" assert captured_file_path["value"] == "C:/Users/test/frame_0000.jpg" - @pytest.mark.parametrize( - "file_uri", - [ - "file://localhost/home/user/frame_0000.jpg", - "file://127.0.0.1/home/user/frame_0000.jpg", - ], - ) - def test_check_and_upload_local_treats_loopback_host_as_local_path( - self, - monkeypatch, - file_uri, - ): - captured_file_path = {} - - def fake_isfile(file_path): - captured_file_path["value"] = file_path - return True - - def fake_upload( - model, - file_path, - api_key, - upload_certificate, - ): - assert model == "test-model" - assert file_path == "/home/user/frame_0000.jpg" - assert api_key == "test-api-key" - return "oss://test-dir/frame_0000.jpg", upload_certificate - - monkeypatch.setattr(oss_utils.os.path, "isfile", fake_isfile) - monkeypatch.setattr(OssUtils, "upload", fake_upload) - - is_upload, file_url, _ = oss_utils.check_and_upload_local( - model="test-model", - content=file_uri, - api_key="test-api-key", - ) - - assert is_upload - assert file_url == "oss://test-dir/frame_0000.jpg" - assert captured_file_path["value"] == "/home/user/frame_0000.jpg" - def test_check_and_upload_local_raises_when_file_uri_not_found( self, monkeypatch, From 0324ff116d2b50beaea8d5932ec53a05b5d89bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:31:52 +0800 Subject: [PATCH 21/24] Revert "fix: improve qwen tts realtime websocket error handling" This reverts commit 652f5e9514b31d92579a3a1ee958c68c53de0bd7. --- .../qwen_tts_realtime/qwen_tts_realtime.py | 54 ++++--------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py index 87fa290..7142a66 100644 --- a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py +++ b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py @@ -97,19 +97,13 @@ def __init__( self.user_workspace = workspace self.model = model self.config = {} - self.callback = callback or QwenTtsRealtimeCallback() + self.callback = callback self.ws = None - self.thread = None self.session_id = None self.last_message = None self.last_response_id = None self.last_first_text_time = None self.last_first_audio_delay = None - self.last_error = None - self.close_status_code = None - self.close_msg = None - self.session_created_event = threading.Event() - self.websocket_closed_event = threading.Event() self.metrics = [] def _generate_event_id(self): @@ -137,11 +131,6 @@ def connect(self) -> None: """ connect to server, create session and return default session configuration # noqa: E501 """ - self.last_error = None - self.close_status_code = None - self.close_msg = None - self.session_created_event.clear() - self.websocket_closed_event.clear() self.ws = websocket.WebSocketApp( self.url, header=self._get_websocket_header(), @@ -152,45 +141,23 @@ def connect(self) -> None: self.thread = threading.Thread(target=self.ws.run_forever) self.thread.daemon = True self.thread.start() - timeout = 5 + timeout = 5 # 最长等待时间(秒) start_time = time.time() while ( not (self.ws.sock and self.ws.sock.connected) - and not self.websocket_closed_event.is_set() and (time.time() - start_time) < timeout ): - time.sleep(0.1) - if not self._is_websocket_connected(): + time.sleep(0.1) # 短暂休眠,避免密集轮询 + if not (self.ws.sock and self.ws.sock.connected): raise TimeoutError( "websocket connection could not established within 5s. " - f"{self._build_connection_state_message()}", - ) - if not self.session_created_event.wait(timeout): - raise TimeoutError( - "websocket session could not be created within 5s. " - f"{self._build_connection_state_message()}", + "Please check your network connection, firewall settings, or server status.", # noqa: E501 # pylint: disable=line-too-long ) self.callback.on_open() - def _is_websocket_connected(self): - return bool(self.ws and self.ws.sock and self.ws.sock.connected) - - def _build_connection_state_message(self): - return ( - f"close_status_code: {self.close_status_code}, " - f"close_msg: {self.close_msg}, " - f"last_error: {self.last_error}, " - f"last_message: {self.last_message}" - ) - def __send_str(self, data: str, enable_log: bool = True): if enable_log: logger.debug("[qwen tts realtime] send string: %s", data) - if not self._is_websocket_connected(): - raise ConnectionError( - "qwen tts realtime websocket connection is closed. " - f"{self._build_connection_state_message()}", - ) self.ws.send(data) def update_session( @@ -384,7 +351,6 @@ def on_message( # pylint: disable=unused-argument if "type" in message: if "session.created" == json_data["type"]: self.session_id = json_data["session"]["id"] - self.session_created_event.set() if "response.created" == json_data["type"]: self.last_response_id = json_data["response"]["id"] elif "response.audio.delta" == json_data["type"]: @@ -421,11 +387,8 @@ def on_close( # pylint: disable=unused-argument close_status_code, close_msg, ): - self.close_status_code = close_status_code - self.close_msg = close_msg - self.websocket_closed_event.set() logger.debug( - "[qwen tts realtime] connection closed with code %s and message %s", # noqa: E501 + "[omni realtime] connection closed with code %s and message %s", # noqa: E501 close_status_code, close_msg, ) @@ -433,8 +396,9 @@ def on_close( # pylint: disable=unused-argument # WebSocket发生错误的回调函数 def on_error(self, ws, error): # pylint: disable=unused-argument - self.last_error = error - logger.error("[qwen tts realtime] websocket closed due to %s", error) + print(f"websocket closed due to {error}") + # pylint: disable=broad-exception-raised + raise Exception(f"websocket closed due to {error}") # 获取上一个任务的taskId def get_session_id(self): From 44f334f0e4049ce6565cb434389dd3b675bd8af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:31:52 +0800 Subject: [PATCH 22/24] Revert "fix: improve streaming interruption diagnostics" This reverts commit cfa1adba317b5475dd95f1f6708036daede67797. --- README.md | 31 +------ dashscope/api_entities/aiohttp_request.py | 74 +++++---------- dashscope/api_entities/http_request.py | 49 ++-------- dashscope/client/base_api.py | 77 +++++++-------- dashscope/common/utils.py | 108 +++++++++------------- 5 files changed, 110 insertions(+), 229 deletions(-) diff --git a/README.md b/README.md index 38946fb..6190b4e 100644 --- a/README.md +++ b/README.md @@ -241,35 +241,18 @@ response = TextReRank.call( ### Image Generation -Image and video generation APIs use server-side asynchronous tasks: - -- `async_call()` submits a task and returns task information immediately. It is not a Python `async` coroutine. -- `call()` submits a task and blocks by polling task status until the task finishes. -- Use `fetch()` to query task status manually, or `wait()` to block until completion. -- Use `wait_timeout_seconds` with blocking calls to limit the maximum wait time. - ```python from dashscope import ImageSynthesis -# Submit a server-side async task +# Async task pattern response = ImageSynthesis.async_call( model="wanx-v1", prompt="A serene mountain landscape at sunset", ) -# Query task status manually -status = ImageSynthesis.fetch(response) - -# Or wait for result +# Wait for result result = ImageSynthesis.wait(response) -# Blocking call with timeout -result = ImageSynthesis.call( - model="wanx-v1", - prompt="A serene mountain landscape at sunset", - wait_timeout_seconds=60, -) - # Sync call (for wan2.2-t2i-flash/plus) result = ImageSynthesis.sync_call( model="wan2.2-t2i-flash", @@ -282,21 +265,13 @@ result = ImageSynthesis.sync_call( ```python from dashscope import VideoSynthesis -# Submit a server-side async task +# Text-to-video response = VideoSynthesis.async_call( model="wan2.7-t2v", prompt="A cat playing with a ball of yarn", ) -# Wait for result result = VideoSynthesis.wait(response) - -# Blocking call with timeout -result = VideoSynthesis.call( - model="wan2.7-t2v", - prompt="A cat playing with a ball of yarn", - wait_timeout_seconds=60, -) ``` ### Speech Synthesis (TTS) diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index 008bfa5..275115f 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -import asyncio import json from http import HTTPStatus @@ -109,38 +108,25 @@ async def aio_call(self): return result async def _handle_stream(self, response): + # TODO define done message. is_error = False status_code = HTTPStatus.BAD_REQUEST - event_type = None - try: - async for line in response.content: - if line: - line = line.decode("utf8") - line = line.rstrip("\n").rstrip("\r") - if line.startswith("event:"): - event_type = line[len("event:") :].strip() - if event_type == "error": - is_error = True - elif line.startswith("status:"): - status_code = line[len("status:") :] - status_code = int(status_code.strip()) - elif line.startswith("data:"): - line = line[len("data:") :] - if event_type == "done": - continue - yield (is_error, status_code, line) - if is_error: - break - else: - continue # ignore heartbeat... - except (aiohttp.ClientError, asyncio.TimeoutError): - logger.exception( - "Stream response interrupted while reading aiohttp SSE " - "response, status_code=%s, request_id=%s", - response.status, - response.headers.get("X-Request-Id"), - ) - raise + async for line in response.content: + if line: + line = line.decode("utf8") + line = line.rstrip("\n").rstrip("\r") + if line.startswith("event:error"): + is_error = True + elif line.startswith("status:"): + status_code = line[len("status:") :] + status_code = int(status_code.strip()) + elif line.startswith("data:"): + line = line[len("data:") :] + yield (is_error, status_code, line) + if is_error: + break + else: + continue # ignore heartbeat... # pylint: disable=too-many-statements async def _handle_response( # pylint: disable=too-many-branches @@ -297,23 +283,9 @@ async def _handle_request(self): async with response: async for rsp in self._handle_response(response): yield rsp - except (aiohttp.ClientError, asyncio.TimeoutError): - logger.exception( - "Aio HTTP request failed, url=%s, method=%s, stream=%s, " - "timeout=%s", - self.url, - self.method, - self.stream, - self.timeout, - ) - raise - except Exception: - logger.exception( - "Unexpected aio HTTP request error, url=%s, method=%s, " - "stream=%s, timeout=%s", - self.url, - self.method, - self.stream, - self.timeout, - ) - raise + except aiohttp.ClientConnectorError as e: + logger.error(e) + raise e + except Exception as e: + logger.error(e) + raise e diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 203092c..8506b2c 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -225,26 +225,12 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches # Only close if we created the session if should_close: await session.close() - except aiohttp.ClientError: - logger.exception( - "Aio HTTP request failed, url=%s, method=%s, stream=%s, " - "timeout=%s", - self.url, - self.method, - self.stream, - self.timeout, - ) - raise - except Exception: - logger.exception( - "Unexpected aio HTTP request error, url=%s, method=%s, " - "stream=%s, timeout=%s", - self.url, - self.method, - self.stream, - self.timeout, - ) - raise + except aiohttp.ClientConnectorError as e: + logger.error(e) + raise e + except BaseException as e: + logger.error(e) + raise e @staticmethod def __handle_parameters(params: dict) -> dict: @@ -523,23 +509,6 @@ def _handle_request(self): # Only close if we created the session if should_close: session.close() - except requests.exceptions.RequestException: - logger.exception( - "HTTP request failed, url=%s, method=%s, stream=%s, " - "timeout=%s", - self.url, - self.method, - self.stream, - self.timeout, - ) - raise - except Exception: - logger.exception( - "Unexpected HTTP request error, url=%s, method=%s, " - "stream=%s, timeout=%s", - self.url, - self.method, - self.stream, - self.timeout, - ) - raise + except BaseException as e: + logger.error(e) + raise e diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index cd5b736..38a0c35 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -454,7 +454,7 @@ async def call( function (str, optional): The function of the task. Defaults to None. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. api_protocol (str, optional): Api protocol websocket or http. Defaults to None. ws_stream_mode (str, optional): websocket stream mode, @@ -520,7 +520,7 @@ def call( function (str, optional): The function of the task. Defaults to None. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. api_protocol (str, optional): Api protocol websocket or http. Defaults to None. ws_stream_mode (str, optional): websocket stream mode, @@ -818,8 +818,8 @@ def wait( # the query interval after every 3(increment_steps) # intervals, until we hit the max waiting interval # of 5(seconds) - # Polling is used here because the task status API returns the - # current state for each request. + # TODO: investigate if we can use long-poll + # (server side return immediately when ready) if wait_seconds < max_wait_seconds and step % increment_steps == 0: wait_seconds = min(wait_seconds * 2, max_wait_seconds) rsp = cls._get(task_id, api_key, workspace=workspace, **kwargs) @@ -884,7 +884,7 @@ def async_call( function (str, optional): The function of the task. Defaults to None. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The async task information, @@ -1027,7 +1027,7 @@ def list( Args: api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. path (str, optional): The path of the api, if not default. page_no (int, optional): Page number. Defaults to 1. page_size (int, optional): Items per page. Defaults to 10. @@ -1103,7 +1103,7 @@ def get( Args: target (str): The target to get, such as model_id. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The object information in output. @@ -1144,7 +1144,7 @@ def get( Args: target (str): The target to get, such as model_id. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The object information in output. @@ -1184,7 +1184,7 @@ def delete( Args: target (str): The object to delete, . api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The delete result. @@ -1233,7 +1233,7 @@ def call( Args: data (object): The create request json body. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The created object in output. @@ -1292,7 +1292,7 @@ def update( target (str): The target to update. json (object): The create request json body. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The updated object information in output. @@ -1356,7 +1356,7 @@ def put( target (str): The target to update. json (object): The create request json body. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The updated object information in output. @@ -1408,7 +1408,7 @@ def upload( # pylint: disable=unused-argument descriptions (list[str]): The file description messages. params (dict): The parameters api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The uploaded file information in the output. @@ -1458,7 +1458,7 @@ def cancel( Args: target (str): The request params, key/value map. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The cancel result. @@ -1495,38 +1495,25 @@ def cancel( class StreamEventMixin: @classmethod def _handle_stream(cls, response: requests.Response): + # TODO define done message. is_error = False status_code = HTTPStatus.INTERNAL_SERVER_ERROR - event_type = None - try: - for line in response.iter_lines(): - if line: - line = line.decode("utf8") - line = line.rstrip("\n").rstrip("\r") - if line.startswith("event:"): - event_type = line[len("event:") :].strip() - if event_type == "error": - is_error = True - elif line.startswith("status:"): - status_code = line[len("status:") :] - status_code = int(status_code.strip()) - elif line.startswith("data:"): - line = line[len("data:") :] - if event_type == "done": - continue - yield (is_error, status_code, line) - if is_error: - break - else: - continue # ignore heartbeat... - except requests.exceptions.RequestException: - logger.exception( - "Stream response interrupted while reading SSE response, " - "status_code=%s, request_id=%s", - response.status_code, - response.headers.get("X-Request-Id"), - ) - raise + for line in response.iter_lines(): + if line: + line = line.decode("utf8") + line = line.rstrip("\n").rstrip("\r") + if line.startswith("event:error"): + is_error = True + elif line.startswith("status:"): + status_code = line[len("status:") :] + status_code = int(status_code.strip()) + elif line.startswith("data:"): + line = line[len("data:") :] + yield (is_error, status_code, line) + if is_error: + break + else: + continue # ignore heartbeat... @classmethod def _handle_response(cls, response: requests.Response): @@ -1582,7 +1569,7 @@ def stream_events( Args: target (str): The target to get, such as model_id. api_key (str, optional): The api api_key, if not present, - will use the default API key resolution rule. Defaults to None. + will get by default rule(TODO: api key doc). Defaults to None. Returns: DashScopeAPIResponse: The target outputs. diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index f740c9b..d568446 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -229,45 +229,36 @@ def __init__( # pylint: disable=redefined-builtin def _handle_stream(response: requests.Response): + # TODO define done message. is_error = False status_code = HTTPStatus.BAD_REQUEST event = SSEEvent(None, None, None) # type: ignore[arg-type] eventType = None - - try: - for line in response.iter_lines(): - if line: - line = line.decode("utf8") - line = line.rstrip("\n").rstrip("\r") - if line.startswith("id:"): - event_id = line[len("id:") :] - event.id = event_id.strip() - elif line.startswith("event:"): - eventType = line[len("event:") :].strip() - event.eventType = eventType - if eventType == "error": - is_error = True - elif line.startswith("status:"): - status_code = line[len("status:") :] - status_code = int(status_code.strip()) - elif line.startswith("data:"): - line = line[len("data:") :] - event.data = line.strip() - if eventType is not None and eventType == "done": - continue - yield (is_error, status_code, event) - if is_error: - break - else: - continue # ignore heartbeat... - except requests.exceptions.RequestException: - logger.exception( - "Stream response interrupted while reading SSE response, " - "status_code=%s, request_id=%s", - response.status_code, - response.headers.get("X-Request-Id"), - ) - raise + for line in response.iter_lines(): + if line: + line = line.decode("utf8") + line = line.rstrip("\n").rstrip("\r") + if line.startswith("id:"): + id = line[len("id:") :] # pylint: disable=redefined-builtin + event.id = id.strip() + elif line.startswith("event:"): + eventType = line[len("event:") :] + event.eventType = eventType.strip() + if eventType == "error": + is_error = True + elif line.startswith("status:"): + status_code = line[len("status:") :] + status_code = int(status_code.strip()) + elif line.startswith("data:"): + line = line[len("data:") :] + event.data = line.strip() + if eventType is not None and eventType == "done": + continue + yield (is_error, status_code, event) + if is_error: + break + else: + continue # ignore heartbeat... def _handle_error_message(error, status_code, flattened_output, headers): @@ -340,38 +331,25 @@ def _handle_http_failed_response( async def _handle_aio_stream(response): + # TODO define done message. is_error = False status_code = HTTPStatus.BAD_REQUEST - event_type = None - try: - async for line in response.content: - if line: - line = line.decode("utf8") - line = line.rstrip("\n").rstrip("\r") - if line.startswith("event:"): - event_type = line[len("event:") :].strip() - if event_type == "error": - is_error = True - elif line.startswith("status:"): - status_code = line[len("status:") :] - status_code = int(status_code.strip()) - elif line.startswith("data:"): - line = line[len("data:") :] - if event_type == "done": - continue - yield (is_error, status_code, line) - if is_error: - break - else: - continue # ignore heartbeat... - except (aiohttp.ClientError, asyncio.TimeoutError): - logger.exception( - "Stream response interrupted while reading aiohttp SSE " - "response, status_code=%s, request_id=%s", - response.status, - response.headers.get("X-Request-Id"), - ) - raise + async for line in response.content: + if line: + line = line.decode("utf8") + line = line.rstrip("\n").rstrip("\r") + if line.startswith("event:error"): + is_error = True + elif line.startswith("status:"): + status_code = line[len("status:") :] + status_code = int(status_code.strip()) + elif line.startswith("data:"): + line = line[len("data:") :] + yield (is_error, status_code, line) + if is_error: + break + else: + continue # ignore heartbeat... async def _handle_aiohttp_failed_response( From 03a84e0d04b31f3bf927e7313d44422c15e446b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:31:52 +0800 Subject: [PATCH 23/24] Revert "feat: add global trust_env config for aiohttp sessions" This reverts commit dbbe5b27505a0508e691b479e42bf7802310ccb5. --- dashscope/__init__.py | 2 - dashscope/api_entities/aiohttp_request.py | 2 - dashscope/api_entities/http_request.py | 2 - dashscope/common/env.py | 12 ------ tests/unit/test_async_custom_session.py | 52 ----------------------- 5 files changed, 70 deletions(-) diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 19a32ca..92cd259 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -28,7 +28,6 @@ base_compatible_api_url, base_http_api_url, base_websocket_api_url, - trust_env, ) from dashscope.finetune.deployments import Deployments from dashscope.finetune.finetunes import FineTunes @@ -75,7 +74,6 @@ "base_websocket_api_url", "api_key", "api_key_file_path", - "trust_env", "save_api_key", "AioGeneration", "Conversation", diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index 275115f..75b3965 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -13,7 +13,6 @@ SSE_CONTENT_TYPE, HTTPMethod, ) -from dashscope.common.env import get_trust_env from dashscope.common.error import UnsupportedHTTPMethod from dashscope.common.logging import logger from dashscope.common.utils import async_to_sync @@ -250,7 +249,6 @@ async def _handle_request(self): async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=self.timeout), headers=self.headers, - trust_env=get_trust_env(), ) as session: logger.debug("Starting request: %s", self.url) if self.method == HTTPMethod.POST: diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 8506b2c..85ee959 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -18,7 +18,6 @@ HTTPMethod, ) from dashscope.common.error import UnsupportedHTTPMethod -from dashscope.common.env import get_trust_env from dashscope.common.logging import logger from dashscope.common.utils import ( _handle_aio_stream, @@ -177,7 +176,6 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches connector=connector, timeout=aiohttp.ClientTimeout(total=self.timeout), headers=self.headers, - trust_env=get_trust_env(), ) should_close = True diff --git a/dashscope/common/env.py b/dashscope/common/env.py index 8498cc6..bf9a0f9 100644 --- a/dashscope/common/env.py +++ b/dashscope/common/env.py @@ -2,7 +2,6 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import os -import sys from dashscope.common.constants import ( DASHSCOPE_API_KEY_ENV, @@ -16,12 +15,6 @@ # read the api key from env api_key = os.environ.get(DASHSCOPE_API_KEY_ENV) api_key_file_path = os.environ.get(DASHSCOPE_API_KEY_FILE_PATH_ENV) -trust_env = os.environ.get("DASHSCOPE_TRUST_ENV", "true").lower() in ( - "true", - "1", - "yes", -) - # define api base url, ensure end / base_http_api_url = os.environ.get( @@ -36,8 +29,3 @@ "DASHSCOPE_COMPATIBLE_BASE_URL", f"https://dashscope.aliyuncs.com/compatible-mode/{api_version}", ) - - -def get_trust_env() -> bool: - dashscope_module = sys.modules.get("dashscope") - return bool(getattr(dashscope_module, "trust_env", trust_env)) diff --git a/tests/unit/test_async_custom_session.py b/tests/unit/test_async_custom_session.py index 012b77e..180b478 100644 --- a/tests/unit/test_async_custom_session.py +++ b/tests/unit/test_async_custom_session.py @@ -24,7 +24,6 @@ import certifi import pytest -import dashscope from dashscope.api_entities.http_request import HttpRequest from dashscope.api_entities.api_request_data import ApiRequestData from dashscope.common.constants import ApiProtocol, HTTPMethod @@ -578,57 +577,6 @@ async def mock_handle_response(_response): # 验证临时 aio_session 被关闭(原有行为) mock_session.close.assert_called_once() - @pytest.mark.asyncio - async def test_temporary_aio_session_uses_global_trust_env(self): - """测试临时 aio_session 会使用全局 trust_env 配置""" - mock_session = AsyncMock() - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.headers = {"content-type": "application/json"} - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock(return_value=None) - mock_session.request.return_value = mock_response - - http_request = HttpRequest( - url="http://example.com/api", - api_key="fake-api-key", - http_method=HTTPMethod.POST, - stream=False, - ) - http_request.data = ApiRequestData( - model="test-model", - task_group="test", - task="test", - function="test", - input_data={"test": "data"}, - form=None, - is_binary_input=False, - api_protocol=ApiProtocol.HTTPS, - ) - - original_trust_env = dashscope.trust_env - dashscope.trust_env = False - try: - - async def mock_handle_response(_response): - yield mock_response - - with patch( - "aiohttp.ClientSession", - return_value=mock_session, - ) as session_class: - with patch.object( - http_request, - "_handle_aio_response", - side_effect=mock_handle_response, - ): - _ = await http_request.aio_call() - - session_class.assert_called_once() - assert session_class.call_args.kwargs["trust_env"] is False - finally: - dashscope.trust_env = original_trust_env - class TestAsyncSessionLifecycle: """测试异步 Session 生命周期""" From 6536dffc242e0b66843034976657da9d1031a452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=86=E5=BC=80=E9=97=BB1?= <16583921+lu-kaiwen-1@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 17:33:46 +0800 Subject: [PATCH 24/24] Revert "fix: add timeout guard for async task polling" This reverts commit 2676efd9902899885a7ae9c3672c6388f580b98e. --- dashscope/client/base_api.py | 44 +--- tests/unit/test_async_task_wait_timeout.py | 226 --------------------- 2 files changed, 2 insertions(+), 268 deletions(-) delete mode 100644 tests/unit/test_async_task_wait_timeout.py diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 38a0c35..cc52fa2 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -20,12 +20,7 @@ TaskStatus, HTTPMethod, ) -from dashscope.common.error import ( - InvalidParameter, - InvalidTask, - ModelRequired, - TimeoutException, -) +from dashscope.common.error import InvalidParameter, InvalidTask, ModelRequired from dashscope.common.logging import logger from dashscope.common.utils import ( _handle_http_failed_response, @@ -153,7 +148,6 @@ async def call( workspace: str = None, **kwargs, ) -> DashScopeAPIResponse: - wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) # call request service. response = await BaseAsyncAioApi.async_call( model, @@ -165,14 +159,11 @@ async def call( workspace, **kwargs, ) - wait_kwargs = kwargs.copy() - if wait_timeout_seconds is not None: - wait_kwargs["wait_timeout_seconds"] = wait_timeout_seconds response = await BaseAsyncAioApi.wait( response, api_key=api_key, workspace=workspace, - **wait_kwargs, + **kwargs, ) return response @@ -211,8 +202,6 @@ async def wait( Returns: DashScopeAPIResponse: The async task information. """ - wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) - start_time = time.monotonic() task_id = cls._get_task_id(task) wait_seconds = 1 max_wait_seconds = 5 @@ -247,12 +236,6 @@ async def wait( return rsp else: logger.info("The task %s is %s", task_id, task_status) - if ( - wait_timeout_seconds is not None - and time.monotonic() - start_time - >= wait_timeout_seconds - ): - raise TimeoutException(f"Wait task {task_id} timeout.") await asyncio.sleep(wait_seconds) # 异步等待 elif rsp.status_code in REPEATABLE_STATUS: logger.warning( @@ -263,11 +246,6 @@ async def wait( rsp.code, rsp.message, ) - if ( - wait_timeout_seconds is not None - and time.monotonic() - start_time >= wait_timeout_seconds - ): - raise TimeoutException(f"Wait task {task_id} timeout.") await asyncio.sleep(wait_seconds) # 异步等待 else: return rsp @@ -621,21 +599,16 @@ def call( **kwargs, ) -> DashScopeAPIResponse: """Call service and get result.""" - wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) task_response = cls.async_call( # type: ignore[misc] *args, api_key=api_key, workspace=workspace, **kwargs, ) - wait_kwargs = {} - if wait_timeout_seconds is not None: - wait_kwargs["wait_timeout_seconds"] = wait_timeout_seconds response = cls.wait( task_response, api_key=api_key, workspace=workspace, - **wait_kwargs, ) return response @@ -805,8 +778,6 @@ def wait( Returns: DashScopeAPIResponse: The async task information. """ - wait_timeout_seconds = kwargs.pop("wait_timeout_seconds", None) - start_time = time.monotonic() task_id = cls._get_task_id(task) wait_seconds = 1 max_wait_seconds = 5 @@ -837,12 +808,6 @@ def wait( return rsp else: logger.info("The task %s is %s", task_id, task_status) - if ( - wait_timeout_seconds is not None - and time.monotonic() - start_time - >= wait_timeout_seconds - ): - raise TimeoutException(f"Wait task {task_id} timeout.") time.sleep(wait_seconds) elif rsp.status_code in REPEATABLE_STATUS: logger.warning( @@ -853,11 +818,6 @@ def wait( rsp.code, rsp.message, ) - if ( - wait_timeout_seconds is not None - and time.monotonic() - start_time >= wait_timeout_seconds - ): - raise TimeoutException(f"Wait task {task_id} timeout.") time.sleep(wait_seconds) else: return rsp diff --git a/tests/unit/test_async_task_wait_timeout.py b/tests/unit/test_async_task_wait_timeout.py deleted file mode 100644 index f9e4bb8..0000000 --- a/tests/unit/test_async_task_wait_timeout.py +++ /dev/null @@ -1,226 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -from http import HTTPStatus -from unittest.mock import AsyncMock, patch - -import pytest - -from dashscope.aigc.image_synthesis import ImageSynthesis -from dashscope.aigc.video_synthesis import VideoSynthesis -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import BaseAsyncAioApi, BaseAsyncApi -from dashscope.embeddings.batch_text_embedding import BatchTextEmbedding -from dashscope.common.constants import TaskStatus -from dashscope.common.error import TimeoutException - - -class TimeoutWaitTestAsyncApi(BaseAsyncApi): - pass - - -class TimeoutCallTestAsyncApi(BaseAsyncApi): - captured_async_call_kwargs = {} - captured_wait_kwargs = {} - - @classmethod - def async_call(cls, *_args, **kwargs): - cls.captured_async_call_kwargs = kwargs - return DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_id": "task-id"}, - usage=None, - message="", - ) - - @classmethod - def wait(cls, task, api_key=None, workspace=None, **kwargs): - cls.captured_wait_kwargs = kwargs - return task - - -class LegacyWaitSignatureTestAsyncApi(BaseAsyncApi): - @classmethod - def async_call(cls, *_args, **_kwargs): - return DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_id": "task-id"}, - usage=None, - message="", - ) - - @classmethod - def wait(cls, task, api_key=None, workspace=None): - return task - - -class TimeoutTestAsyncAioApi(BaseAsyncAioApi): - pass - - -@pytest.fixture(autouse=True) -def reset_timeout_test_api(): - TimeoutCallTestAsyncApi.captured_async_call_kwargs = {} - TimeoutCallTestAsyncApi.captured_wait_kwargs = {} - - -class TestAsyncTaskWaitTimeout: - def test_base_async_api_wait_raises_timeout(self): - response = DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_status": TaskStatus.RUNNING}, - usage=None, - message="", - ) - - with patch.object( - TimeoutWaitTestAsyncApi, - "_get", - return_value=response, - ): - with pytest.raises(TimeoutException): - TimeoutWaitTestAsyncApi.wait("task-id", wait_timeout_seconds=0) - - @pytest.mark.asyncio - async def test_base_async_aio_api_wait_raises_timeout(self): - response = DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_status": TaskStatus.RUNNING}, - usage=None, - message="", - ) - - with patch.object( - TimeoutTestAsyncAioApi, - "_get", - AsyncMock(return_value=response), - ): - with pytest.raises(TimeoutException): - await TimeoutTestAsyncAioApi.wait( - "task-id", - wait_timeout_seconds=0, - ) - - def test_base_async_call_does_not_pass_default_wait_timeout( - self, - ): - response = LegacyWaitSignatureTestAsyncApi.call( - "model", - "input", - api_key="api-key", - ) - - assert response.output["task_id"] == "task-id" - - def test_base_async_call_excludes_wait_timeout_from_request( - self, - ): - response = TimeoutCallTestAsyncApi.call( - "model", - "input", - api_key="api-key", - wait_timeout_seconds=10, - custom_param="custom-value", - ) - - assert response.output["task_id"] == "task-id" - assert ( - "wait_timeout_seconds" - not in TimeoutCallTestAsyncApi.captured_async_call_kwargs - ) - assert ( - TimeoutCallTestAsyncApi.captured_async_call_kwargs["custom_param"] - == "custom-value" - ) - assert ( - TimeoutCallTestAsyncApi.captured_wait_kwargs[ - "wait_timeout_seconds" - ] - == 10 - ) - - @pytest.mark.asyncio - async def test_base_async_aio_call_excludes_wait_timeout_from_request( - self, - ): - async_call_response = DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_id": "task-id"}, - usage=None, - message="", - ) - wait_response = DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.OK, - code=None, - output={"task_status": TaskStatus.SUCCEEDED}, - usage=None, - message="", - ) - - with patch.object( - BaseAsyncAioApi, - "async_call", - AsyncMock(return_value=async_call_response), - ) as async_call_mock: - with patch.object( - BaseAsyncAioApi, - "wait", - AsyncMock(return_value=wait_response), - ) as wait_mock: - response = await BaseAsyncAioApi.call( - "model", - "input", - "task-group", - api_key="api-key", - wait_timeout_seconds=10, - custom_param="custom-value", - ) - - assert response is wait_response - assert "wait_timeout_seconds" not in async_call_mock.call_args.kwargs - assert ( - async_call_mock.call_args.kwargs["custom_param"] == "custom-value" - ) - assert wait_mock.call_args.kwargs["wait_timeout_seconds"] == 10 - - @pytest.mark.parametrize( - "api_class", - [ImageSynthesis, VideoSynthesis, BatchTextEmbedding], - ) - def test_overridden_wait_accepts_wait_timeout( - self, - api_class, - ): - wait_response = DashScopeAPIResponse( - request_id="request-id", - status_code=HTTPStatus.BAD_REQUEST, - code="InvalidParameter", - output=None, - usage=None, - message="invalid parameter", - ) - - with patch.object( - BaseAsyncApi, - "wait", - return_value=wait_response, - ) as wait_mock: - response = api_class.wait( - "task-id", - api_key="api-key", - wait_timeout_seconds=10, - ) - - assert response.status_code == HTTPStatus.BAD_REQUEST - assert wait_mock.call_args.kwargs["wait_timeout_seconds"] == 10