From a908992f3c32653b671eff1480efab0e4a1e2702 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 9 Jul 2026 14:18:39 +0800 Subject: [PATCH 01/14] 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() From eabf06c3f9a293ff9ba338f03c0af13818701bf2 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 10 Jul 2026 19:14:17 +0800 Subject: [PATCH 02/14] refactor: improve error handling consistency and preserve original API error data --- dashscope/api_entities/aiohttp_request.py | 7 +- dashscope/api_entities/http_request.py | 4 +- dashscope/api_entities/websocket_request.py | 2 +- dashscope/cli/agentic_rl.py | 44 +-- dashscope/cli/common.py | 340 +++++++++++++++++--- dashscope/cli/generation.py | 1 + dashscope/client/base_api.py | 6 +- dashscope/common/utils.py | 60 ++-- tests/unit/test_cli_common.py | 83 ++--- tests/unit/test_cli_main.py | 2 +- verify_error_handling.py | 240 -------------- 11 files changed, 418 insertions(+), 371 deletions(-) delete mode 100644 verify_error_handling.py diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index d220ffb..2358f58 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -162,14 +162,15 @@ async def _handle_response( # pylint: disable=too-many-branches if "request_id" in msg: request_id = msg["request_id"] except json.JSONDecodeError: + msg = None yield DashScopeAPIResponse( request_id=request_id, status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - code="Unknown", + code=None, message=data, ) continue - if is_error: + if is_error and msg is not None: yield DashScopeAPIResponse( request_id=request_id, status_code=status_code, @@ -249,7 +250,7 @@ async def _handle_response( # pylint: disable=too-many-branches yield DashScopeAPIResponse( request_id=request_id, status_code=response.status, - code="Unknown", + code=None, message=msg.decode("utf-8"), ) diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 9a7f45d..1bcc6b5 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -293,7 +293,7 @@ async def _handle_aio_response( # pylint: disable=too-many-branches, too-many-s yield DashScopeAPIResponse( request_id=request_id, status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - code="Unknown", + code=None, message=data, headers=headers, ) @@ -403,7 +403,7 @@ def _handle_response( # pylint: disable=too-many-branches request_id=request_id, status_code=HTTPStatus.BAD_REQUEST, output=None, - code="Unknown", + code=None, message=data, headers=headers, ) diff --git a/dashscope/api_entities/websocket_request.py b/dashscope/api_entities/websocket_request.py index e277c1d..0979978 100644 --- a/dashscope/api_entities/websocket_request.py +++ b/dashscope/api_entities/websocket_request.py @@ -230,7 +230,7 @@ async def connection_handler( yield DashScopeAPIResponse( request_id=task_id, status_code=code, - code=f"http_{code}" if code else "websocket_handshake_error", + code=f"WS_HANDSHAKE_{code}" if code else "WS_HANDSHAKE_FAILED", message=msg, ) except BaseException as e: diff --git a/dashscope/cli/agentic_rl.py b/dashscope/cli/agentic_rl.py index 803c9f6..3a157b1 100644 --- a/dashscope/cli/agentic_rl.py +++ b/dashscope/cli/agentic_rl.py @@ -24,7 +24,11 @@ ) from dashscope.finetune.reinforcement.common.errors import OutputError from dashscope.finetune.customize_types import FineTune -from dashscope.cli.common import error, normalize_local_path_or_url +from dashscope.cli.common import ( + error, + normalize_local_path_or_url, + _handle_exception, +) app = typer.Typer( @@ -215,9 +219,7 @@ async def _register_fc_async( } except Exception as e: - root = _root_cause(e) - err_console.print(f"[red]❌ FC registration failed: {root}[/red]") - raise typer.Exit(1) + _handle_exception(e, "FC registration failed", err_console) @app.command("register-functions", hidden=True) @@ -292,9 +294,7 @@ async def _test_fc_async( return result except Exception as e: - root = _root_cause(e) - err_console.print(f"[red]❌ Function test failed: {root}[/red]") - raise typer.Exit(1) + _handle_exception(e, "Function test failed", err_console) @app.command("test-functions", hidden=True) @@ -370,9 +370,7 @@ async def _upload_data_async( } except Exception as e: - root = _root_cause(e) - err_console.print(f"[red]❌ Dataset upload failed: {root}[/red]") - raise typer.Exit(1) + _handle_exception(e, "Dataset upload failed", err_console) @app.command("upload-data", hidden=True) @@ -567,24 +565,22 @@ def run( ) except Exception as e: - root = _root_cause(e) label = ( "Validation error" if isinstance(e, ValueError) else "Workflow execution failed" ) - err_console.print(f"[red]❌ {label}: {root}[/red]") + _handle_exception(e, label, err_console) if _cli_verbose: err_console.print( "".join( tb_module.format_exception( - type(root), - root, - root.__traceback__, + type(e), + e, + e.__traceback__, ), ), ) - raise typer.Exit(1) @app.command() @@ -631,9 +627,7 @@ def get( fmt=output_format, ) except Exception as e: - root = _root_cause(e) - err_console.print(f"[red]❌ Query failed: {root}[/red]") - raise typer.Exit(1) + _handle_exception(e, "Query failed", err_console) @app.command() @@ -669,9 +663,7 @@ def cancel( f"[green]✅ Job {job_id} canceled successfully[/green]", ) except Exception as e: - root = _root_cause(e) - err_console.print(f"[red]❌ Cancellation failed: {root}[/red]") - raise typer.Exit(1) + _handle_exception(e, "Cancellation failed", err_console) @app.command() @@ -722,9 +714,7 @@ def logs( fmt=output_format, ) except Exception as e: - root = _root_cause(e) - err_console.print(f"[red]❌ Log retrieval failed: {root}[/red]") - raise typer.Exit(1) + _handle_exception(e, "Log retrieval failed", err_console) @app.command("list") @@ -768,9 +758,7 @@ def list_jobs( ) format_output(output_data, fmt=output_format) except Exception as e: - root = _root_cause(e) - err_console.print(f"[red]❌ List query failed: {root}[/red]") - raise typer.Exit(1) + _handle_exception(e, "List query failed", err_console) # if __name__ == "__main__": diff --git a/dashscope/cli/common.py b/dashscope/cli/common.py index 80ad426..da864c7 100644 --- a/dashscope/cli/common.py +++ b/dashscope/cli/common.py @@ -4,7 +4,7 @@ import os from functools import wraps from http import HTTPStatus -from typing import Callable, NoReturn, TypeVar +from typing import Callable, Dict, NoReturn, TypeVar from urllib.parse import urlparse import typer @@ -23,21 +23,211 @@ DEFAULT_PAGE_SIZE = 10 DEFAULT_START_PAGE = 1 +# --------------------------------------------------------------------------- +# Error code mapping: SDK error codes -> CLI-friendly error codes +# --------------------------------------------------------------------------- +ERROR_CODE_MAPPING: Dict[str, str] = { + # Authentication errors + "AuthenticationError": "AUTH_FAILED", + "AuthFailed": "AUTH_FAILED", + "InvalidToken": "AUTH_FAILED", + "TokenExpired": "AUTH_FAILED", + "Unauthorized": "AUTH_FAILED", + # Parameter errors + "InvalidParameter": "INVALID_PARAMETER", + "InvalidParam": "INVALID_PARAMETER", + "BadRequest": "INVALID_PARAMETER", + "ModelRequired": "MISSING_MODEL", + "InvalidModel": "INVALID_MODEL", + "InvalidInput": "INVALID_INPUT", + "InvalidFileFormat": "INVALID_FILE_FORMAT", + "InputDataRequired": "MISSING_INPUT_DATA", + "InputRequired": "MISSING_INPUT", + "UnsupportedDataType": "UNSUPPORTED_DATA_TYPE", + # Task errors + "InvalidTask": "INVALID_TASK", + "UnsupportedTask": "UNSUPPORTED_TASK", + "UnsupportedModel": "UNSUPPORTED_MODEL", + "UnsupportedApiProtocol": "UNSUPPORTED_PROTOCOL", + "NotImplemented": "NOT_IMPLEMENTED", + "MultiInputsWithBinaryNotSupported": "BINARY_INPUT_NOT_SUPPORTED", + "UnexpectedMessageReceived": "UNEXPECTED_MESSAGE", + "UnsupportedData": "UNSUPPORTED_DATA", + "UnknownMessageReceived": "UNKNOWN_MESSAGE", + # Service errors + "ServiceUnavailableError": "SERVICE_UNAVAILABLE", + "UnsupportedHTTPMethod": "UNSUPPORTED_METHOD", + "AsyncTaskCreateFailed": "TASK_CREATE_FAILED", + "UploadFileException": "UPLOAD_FAILED", + "TimeoutException": "REQUEST_TIMEOUT", + # Assistant errors + "AssistantError": "ASSISTANT_ERROR", +} + +# Error message templates with CLI context +ERROR_MESSAGE_TEMPLATES: Dict[str, str] = { + "AUTH_FAILED": ( + "Authentication failed. " "Please check your API key and try again." + ), + "INVALID_PARAMETER": ( + "Invalid parameter provided. " "Please check your input parameters." + ), + "MISSING_MODEL": ( + "Model parameter is required. " "Please specify a valid model." + ), + "INVALID_MODEL": ( + "Invalid model specified. " "Please check the model name." + ), + "INVALID_INPUT": ( + "Invalid input data. " "Please check your input format." + ), + "INVALID_FILE_FORMAT": ( + "Invalid file format. " "Please check the file type." + ), + "MISSING_INPUT_DATA": ( + "Input data is required. " "Please provide the necessary input." + ), + "MISSING_INPUT": ( + "Input is required. " "Please provide the necessary input." + ), + "UNSUPPORTED_DATA_TYPE": ( + "Unsupported data type. " "Please check the data format." + ), + "INVALID_TASK": ("Invalid task specified. " "Please check the task type."), + "UNSUPPORTED_TASK": ( + "Unsupported task type. " "Please check the available tasks." + ), + "UNSUPPORTED_MODEL": ( + "Unsupported model. " "Please check the available models." + ), + "UNSUPPORTED_PROTOCOL": ( + "Unsupported API protocol. " "Please check the protocol version." + ), + "NOT_IMPLEMENTED": "This feature is not yet implemented.", + "BINARY_INPUT_NOT_SUPPORTED": ( + "Binary input is not supported with multiple inputs." + ), + "UNEXPECTED_MESSAGE": ("Unexpected message received from the server."), + "UNSUPPORTED_DATA": "Unsupported data format.", + "UNKNOWN_MESSAGE": ("Unknown message received from the server."), + "SERVICE_UNAVAILABLE": ( + "Service is temporarily unavailable. " "Please try again later." + ), + "UNSUPPORTED_METHOD": ( + "Unsupported HTTP method. " "Please check the request method." + ), + "TASK_CREATE_FAILED": ( + "Failed to create async task. " "Please check your request." + ), + "UPLOAD_FAILED": ( + "File upload failed. " "Please check the file and try again." + ), + "REQUEST_TIMEOUT": "Request timed out. Please try again.", + "ASSISTANT_ERROR": ( + "Assistant encountered an error. " "Please check the error details." + ), +} + # --------------------------------------------------------------------------- # Rich consoles # --------------------------------------------------------------------------- console = Console() err_console = Console(stderr=True) +# --------------------------------------------------------------------------- +# Error handling utilities +# --------------------------------------------------------------------------- + + +def _get_cli_error_code(sdk_error_code: str) -> str: + """Map SDK error code to CLI-friendly error code. + + Args: + sdk_error_code: The error code from the SDK response + + Returns: + CLI-friendly error code, or the original code if no mapping exists + """ + return ERROR_CODE_MAPPING.get(sdk_error_code, sdk_error_code) + + +def _get_cli_error_message(sdk_error_code: str, sdk_error_message: str) -> str: + """Get CLI-friendly error message with context. + + Args: + sdk_error_code: The error code from the SDK response + sdk_error_message: The error message from the SDK response + + Returns: + CLI-friendly error message + """ + cli_error_code = _get_cli_error_code(sdk_error_code) + + # Use template message if available, otherwise use SDK message + template_message = ERROR_MESSAGE_TEMPLATES.get(cli_error_code) + if template_message: + # Append SDK-specific message if available + if sdk_error_message: + return f"{sdk_error_message}" + return template_message + + # No template available, use SDK message directly + return ( + sdk_error_message + if sdk_error_message + else f"Error code: {cli_error_code}" + ) + + +def _format_error_parts( + request_id: str, + status_code: str, + cli_error_code: str, + cli_error_message: str, + command_name: str = None, +) -> str: + """Build formatted error output parts. + + Args: + request_id: The request ID from the response + status_code: The HTTP status code + cli_error_code: The CLI-friendly error code + cli_error_message: The CLI-friendly error message + command_name: The CLI command name (optional) + + Returns: + Formatted error message string + """ + parts = [] + if command_name: + parts.append(f"[red]{command_name} failed[/red]") + else: + parts.append("[red]Request failed[/red]") + + if request_id and request_id != "N/A": + parts.append(f"request_id: {request_id}") + if status_code and status_code != "N/A": + parts.append(f"status_code: {status_code}") + parts.append(f"code: {cli_error_code}") + parts.append(f"message: {cli_error_message}") + + return ", ".join(parts) + + # --------------------------------------------------------------------------- # Response helpers # --------------------------------------------------------------------------- -def print_failed_message(rsp): +def print_failed_message(rsp, command_name: str = None): """Print a standardised error message for a failed API response. - Safely handles responses with missing or None attributes. + Maps SDK error codes to CLI-friendly codes and enhances error messages + with CLI context. Safely handles responses with missing or None attributes. + + Args: + rsp: The API response object + command_name: The CLI command name (optional, for better context) """ # Use try-except to handle missing attributes gracefully (works with Mock # objects) @@ -67,21 +257,36 @@ def print_failed_message(rsp): 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}") + # Use the new error formatting with CLI context 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): + cli_error_code = _get_cli_error_code(code) + cli_error_message = _get_cli_error_message(code, message) + + formatted_error = _format_error_parts( + request_id=request_id, + status_code=status_code, + cli_error_code=cli_error_code, + cli_error_message=cli_error_message, + command_name=command_name, + ) + err_console.print(formatted_error) + else: + # Fallback for responses without error code + 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 message: + parts.append(f"message: {message}") + err_console.print(", ".join(parts)) + + +def ensure_ok( + rsp, + check_business_error: bool = True, + command_name: str = None, +): """Return *rsp.output* when the response is OK; otherwise print the error and exit with code 1. @@ -90,7 +295,7 @@ def ensure_ok(rsp, check_business_error: bool = True): 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 + - HTTP 4xx/5xx → clear error message with CLI context Args: rsp: The API response object @@ -98,10 +303,11 @@ def ensure_ok(rsp, check_business_error: bool = True): error codes in the output. Set to False for async task creation where we only care about HTTP success, not task execution. + command_name: The CLI command name (optional, for better context) """ # Check HTTP status first if rsp.status_code != HTTPStatus.OK: - print_failed_message(rsp) + print_failed_message(rsp, command_name=command_name) raise typer.Exit(1) # Check if output exists @@ -127,18 +333,22 @@ def ensure_ok(rsp, check_business_error: bool = True): # 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" + # Use the new error formatting with CLI context + request_id = getattr(rsp, "request_id", "N/A") + cli_error_code = _get_cli_error_code(error_code) + cli_error_message = _get_cli_error_message( + error_code, + message or "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}", + + formatted_error = _format_error_parts( + request_id=request_id, + status_code=str(rsp.status_code), + cli_error_code=cli_error_code, + cli_error_message=cli_error_message, + command_name=command_name, ) + err_console.print(formatted_error) raise typer.Exit(1) return output @@ -160,11 +370,63 @@ def error(message: str, exit_code: int = 1) -> NoReturn: raise typer.Exit(exit_code) +def _handle_exception( + exception: Exception, + action: str, + output_console: Console, +) -> NoReturn: + """Handle an exception and print a friendly error message. + + Maps SDK exception types to CLI-friendly error codes and enhances + error messages with CLI context. Preserves full exception context + including stack trace for debugging. + + Args: + exception: The exception to handle. + action: The action that failed (e.g., "FC registration failed"). + output_console: The Rich console to print to. + """ + if isinstance(exception, DashScopeException): + # Handle known DashScope exceptions with structured error info + request_id = getattr(exception, "request_id", "N/A") or "N/A" + message = getattr(exception, "message", str(exception)) + + # Map exception type to CLI-friendly error code + exception_type_name = type(exception).__name__ + cli_error_code = _get_cli_error_code(exception_type_name) + cli_error_message = _get_cli_error_message( + exception_type_name, + message, + ) + + output_console.print( + f"[red]{action}[/red] " + f"(request_id: {request_id}, code: {cli_error_code})\n" + f" {cli_error_message}", + no_wrap=True, + ) + # Log full traceback for debugging + logger.debug( + f"{action} failed with DashScopeException", + exc_info=True, + ) + else: + # Handle unexpected exceptions with full context + output_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 + + def handle_sdk_error(action: str): """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. + Maps SDK exception types to CLI-friendly error codes and enhances + error messages with CLI context. Preserves full exception context + including stack trace for debugging. """ def decorator(command_function: CommandFunction) -> CommandFunction: @@ -178,15 +440,21 @@ def wrapper(*args, **kwargs): 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, + message = getattr(exception, "message", str(exception)) + + # Map exception type to CLI-friendly error code + exception_type_name = type(exception).__name__ + cli_error_code = _get_cli_error_code(exception_type_name) + cli_error_message = _get_cli_error_message( + exception_type_name, + message, ) err_console.print( f"[red]{action}[/red] " - f"(request_id: {request_id}, code: {code})\n" - f" {message}", + f"(request_id: {request_id}, code: {cli_error_code})\n" + f" {cli_error_message}", + no_wrap=True, ) # Log full traceback for debugging logger.debug( diff --git a/dashscope/cli/generation.py b/dashscope/cli/generation.py index d0c2bd0..73c3617 100644 --- a/dashscope/cli/generation.py +++ b/dashscope/cli/generation.py @@ -180,6 +180,7 @@ def create( typer.echo(usage) else: print_failed_message(rsp) + break else: if response.status_code == HTTPStatus.OK: typer.echo(response.output) diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index 2aae5ce..1786500 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -1565,14 +1565,14 @@ def _handle_response(cls, response: requests.Response): "error_message", ) except json.JSONDecodeError: - code = "Unknown" + code = None message = data yield DashScopeAPIResponse( request_id=request_id, status_code=status_code, output=None, - code=code or "", - message=message or "", + code=code, + message=message, ) # noqa E501 else: yield DashScopeAPIResponse( diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index 7e1dda4..8c073f2 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -269,16 +269,16 @@ def _handle_stream(response: requests.Response): def _handle_error_message(error, status_code, flattened_output, headers): - code = None + code = "" msg = "" request_id = "" if flattened_output: error["status_code"] = status_code return error - if "message" in error: - msg = error["message"] if "msg" in error: msg = error["msg"] + if "message" in error: + msg = error["message"] if "code" in error: code = error["code"] if "request_id" in error: @@ -375,19 +375,25 @@ async def _handle_aiohttp_failed_response( ) elif SSE_CONTENT_TYPE in response.content_type: error = None + raw_data = [] async for _, _, data in _handle_aio_stream(response): - error = json.loads(data) + raw_data.append(data) + try: + error = json.loads(data) + except json.JSONDecodeError: + continue if error is None: + raw_content = "\n".join(raw_data) if raw_data else "" if flattened_output: return { # type: ignore[return-value] "status_code": response.status, - "message": "Empty SSE error response", + "message": raw_content or "Empty SSE error response", } return DashScopeAPIResponse( request_id=request_id, status_code=response.status, code=f"http_{response.status}", - message="Empty SSE error response", + message=raw_content or "Empty SSE error response", headers=headers, ) return _handle_error_message( @@ -458,18 +464,18 @@ def _handle_http_stream_response( usage=usage, headers=headers, ) - except json.JSONDecodeError as e: + except json.JSONDecodeError: if flattened_output: yield event.eventType, { "status_code": response.status_code, - "message": e.message, + "message": event.data, } else: yield event.eventType, DashScopeAPIResponse( request_id=request_id, - status_code=HTTPStatus.BAD_REQUEST, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, output=None, - code="Unknown", + code="", message=event.data, headers=headers, ) @@ -481,17 +487,29 @@ def _handle_http_stream_response( "message": event.data, } else: - msg = json.loads(event.data) - yield event.eventType, DashScopeAPIResponse( - 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, - headers=headers, - ) # noqa E501 + try: + msg = json.loads(event.data) + yield event.eventType, DashScopeAPIResponse( + request_id=request_id, + status_code=status_code, + output=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 + except json.JSONDecodeError: + yield event.eventType, DashScopeAPIResponse( + request_id=request_id, + status_code=status_code, + output=None, + code=f"http_{status_code}", + message=event.data, + headers=headers, + ) # pylint: disable=consider-using-in elif ( response.status_code == HTTPStatus.OK diff --git a/tests/unit/test_cli_common.py b/tests/unit/test_cli_common.py index ca6ed97..1216e14 100644 --- a/tests/unit/test_cli_common.py +++ b/tests/unit/test_cli_common.py @@ -1,8 +1,9 @@ # -*- 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 +from unittest.mock import Mock + +import pytest import typer from dashscope.cli.common import ( @@ -11,7 +12,7 @@ handle_sdk_error, ) from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.common.error import DashScopeException, AuthenticationError +from dashscope.common.error import AuthenticationError class TestPrintFailedMessage: @@ -40,12 +41,13 @@ def test_missing_request_id(self, capsys): 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 + # Error code is mapped to CLI-friendly code + assert "INVALID_PARAMETER" in captured.err assert "400" in captured.err def test_empty_code_and_message(self, capsys): @@ -71,7 +73,7 @@ def test_none_attributes(self, capsys): rsp.request_id = None rsp.code = None rsp.message = None - + print_failed_message(rsp) captured = capsys.readouterr() # None values should not be displayed @@ -104,13 +106,14 @@ def test_http_error(self, capsys): 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 + # Error message now uses "Request failed" instead of "Failed" + assert captured.err.count("Request failed") == 1 def test_business_error_in_dict_output(self, capsys): """Test with HTTP 200 but business error in dict output.""" @@ -121,15 +124,15 @@ def test_business_error_in_dict_output(self, capsys): 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 + # Error code is mapped to CLI-friendly code + assert "INVALID_PARAMETER" in normalized_err assert "Model not found" in normalized_err def test_business_error_without_message(self, capsys): @@ -139,12 +142,13 @@ def test_business_error_without_message(self, capsys): 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) + # 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 @@ -155,10 +159,10 @@ def test_none_output(self, capsys): request_id="req_null", output=None, ) - + with pytest.raises(typer.Exit): ensure_ok(rsp) - + captured = capsys.readouterr() assert "no output data" in captured.err @@ -166,28 +170,34 @@ 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"}, + 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"} + 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 @@ -197,7 +207,7 @@ class TestHandleSdkError: 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 @@ -206,47 +216,48 @@ def failing_function(): 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 + # Error code is mapped to CLI-friendly code + assert "AUTH_FAILED" 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_main.py b/tests/unit/test_cli_main.py index d4c839c..4b178ef 100644 --- a/tests/unit/test_cli_main.py +++ b/tests/unit/test_cli_main.py @@ -46,7 +46,7 @@ def mock_list(**kwargs): combined_output = captured_output.out + captured_output.err assert exception_info.value.code == 1 - assert "No api key provided." in combined_output + assert "No api key provided" in combined_output assert "Traceback" not in combined_output def test_top_level_help_shows_global_api_key(self): diff --git a/verify_error_handling.py b/verify_error_handling.py deleted file mode 100644 index 8687cec..0000000 --- a/verify_error_handling.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/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() From 58e8f04dbf8a42b9ee4a4fa592cee0def8c306f8 Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 13 Jul 2026 11:11:11 +0800 Subject: [PATCH 03/14] Fix CLI output handling to support both dict and object formats --- dashscope/cli/agentic_rl.py | 7 +++++-- dashscope/cli/deployments.py | 25 ++++++++++++++++++------- dashscope/cli/fine_tunes.py | 14 +++++++++----- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/dashscope/cli/agentic_rl.py b/dashscope/cli/agentic_rl.py index 3a157b1..d1deee0 100644 --- a/dashscope/cli/agentic_rl.py +++ b/dashscope/cli/agentic_rl.py @@ -706,8 +706,11 @@ def logs( # 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", "") + if result.output is not None: + if isinstance(result.output, dict): + logs_data = result.output.get("logs", "") + else: + logs_data = getattr(result.output, "logs", "") format_output( {"job_id": job_id, "logs": logs_data}, diff --git a/dashscope/cli/deployments.py b/dashscope/cli/deployments.py index 1130ad3..7c50662 100644 --- a/dashscope/cli/deployments.py +++ b/dashscope/cli/deployments.py @@ -92,15 +92,26 @@ def _wait_for_deployment( def _print_deployments(output): """Pretty-print a list of deployments from *output*.""" - if ( - output is None - or not isinstance(output, dict) - or "deployments" not in output - or not output["deployments"] - ): + if output is None: + console.print("There is no deployed model!") + return + + # Support both dictionary and object formats + if isinstance(output, dict): + raw_deps = output.get("deployments", []) + from types import SimpleNamespace + deployments = [ + SimpleNamespace(**d) if isinstance(d, dict) else d + for d in raw_deps + ] + else: + deployments = getattr(output, "deployments", []) + + if not deployments: console.print("There is no deployed model!") return - for dep in output.deployments: + + for dep in deployments: console.print( f"Deployed_model: {dep.deployed_model}, " f"model: {dep.model_name}, " diff --git a/dashscope/cli/fine_tunes.py b/dashscope/cli/fine_tunes.py index d09d4a1..c719640 100644 --- a/dashscope/cli/fine_tunes.py +++ b/dashscope/cli/fine_tunes.py @@ -107,15 +107,19 @@ def _stream_events(job_id: str): print_failed_message(rsp) return - # Validate output is not None and is a dict before accessing - if rsp.output is None or not isinstance(rsp.output, dict): + # Validate output is not None before accessing attributes + if rsp.output is None: 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") + # Support both dictionary and object formats + if isinstance(rsp.output, dict): + status = rsp.output.get("status") + else: + status = getattr(rsp.output, "status", None) if status in ( TaskStatus.FAILED, TaskStatus.CANCELED, @@ -150,7 +154,7 @@ def _dump_logs(job_id: str): line=LOG_PAGE_SIZE, ) output = ensure_ok(rsp) - logs = output.get("logs", []) + logs = output.get("logs", []) if isinstance(output, dict) else getattr(output, "logs", []) if not logs: break for line in logs: @@ -244,7 +248,7 @@ def create( if output is None: error("Fine-tune creation returned empty response") - job_id = output.get("job_id") + job_id = output.get("job_id") if isinstance(output, dict) else getattr(output, "job_id", None) if not job_id: error( "Fine-tune creation succeeded but missing job_id in response. " From 1755866d3bbeab1b2257b2e055dbc52cc6bfbd0a Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 13 Jul 2026 11:14:54 +0800 Subject: [PATCH 04/14] Fix CLI output handling to support both dict and object formats --- dashscope/cli/agentic_rl.py | 2 ++ dashscope/cli/deployments.py | 7 ++++--- dashscope/cli/fine_tunes.py | 12 ++++++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/dashscope/cli/agentic_rl.py b/dashscope/cli/agentic_rl.py index d1deee0..65376c8 100644 --- a/dashscope/cli/agentic_rl.py +++ b/dashscope/cli/agentic_rl.py @@ -220,6 +220,7 @@ async def _register_fc_async( except Exception as e: _handle_exception(e, "FC registration failed", err_console) + raise # pragma: no cover @app.command("register-functions", hidden=True) @@ -295,6 +296,7 @@ async def _test_fc_async( except Exception as e: _handle_exception(e, "Function test failed", err_console) + raise # pragma: no cover @app.command("test-functions", hidden=True) diff --git a/dashscope/cli/deployments.py b/dashscope/cli/deployments.py index 7c50662..e08e123 100644 --- a/dashscope/cli/deployments.py +++ b/dashscope/cli/deployments.py @@ -95,22 +95,23 @@ def _print_deployments(output): if output is None: console.print("There is no deployed model!") return - + # Support both dictionary and object formats if isinstance(output, dict): raw_deps = output.get("deployments", []) from types import SimpleNamespace + deployments = [ SimpleNamespace(**d) if isinstance(d, dict) else d for d in raw_deps ] else: deployments = getattr(output, "deployments", []) - + if not deployments: console.print("There is no deployed model!") return - + for dep in deployments: console.print( f"Deployed_model: {dep.deployed_model}, " diff --git a/dashscope/cli/fine_tunes.py b/dashscope/cli/fine_tunes.py index c719640..66f7f9d 100644 --- a/dashscope/cli/fine_tunes.py +++ b/dashscope/cli/fine_tunes.py @@ -154,7 +154,11 @@ def _dump_logs(job_id: str): line=LOG_PAGE_SIZE, ) output = ensure_ok(rsp) - logs = output.get("logs", []) if isinstance(output, dict) else getattr(output, "logs", []) + logs = ( + output.get("logs", []) + if isinstance(output, dict) + else getattr(output, "logs", []) + ) if not logs: break for line in logs: @@ -248,7 +252,11 @@ def create( if output is None: error("Fine-tune creation returned empty response") - job_id = output.get("job_id") if isinstance(output, dict) else getattr(output, "job_id", None) + job_id = ( + output.get("job_id") + if isinstance(output, dict) + else getattr(output, "job_id", None) + ) if not job_id: error( "Fine-tune creation succeeded but missing job_id in response. " From fc1d296dcadf0a206beaa57bb70516d8b2a10477 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Mon, 13 Jul 2026 17:42:30 +0800 Subject: [PATCH 05/14] fix: replace last remaining code='Unknown' with http_{status} in async response handler --- dashscope/common/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index 8c073f2..ddecc27 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -213,7 +213,7 @@ async def _handle_aiohttp_response(response: aiohttp.ClientResponse): request_id=request_id, status_code=response.status, output=None, - code="Unknown", + code=f"http_{response.status}", message=msg, ) From 2fe1241103e310763f0e4ee3233916ad2750ab03 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 15 Jul 2026 15:23:22 +0800 Subject: [PATCH 06/14] refactor: unify error response handling and improve error code flexibility --- dashscope/api_entities/aiohttp_request.py | 47 +++++---------------- dashscope/api_entities/http_request.py | 8 +++- dashscope/api_entities/websocket_request.py | 3 ++ dashscope/common/utils.py | 26 +++++++----- 4 files changed, 36 insertions(+), 48 deletions(-) diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index 2358f58..d904448 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -17,7 +17,10 @@ ) from dashscope.common.error import UnsupportedHTTPMethod from dashscope.common.logging import logger -from dashscope.common.utils import async_to_sync +from dashscope.common.utils import ( + async_to_sync, + _handle_aiohttp_failed_response, +) class AioHttpRequest(AioBaseRequest): @@ -174,8 +177,12 @@ async def _handle_response( # pylint: disable=too-many-branches yield DashScopeAPIResponse( request_id=request_id, status_code=status_code, - code=msg["code"], - message=msg["message"], + 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", ) else: yield DashScopeAPIResponse( @@ -220,39 +227,7 @@ async def _handle_response( # pylint: disable=too-many-branches usage=usage, ) else: - if "application/json" in response.content_type: - error = await response.json() - if "request_id" in error: - request_id = error["request_id"] - if "message" not in error: - message = "" - logger.error( - "Request: %s failed, status: %s", - self.url, - response.status, - ) - else: - message = error["message"] - logger.error( - "Request: %s failed, status: %s, message: %s", - self.url, - response.status, - error["message"], - ) - yield DashScopeAPIResponse( - request_id=request_id, - status_code=response.status, - code=error["code"], - message=message, - ) - else: - msg = await response.read() - yield DashScopeAPIResponse( - request_id=request_id, - status_code=response.status, - code=None, - message=msg.decode("utf-8"), - ) + yield _handle_aiohttp_failed_response(response) # pylint: disable=too-many-branches async def _handle_request(self): diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 1bcc6b5..e91fc8b 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -302,8 +302,12 @@ async def _handle_aio_response( # pylint: disable=too-many-branches, too-many-s yield DashScopeAPIResponse( request_id=request_id, status_code=status_code, - code=msg["code"], - message=msg["message"], + 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, ) else: diff --git a/dashscope/api_entities/websocket_request.py b/dashscope/api_entities/websocket_request.py index 0979978..12625ad 100644 --- a/dashscope/api_entities/websocket_request.py +++ b/dashscope/api_entities/websocket_request.py @@ -227,6 +227,9 @@ async def connection_handler( or f"WebSocket handshake failed with status {e.status}" ) + # Note: For WebSocket handshake errors, code uses format + # "WS_HANDSHAKE_{status_code}" to distinguish from business errors. + # This is a special case. yield DashScopeAPIResponse( request_id=task_id, status_code=code, diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index ddecc27..63ec9e9 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -213,7 +213,7 @@ async def _handle_aiohttp_response(response: aiohttp.ClientResponse): request_id=request_id, status_code=response.status, output=None, - code=f"http_{response.status}", + code="", message=msg, ) @@ -275,10 +275,10 @@ def _handle_error_message(error, status_code, flattened_output, headers): if flattened_output: error["status_code"] = status_code return error - if "msg" in error: - msg = error["msg"] if "message" in error: msg = error["message"] + elif "msg" in error: + msg = error["msg"] if "code" in error: code = error["code"] if "request_id" in error: @@ -320,8 +320,8 @@ def _handle_http_failed_response( return DashScopeAPIResponse( request_id=request_id, status_code=response.status_code, - code=f"http_{response.status_code}", - message=msgs, + code="", + message="\n".join(msgs).strip() or "Empty SSE error response", headers=headers, ) else: @@ -331,7 +331,7 @@ def _handle_http_failed_response( return DashScopeAPIResponse( request_id=request_id, status_code=response.status_code, - code=f"http_{response.status_code}", + code="", message=msg, headers=headers, ) @@ -367,6 +367,12 @@ async def _handle_aiohttp_failed_response( headers = dict(response.headers) if "application/json" in response.content_type: error = await response.json() + logger.error( + "Request failed: status=%s, code=%s, message=%s", + response.status, + error.get("code", error.get("error_code", "unknown")), + error.get("message", error.get("error_message", "unknown")), + ) return _handle_error_message( error, response.status, @@ -383,7 +389,7 @@ async def _handle_aiohttp_failed_response( except json.JSONDecodeError: continue if error is None: - raw_content = "\n".join(raw_data) if raw_data else "" + raw_content = "\n".join(raw_data).strip() if raw_data else "" if flattened_output: return { # type: ignore[return-value] "status_code": response.status, @@ -392,7 +398,7 @@ async def _handle_aiohttp_failed_response( return DashScopeAPIResponse( request_id=request_id, status_code=response.status, - code=f"http_{response.status}", + code="", message=raw_content or "Empty SSE error response", headers=headers, ) @@ -409,7 +415,7 @@ async def _handle_aiohttp_failed_response( return DashScopeAPIResponse( request_id=request_id, status_code=response.status, - code=f"http_{response.status}", + code="", message=msg, headers=headers, ) @@ -506,7 +512,7 @@ def _handle_http_stream_response( request_id=request_id, status_code=status_code, output=None, - code=f"http_{status_code}", + code="", message=event.data, headers=headers, ) From 032637da60de3794280f60bf6c9570d220523621 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 16 Jul 2026 18:54:34 +0800 Subject: [PATCH 07/14] feat: add centralized error registry and improve internal error handling --- dashscope/api_entities/aiohttp_request.py | 61 ++-- dashscope/api_entities/http_request.py | 29 +- dashscope/api_entities/websocket_request.py | 134 +++++---- dashscope/common/error_registry.py | 185 ++++++++++++ dashscope/common/utils.py | 285 ++++++++++++------- tests/integration/test_large_utf8_payload.py | 16 +- tests/unit/test_utf8_encoding.py | 1 + 7 files changed, 512 insertions(+), 199 deletions(-) create mode 100644 dashscope/common/error_registry.py diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index d904448..97d8d32 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -3,7 +3,7 @@ import json from http import HTTPStatus -from typing import Optional +from typing import Optional, Dict import aiohttp @@ -17,6 +17,7 @@ ) from dashscope.common.error import UnsupportedHTTPMethod from dashscope.common.logging import logger +from dashscope.common.error_registry import INTERNAL_ERROR from dashscope.common.utils import ( async_to_sync, _handle_aiohttp_failed_response, @@ -88,10 +89,10 @@ def __init__( else: self.timeout = timeout # type: ignore[has-type] - def add_header(self, key, value): + def add_header(self, key: str, value: str) -> None: self.headers[key] = value - def add_headers(self, headers): + def add_headers(self, headers: Dict[str, str]) -> None: self.headers = {**self.headers, **headers} def call(self): @@ -100,9 +101,8 @@ def call(self): return (item for item in response) else: output = next(response) - try: - next(response) - except StopIteration: + # Consume remaining items to ensure generator completes + for _ in response: pass return output @@ -112,9 +112,8 @@ async def aio_call(self): return (item async for item in response) else: result = await response.__anext__() - try: - await response.__anext__() - except StopAsyncIteration: + # Consume remaining items to ensure generator completes + async for _ in response: pass return result @@ -145,6 +144,7 @@ async def _handle_response( # pylint: disable=too-many-branches response: aiohttp.ClientResponse, ): request_id = "" + headers = dict(response.headers) if ( response.status == HTTPStatus.OK and self.stream @@ -165,12 +165,16 @@ async def _handle_response( # pylint: disable=too-many-branches if "request_id" in msg: request_id = msg["request_id"] except json.JSONDecodeError: - msg = None + logger.error( + "Failed to parse SSE stream data: %s", + data[:200] if len(data) > 200 else data, + ) yield DashScopeAPIResponse( request_id=request_id, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - code=None, - message=data, + status_code=INTERNAL_ERROR.status_code, + code=INTERNAL_ERROR.error_code, + message=INTERNAL_ERROR.error_msg, + headers=headers, ) continue if is_error and msg is not None: @@ -183,6 +187,7 @@ async def _handle_response( # pylint: disable=too-many-branches message=msg.get("message") or msg.get("error_message") or f"HTTP {status_code} error", + headers=headers, ) else: yield DashScopeAPIResponse( @@ -190,6 +195,7 @@ async def _handle_response( # pylint: disable=too-many-branches status_code=HTTPStatus.OK, output=output, usage=usage, + headers=headers, ) elif ( response.status == HTTPStatus.OK @@ -209,6 +215,7 @@ async def _handle_response( # pylint: disable=too-many-branches request_id=request_id, status_code=HTTPStatus.OK, output=output, + headers=headers, ) elif response.status == HTTPStatus.OK: json_content = await response.json() @@ -225,6 +232,7 @@ async def _handle_response( # pylint: disable=too-many-branches status_code=HTTPStatus.OK, output=output, usage=usage, + headers=headers, ) else: yield _handle_aiohttp_failed_response(response) @@ -232,12 +240,14 @@ async def _handle_response( # pylint: disable=too-many-branches # pylint: disable=too-many-branches async def _handle_request(self): try: + # Session management: + # - External session: managed by caller, we never close it + # - Shared session: managed by get_shared_aio_session(), + # uses connection pooling and is closed when no longer needed if self._external_aio_session is not None: session = self._external_aio_session - should_close = False else: session = await get_shared_aio_session() - should_close = False if self.stream: request_timeout = aiohttp.ClientTimeout( @@ -291,8 +301,21 @@ async def _handle_request(self): async for rsp in self._handle_response(response): yield rsp finally: - if should_close: - await session.close() + # Note: We don't close the session here because: + # - External sessions are managed by the caller + # - Shared sessions use connection pooling and are + # managed centrally by get_shared_aio_session() + pass except Exception as e: - logger.debug(e) - raise e + logger.error( + "Request failed: url=%s, method=%s, error=%s", + self.url, + self.method, + str(e), + exc_info=True, + ) + from dashscope.common.error import DashScopeException + + raise DashScopeException( + f"Request failed: {str(e)}", + ) from e diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index e91fc8b..72366fd 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -17,6 +17,7 @@ HTTPMethod, ) from dashscope.common.error import UnsupportedHTTPMethod +from dashscope.common.error_registry import MISSING_PARAMETER, INVALID_REQUEST from dashscope.common.logging import logger from dashscope.common.utils import ( _handle_aio_stream, @@ -130,10 +131,10 @@ def __init__( else: self.timeout = timeout # type: ignore[has-type] - def add_header(self, key, value): + def add_header(self, key: str, value: str) -> None: self.headers[key] = value - def add_headers(self, headers): + def add_headers(self, headers: Dict[str, str]) -> None: self.headers = {**self.headers, **headers} def call(self): @@ -142,9 +143,8 @@ def call(self): return (item for item in response) else: output = next(response) - try: - next(response) - except StopIteration: + # Consume remaining items to ensure generator completes + for _ in response: pass return output @@ -154,9 +154,8 @@ async def aio_call(self): return (item async for item in response) else: result = await response.__anext__() - try: - await response.__anext__() - except StopAsyncIteration: + # Consume remaining items to ensure generator completes + async for _ in response: pass return result @@ -292,9 +291,11 @@ async def _handle_aio_response( # pylint: disable=too-many-branches, too-many-s except json.JSONDecodeError: yield DashScopeAPIResponse( request_id=request_id, - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - code=None, - message=data, + status_code=MISSING_PARAMETER.status_code, + code=MISSING_PARAMETER.error_code, + message=MISSING_PARAMETER.format_msg( + {"parameter": "response data"}, + ), headers=headers, ) continue @@ -405,10 +406,10 @@ def _handle_response( # pylint: disable=too-many-branches except json.JSONDecodeError: yield DashScopeAPIResponse( request_id=request_id, - status_code=HTTPStatus.BAD_REQUEST, + status_code=INVALID_REQUEST.status_code, output=None, - code=None, - message=data, + code=INVALID_REQUEST.error_code, + message=INVALID_REQUEST.error_msg, headers=headers, ) continue diff --git a/dashscope/api_entities/websocket_request.py b/dashscope/api_entities/websocket_request.py index 12625ad..45dc596 100644 --- a/dashscope/api_entities/websocket_request.py +++ b/dashscope/api_entities/websocket_request.py @@ -5,7 +5,7 @@ import json import uuid from http import HTTPStatus -from typing import Tuple, Union +from typing import Tuple, Union, Dict import aiohttp @@ -13,7 +13,6 @@ from dashscope.api_entities.dashscope_response import DashScopeAPIResponse from dashscope.common.constants import ( DEFAULT_REQUEST_TIMEOUT_SECONDS, - SERVICE_503_MESSAGE, WEBSOCKET_ERROR_CODE, ) from dashscope.common.error import ( @@ -21,6 +20,11 @@ UnexpectedMessageReceived, UnknownMessageReceived, ) +from dashscope.common.error_registry import ( + SERVICE_UNAVAILABLE, + AUTH_FAILED, + INTERNAL_ERROR, +) from dashscope.common.logging import logger from dashscope.common.utils import async_to_sync from dashscope.protocol.websocket import ( @@ -81,7 +85,7 @@ def __init__( } self.pre_task_id = pre_task_id - def add_headers(self, headers): + def add_headers(self, headers: Dict[str, str]) -> None: self.headers = {**self.headers, **headers} def call(self): @@ -115,8 +119,11 @@ async def aio_call(self): async def connection_handler( self, ): # pylint: disable=too-many-branches,too-many-statements + send_task = None + task_id = ( + "" # Initialize at the beginning to ensure it's always defined + ) try: - task_id = None async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout( total=self.timeout, @@ -126,7 +133,7 @@ async def connection_handler( async with session.ws_connect( self.url, headers=self.headers, - heartbeat=6000, + heartbeat=30, ) as ws: await self._start_task(ws) # send start task action. task_id = self.task_headers["task_id"] @@ -178,15 +185,27 @@ async def connection_handler( message, ) else: # duplex mode - asyncio.create_task(self._send_continue_task_data(ws)) - async for is_binary, message in self._receive_streaming_data_task( # noqa E501 # pylint: disable=line-too-long - ws, - ): - yield self._to_DashScopeAPIResponse( - task_id, - is_binary, - message, - ) + # Track the background task to ensure proper cleanup + send_task = asyncio.create_task( + self._send_continue_task_data(ws), + ) + try: + async for is_binary, message in self._receive_streaming_data_task( # noqa E501 # pylint: disable=line-too-long + ws, + ): + yield self._to_DashScopeAPIResponse( + task_id, + is_binary, + message, + ) + finally: + # Cancel the send task if it's still running + if send_task and not send_task.done(): + send_task.cancel() + try: + await send_task + except asyncio.CancelledError: + pass except RequestFailure as e: yield DashScopeAPIResponse( request_id=e.request_id, @@ -197,53 +216,66 @@ async def connection_handler( ) except aiohttp.ClientConnectorError as e: logger.exception(e) - yield DashScopeAPIResponse( - request_id="", - status_code=-1, - code="ClientConnectorError", - message=str(e), - ) + # Check if the error message indicates service unavailability + error_str = str(e).lower() + if "service unavailable" in error_str or "503" in error_str: + yield DashScopeAPIResponse( + request_id=task_id if task_id else "", + status_code=SERVICE_UNAVAILABLE.status_code, + code=SERVICE_UNAVAILABLE.error_code, + message=SERVICE_UNAVAILABLE.error_msg, + ) + else: + yield DashScopeAPIResponse( + request_id=task_id if task_id else "", + status_code=-1, + code="ClientConnectorError", + message=str(e), + ) except aiohttp.WSServerHandshakeError as e: - code = e.status original_msg = e.message or "" if e.status in [HTTPStatus.FORBIDDEN, HTTPStatus.UNAUTHORIZED]: - friendly_hint = "Unauthorized, your api-key may be invalid!" - msg = ( - f"{friendly_hint} (Server details: {original_msg})" - if original_msg - else friendly_hint + yield DashScopeAPIResponse( + request_id=task_id if task_id else "", + status_code=AUTH_FAILED.status_code, + code=AUTH_FAILED.error_code, + message=AUTH_FAILED.error_msg, ) elif e.status == HTTPStatus.SERVICE_UNAVAILABLE: - friendly_hint = SERVICE_503_MESSAGE - msg = ( - f"{friendly_hint} (Server details: {original_msg})" - if original_msg - else friendly_hint + yield DashScopeAPIResponse( + request_id=task_id if task_id else "", + status_code=SERVICE_UNAVAILABLE.status_code, + code=SERVICE_UNAVAILABLE.error_code, + message=SERVICE_UNAVAILABLE.error_msg, ) else: - msg = ( - original_msg - or f"WebSocket handshake failed with status {e.status}" - ) - - # Note: For WebSocket handshake errors, code uses format - # "WS_HANDSHAKE_{status_code}" to distinguish from business errors. - # This is a special case. - yield DashScopeAPIResponse( - request_id=task_id, - status_code=code, - code=f"WS_HANDSHAKE_{code}" if code else "WS_HANDSHAKE_FAILED", - message=msg, - ) - except BaseException as e: + if e.status == HTTPStatus.INTERNAL_SERVER_ERROR: + yield DashScopeAPIResponse( + request_id=task_id if task_id else "", + status_code=INTERNAL_ERROR.status_code, + code=INTERNAL_ERROR.error_code, + message=INTERNAL_ERROR.error_msg, + ) + else: + # Log unexpected status codes for debugging + logger.warning( + "WebSocket handshake failed with unexpected " + "status %s: %s", + e.status, + original_msg, + ) + raise e + except Exception as e: logger.exception(e) - exception_name = type(e).__name__ yield DashScopeAPIResponse( - request_id="", - status_code=-1, - code="", - message=f"[SDK Internal Error] {exception_name}: {e}", + request_id=task_id if task_id else "", + status_code=INTERNAL_ERROR.status_code, + code=INTERNAL_ERROR.error_code, + message=( + f"{INTERNAL_ERROR.error_msg} " + f"(SDK Internal Error: {type(e).__name__}: {e})" + ), ) def _to_DashScopeAPIResponse(self, task_id, is_binary, result): diff --git a/dashscope/common/error_registry.py b/dashscope/common/error_registry.py new file mode 100644 index 0000000..b1dd6bf --- /dev/null +++ b/dashscope/common/error_registry.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +"""Auto-generated by tools/generate.py -- DO NOT EDIT.""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Dict, List + + +@dataclass(frozen=True) +class PublicError: + key: str + status_code: int + error_code: str + error_msg: str + anthropic_error_code: str + + def format_msg(self, variables: Dict[str, str] = None) -> str: + msg = self.error_msg + if variables: + for k, v in variables.items(): + msg = msg.replace("{" + k + "}", v) + return msg + + +@dataclass(frozen=True) +class InternalErrorDef: + name: str + external: PublicError + message: str + allow_retry: bool = False + vars: List[str] = field(default_factory=list) + cause: str = "" + solution: str = "" + + def format_message(self, variables: Dict[str, str] = None) -> str: + msg = self.message + if variables: + for k, v in variables.items(): + msg = msg.replace("{" + k + "}", v) + return msg + + +# -- Public Errors -------------------------------------------------- + +INVALID_REQUEST = PublicError( + key="invalid_request", + status_code=400, + error_code="BadRequestError", + error_msg=( + "The request is invalid. Please check the request and try " "again." + ), + anthropic_error_code="invalid_request_error", +) + +MISSING_PARAMETER = PublicError( + key="missing_parameter", + status_code=400, + error_code="BadRequestError", + error_msg="Missing required parameter: {parameter}.", + anthropic_error_code="invalid_request_error", +) + +CONTENT_POLICY_VIOLATION = PublicError( + key="content_policy_violation", + status_code=400, + error_code="BadRequestError", + error_msg="The request was rejected by content policy.", + anthropic_error_code="invalid_request_error", +) + +INVALID_URL = PublicError( + key="invalid_url", + status_code=400, + error_code="BadRequestError", + error_msg="The provided URL is invalid or cannot be accessed.", + anthropic_error_code="invalid_request_error", +) + +INVALID_FILE = PublicError( + key="invalid_file", + status_code=400, + error_code="BadRequestError", + error_msg="The provided file is invalid.", + anthropic_error_code="invalid_request_error", +) + +AUTH_FAILED = PublicError( + key="auth_failed", + status_code=401, + error_code="AuthenticationError", + error_msg=( + "Authentication failed. Please provide valid " + "authentication credentials." + ), + anthropic_error_code="authentication_error", +) + +INVALID_API_KEY = PublicError( + key="invalid_api_key", + status_code=401, + error_code="AuthenticationError", + error_msg="Incorrect API key provided.", + anthropic_error_code="authentication_error", +) + +PERMISSION_DENIED = PublicError( + key="permission_denied", + status_code=403, + error_code="PermissionDeniedError", + error_msg="You do not have permission to access this resource.", + anthropic_error_code="permission_error", +) + +RESOURCE_NOT_FOUND = PublicError( + key="resource_not_found", + status_code=404, + error_code="NotFoundError", + error_msg="The requested resource was not found: {resource}.", + anthropic_error_code="not_found_error", +) + +REQUEST_TOO_LARGE = PublicError( + key="request_too_large", + status_code=413, + error_code="RequestTooLargeError", + error_msg="The request exceeds the maximum allowed size of {limit}.", + anthropic_error_code="request_too_large", +) + +RATE_LIMIT_EXCEEDED = PublicError( + key="rate_limit_exceeded", + status_code=429, + error_code="RateLimitError", + error_msg="Rate limit exceeded. Please slow down your requests.", + anthropic_error_code="rate_limit_error", +) + +CONCURRENCY_LIMIT_EXCEEDED = PublicError( + key="concurrency_limit_exceeded", + status_code=429, + error_code="RateLimitError", + error_msg=( + "Too many concurrent requests. Please reduce concurrency " + "and try again." + ), + anthropic_error_code="rate_limit_error", +) + +INSUFFICIENT_QUOTA = PublicError( + key="insufficient_quota", + status_code=429, + error_code="RateLimitError", + error_msg=( + "You exceeded your current quota. Please check your plan " + "and billing details." + ), + anthropic_error_code="billing_error", +) + +INTERNAL_ERROR = PublicError( + key="internal_error", + status_code=500, + error_code="InternalServerError", + error_msg=( + "The server encountered an internal error. Please try again " "later." + ), + anthropic_error_code="api_error", +) + +SERVICE_UNAVAILABLE = PublicError( + key="service_unavailable", + status_code=503, + error_code="ServiceUnavailableError", + error_msg=( + "The service is temporarily unavailable. Please try again " "later." + ), + anthropic_error_code="overloaded_error", +) + +REQUEST_TIMEOUT = PublicError( + key="request_timeout", + status_code=504, + error_code="GatewayTimeoutError", + error_msg="The request timed out. Please try again later.", + anthropic_error_code="timeout_error", +) diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index 63ec9e9..aadca46 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -18,11 +18,28 @@ from dashscope.api_entities.dashscope_response import DashScopeAPIResponse from dashscope.common.api_key import get_default_api_key from dashscope.common.constants import SSE_CONTENT_TYPE +from dashscope.common.error_registry import INTERNAL_ERROR from dashscope.common.logging import logger from dashscope.version import __version__ -def is_validate_fine_tune_file(file_path): +def truncate_error_message(message: str, max_length: int = 200) -> str: + """Truncate error message for logging to avoid excessive log output. + + Args: + message: The error message to truncate. + max_length: Maximum length of the message. Defaults to 200. + + Returns: + Truncated message with '...' suffix if longer than max_length, + otherwise original message. + """ + if len(message) > max_length: + return message[:max_length] + "..." + return message + + +def is_validate_fine_tune_file(file_path: str) -> bool: with open(file_path, encoding="utf-8") as f: for line in f: try: @@ -32,7 +49,7 @@ def is_validate_fine_tune_file(file_path): return True -def _get_task_group_and_task(module_name): +def _get_task_group_and_task(module_name: str) -> tuple[str, str]: """Get task_group and task name. get task_group and task name based on api file __name__ @@ -48,41 +65,36 @@ def _get_task_group_and_task(module_name): return task_group, task -def is_path(path: str): - """Check the input path is valid local path. +def is_path(path: str) -> bool: + """Check if the input is a valid local path. Args: - path_or_url (str): The path. + path: The path to check. Returns: - bool: If path return True, otherwise False. + True if it's a valid local path, False otherwise. """ url_parsed = urlparse(path) - if url_parsed.scheme in ("file", ""): - return os.path.exists(url_parsed.path) - else: - return False + return url_parsed.scheme in ("file", "") and os.path.exists( + url_parsed.path, + ) -def is_url(url: str): - """Check the input url is valid url. +def is_url(url: str) -> bool: + """Check if the input is a valid URL. Args: - url (str): The url + url: The URL to check. Returns: - bool: If is url return True, otherwise False. + True if it's a valid URL, False otherwise. """ url_parsed = urlparse(url) - # pylint: disable=simplifiable-if-statement - if url_parsed.scheme in ("http", "https", "oss"): - return True - else: - return False + return url_parsed.scheme in ("http", "https", "oss") def iter_over_async(ait): - loop = asyncio.get_event_loop_policy().new_event_loop() + loop = asyncio.new_event_loop() ait = ait.__aiter__() async def get_next(): @@ -106,28 +118,25 @@ def iter_thread(loop, message_queue): message_queue.put((True, e, None)) break finally: - try: - loop.close() - except Exception: - pass + loop.close() message_queue = queue.Queue() x = threading.Thread( target=iter_thread, args=(loop, message_queue), name="iter_async_thread", + daemon=True, ) x.start() while True: finished, error, obj = message_queue.get() if finished: if error is not None: - exception_name = type(error).__name__ yield DashScopeAPIResponse( - -1, - "", - "", - message=f"[SDK Internal Error] {exception_name}: {error}", + status_code=INTERNAL_ERROR.status_code, + request_id="", + code=INTERNAL_ERROR.error_code, + message=INTERNAL_ERROR.format_msg(), ) break yield obj # pylint: disable=no-else-break @@ -171,7 +180,7 @@ def default_headers(api_key: str = None) -> Dict[str, str]: return headers -def join_url(base_url, *args): +def join_url(base_url: str, *args: str) -> str: if not base_url.endswith("/"): base_url = base_url + "/" url = base_url @@ -181,58 +190,19 @@ def join_url(base_url, *args): return url[:-1] -async def _handle_aiohttp_response(response: aiohttp.ClientResponse): - request_id = "" - if response.status == HTTPStatus.OK: - json_content = await response.json() - if "request_id" in json_content: - request_id = json_content["request_id"] - return DashScopeAPIResponse( - request_id=request_id, - status_code=HTTPStatus.OK, - output=json_content, - ) - else: - if "application/json" in response.content_type: - error = await response.json() - msg = "" - if "message" in error: - msg = error["message"] - if "request_id" in error: - request_id = error["request_id"] - return DashScopeAPIResponse( - request_id=request_id, - status_code=response.status, - output=None, - code=error["code"], - message=msg, - ) - else: - msg = await response.read() - return DashScopeAPIResponse( - request_id=request_id, - status_code=response.status, - output=None, - code="", - message=msg, - ) - - @dataclass class SSEEvent: - id: str - eventType: str - data: str - - def __init__( # pylint: disable=redefined-builtin - self, - id: str, - type: str, - data: str, - ): - self.id = id - self.eventType = type - self.data = data + """Server-Sent Events event representation. + + Attributes: + id: Event ID from the 'id:' field. + eventType: Event type from the 'event:' field. + data: Event data from the 'data:' field. + """ + + id: str = "" + eventType: str = "" + data: str = "" def _handle_stream(response: requests.Response): @@ -272,17 +242,46 @@ def _handle_error_message(error, status_code, flattened_output, headers): code = "" msg = "" request_id = "" + + # Log original error information + original_code = error.get("code", error.get("error_code", "")) + original_message = error.get( + "message", + error.get("error_message", error.get("msg", "")), + ) + logger.error( + "Request failed: status=%s, code=%s, message=%s", + status_code, + original_code or "unknown", + original_message or "unknown", + ) + if flattened_output: error["status_code"] = status_code return error - if "message" in error: + + # Extract message, fallback to INTERNAL_ERROR.format_msg() if empty + if "message" in error and error["message"]: msg = error["message"] - elif "msg" in error: + elif "msg" in error and error["msg"]: msg = error["msg"] - if "code" in error: + elif "error_message" in error and error["error_message"]: + msg = error["error_message"] + else: + msg = INTERNAL_ERROR.format_msg() + + # Extract code, fallback to INTERNAL_ERROR.error_code if empty + if "code" in error and error["code"]: code = error["code"] + elif "error_code" in error and error["error_code"]: + code = error["error_code"] + else: + code = INTERNAL_ERROR.error_code + + # Extract request_id if "request_id" in error: request_id = error["request_id"] + return DashScopeAPIResponse( request_id=request_id, status_code=status_code, @@ -317,22 +316,41 @@ def _handle_http_failed_response( flattened_output, headers, ) + # SSE 响应中没有有效的错误数据 + error_message = "\n".join(msgs).strip() or INTERNAL_ERROR.format_msg() + logger.error( + "Request failed: status=%s, code=%s, message=%s", + response.status_code, + INTERNAL_ERROR.error_code, + truncate_error_message(error_message), + ) return DashScopeAPIResponse( request_id=request_id, status_code=response.status_code, - code="", - message="\n".join(msgs).strip() or "Empty SSE error response", + code=INTERNAL_ERROR.error_code, + message=error_message, headers=headers, ) else: msg = response.content.decode("utf-8") + error_message = msg or INTERNAL_ERROR.format_msg() + logger.error( + "Request failed: status=%s, code=%s, message=%s", + response.status_code, + INTERNAL_ERROR.error_code, + truncate_error_message(error_message), + ) if flattened_output: - return {"status_code": response.status_code, "message": msg} # type: ignore[return-value] # pylint: disable=line-too-long # noqa: E501 + return { # type: ignore[return-value] + "status_code": response.status_code, + "code": INTERNAL_ERROR.error_code, + "message": error_message, + } return DashScopeAPIResponse( request_id=request_id, status_code=response.status_code, - code="", - message=msg, + code=INTERNAL_ERROR.error_code, + message=error_message, headers=headers, ) @@ -360,18 +378,32 @@ async def _handle_aio_stream(response): async def _handle_aiohttp_failed_response( - response: requests.Response, + response: aiohttp.ClientResponse, flattened_output: bool = False, ) -> DashScopeAPIResponse: request_id = "" headers = dict(response.headers) if "application/json" in response.content_type: error = await response.json() + # Pass through code, fallback to + # INTERNAL_ERROR.error_code if not available + error_code = ( + error.get("code") + or error.get("error_code") + or INTERNAL_ERROR.error_code + ) + # Pass through message, fallback to + # INTERNAL_ERROR.error_msg if not available + error_message = ( + error.get("message") + or error.get("error_message") + or INTERNAL_ERROR.format_msg() + ) logger.error( "Request failed: status=%s, code=%s, message=%s", response.status, - error.get("code", error.get("error_code", "unknown")), - error.get("message", error.get("error_message", "unknown")), + error_code, + truncate_error_message(error_message), ) return _handle_error_message( error, @@ -390,16 +422,25 @@ async def _handle_aiohttp_failed_response( continue if error is None: raw_content = "\n".join(raw_data).strip() if raw_data else "" + error_code = INTERNAL_ERROR.error_code + error_message = raw_content or INTERNAL_ERROR.format_msg() + logger.error( + "Request failed: status=%s, code=%s, message=%s", + response.status, + error_code, + truncate_error_message(error_message), + ) if flattened_output: return { # type: ignore[return-value] "status_code": response.status, - "message": raw_content or "Empty SSE error response", + "code": error_code, + "message": error_message, } return DashScopeAPIResponse( request_id=request_id, status_code=response.status, - code="", - message=raw_content or "Empty SSE error response", + code=error_code, + message=error_message, headers=headers, ) return _handle_error_message( @@ -410,13 +451,25 @@ async def _handle_aiohttp_failed_response( ) else: msg = await response.text() + error_code = INTERNAL_ERROR.error_code + error_message = msg or INTERNAL_ERROR.format_msg() + logger.error( + "Request failed: status=%s, code=%s, message=%s", + response.status, + error_code, + truncate_error_message(error_message), + ) if flattened_output: - return {"status_code": response.status, "message": msg} # type: ignore[return-value] # pylint: disable=line-too-long # noqa: E501 + return { # type: ignore[return-value] + "status_code": response.status, + "code": error_code, + "message": error_message, + } return DashScopeAPIResponse( request_id=request_id, status_code=response.status, - code="", - message=msg, + code=error_code, + message=error_message, headers=headers, ) @@ -425,11 +478,10 @@ def _handle_http_response( response: requests.Response, flattened_output: bool = False, ): - response = _handle_http_stream_response(response, flattened_output) - _, output = next(response) - try: - next(response) - except StopIteration: + response_gen = _handle_http_stream_response(response, flattened_output) + _, output = next(response_gen) + # Consume remaining items to ensure generator completes + for _ in response_gen: pass return output @@ -471,18 +523,27 @@ def _handle_http_stream_response( headers=headers, ) except json.JSONDecodeError: + error_code = INTERNAL_ERROR.error_code + error_message = event.data or INTERNAL_ERROR.format_msg() + logger.error( + "Request failed: status=%s, code=%s, message=%s", + response.status_code, + error_code, + truncate_error_message(error_message), + ) if flattened_output: yield event.eventType, { "status_code": response.status_code, - "message": event.data, + "code": error_code, + "message": error_message, } else: yield event.eventType, DashScopeAPIResponse( request_id=request_id, status_code=HTTPStatus.INTERNAL_SERVER_ERROR, output=None, - code="", - message=event.data, + code=error_code, + message=error_message, headers=headers, ) continue @@ -508,12 +569,22 @@ def _handle_http_stream_response( headers=headers, ) # noqa E501 except json.JSONDecodeError: + error_code = INTERNAL_ERROR.error_code + error_message = ( + event.data or INTERNAL_ERROR.format_msg() + ) + logger.error( + "Request failed: status=%s, code=%s, message=%s", + status_code, + error_code, + truncate_error_message(error_message), + ) yield event.eventType, DashScopeAPIResponse( request_id=request_id, status_code=status_code, output=None, - code="", - message=event.data, + code=error_code, + message=error_message, headers=headers, ) # pylint: disable=consider-using-in diff --git a/tests/integration/test_large_utf8_payload.py b/tests/integration/test_large_utf8_payload.py index 39d6ebc..9a52320 100644 --- a/tests/integration/test_large_utf8_payload.py +++ b/tests/integration/test_large_utf8_payload.py @@ -70,7 +70,7 @@ def _print_result(resp, elapsed): # pylint: disable=unused-argument return False -def test_sync(test_messages): +def test_sync(messages): # pylint: disable=redefined-outer-name print("\n" + "=" * 60) print("SYNC TEST") print("=" * 60) @@ -78,7 +78,7 @@ def test_sync(test_messages): try: resp = Generation.call( model=MODEL, - messages=test_messages, + messages=messages, max_tokens=MAX_OUTPUT_TOKENS, result_format="message", ) @@ -94,7 +94,7 @@ def test_sync(test_messages): return _print_result(resp, elapsed) -async def test_async(test_messages): +async def test_async(messages): # pylint: disable=redefined-outer-name print("\n" + "=" * 60) print("ASYNC TEST") print("=" * 60) @@ -102,7 +102,7 @@ async def test_async(test_messages): try: resp = await AioGeneration.call( model=MODEL, - messages=test_messages, + messages=messages, max_tokens=MAX_OUTPUT_TOKENS, result_format="message", ) @@ -118,7 +118,7 @@ async def test_async(test_messages): return _print_result(resp, elapsed) -def test_stream(test_messages): +def test_stream(messages): # pylint: disable=redefined-outer-name print("\n" + "=" * 60) print("STREAM TEST") print("=" * 60) @@ -127,7 +127,7 @@ def test_stream(test_messages): try: responses = Generation.call( model=MODEL, - messages=test_messages, + messages=messages, max_tokens=MAX_OUTPUT_TOKENS, result_format="message", stream=True, @@ -160,10 +160,10 @@ def test_stream(test_messages): return True -def test_websocket(test_messages): +def test_websocket(messages): # pylint: disable=redefined-outer-name # WebSocket has a smaller message size limit than HTTP; # use a subset that still contains non-ASCII content. - ws_messages = test_messages[:5] + ws_messages = messages[:5] print("\n" + "=" * 60) print( f"WEBSOCKET TEST ({len(ws_messages)}/{len(messages)} messages)", diff --git a/tests/unit/test_utf8_encoding.py b/tests/unit/test_utf8_encoding.py index ebee93c..965f719 100644 --- a/tests/unit/test_utf8_encoding.py +++ b/tests/unit/test_utf8_encoding.py @@ -59,6 +59,7 @@ def _make_mock_sync_session(): class _MockAioResponse: status = 200 content_type = "application/json" + headers = {"Content-Type": "application/json; charset=utf-8"} async def json(self): return {"output": {"text": "ok"}, "request_id": "test-123"} From eafd4298ec5c75a547195ecabbd89376754a16af Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 16 Jul 2026 18:56:04 +0800 Subject: [PATCH 08/14] feat: add centralized error registry and improve internal error handling --- dashscope/common/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index aadca46..b1a8aca 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -9,7 +9,7 @@ import threading from dataclasses import dataclass from http import HTTPStatus -from typing import Dict +from typing import Dict, Tuple from urllib.parse import urlparse import aiohttp @@ -49,7 +49,7 @@ def is_validate_fine_tune_file(file_path: str) -> bool: return True -def _get_task_group_and_task(module_name: str) -> tuple[str, str]: +def _get_task_group_and_task(module_name: str) -> Tuple[str, str]: """Get task_group and task name. get task_group and task name based on api file __name__ From bee28b94f1220fac76b9f721a90dca72afdbb40d Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 16 Jul 2026 19:01:46 +0800 Subject: [PATCH 09/14] feat: add centralized error registry and improve internal error handling --- dashscope/finetune/agentic_rl.py | 6 +++--- dashscope/finetune/reinforcement/common/model.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dashscope/finetune/agentic_rl.py b/dashscope/finetune/agentic_rl.py index 61b9c18..93832b4 100644 --- a/dashscope/finetune/agentic_rl.py +++ b/dashscope/finetune/agentic_rl.py @@ -3,7 +3,7 @@ # Copyright (c) Alibaba, Inc. and its affiliates. -from typing import Union, List, Optional, ClassVar, Dict, Any +from typing import Union, List, Optional, ClassVar, Dict, Any, Tuple from typing_extensions import Self from dashscope.client.base_api import CreateMixin @@ -81,7 +81,7 @@ async def register_functions( ] ] = None, lazy_load: Optional[bool] = True, - ) -> tuple[ + ) -> Tuple[ List[str], List[str], List[str], @@ -127,7 +127,7 @@ async def upload_datasets( datasets: Optional[List[Dataset]] = None, training_files: Optional[Union[List[str], str]] = None, validation_files: Optional[Union[List[str], str]] = None, - ) -> tuple[List[str], List[str]]: + ) -> Tuple[List[str], List[str]]: if datasets: self.tuning.datasets = datasets diff --git a/dashscope/finetune/reinforcement/common/model.py b/dashscope/finetune/reinforcement/common/model.py index 8f4123b..879c277 100644 --- a/dashscope/finetune/reinforcement/common/model.py +++ b/dashscope/finetune/reinforcement/common/model.py @@ -1171,7 +1171,7 @@ class TuningModel(Models, BaseModel): async def register_functions( self, lazy_load: Optional[bool] = True, - ) -> tuple[ + ) -> Tuple[ List[str], List[str], List[str], @@ -1265,7 +1265,7 @@ async def upload_datasets( self, training_files: Union[List[str], str] = None, validation_files: Union[List[str], str] = None, - ) -> tuple[List[str], List[str]]: + ) -> Tuple[List[str], List[str]]: """Register and validate training/validation datasets.""" uploaded_training_ids = [] uploaded_validation_ids = [] From f45a27b67e81d815a72834d7f56ebe21b60c62af Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 17 Jul 2026 09:36:35 +0800 Subject: [PATCH 10/14] refactor(cli): keep error codes in original camelCase format --- dashscope/cli/common.py | 130 +++++++++++++++++++--------------- tests/unit/test_cli_common.py | 12 ++-- 2 files changed, 77 insertions(+), 65 deletions(-) diff --git a/dashscope/cli/common.py b/dashscope/cli/common.py index da864c7..066952c 100644 --- a/dashscope/cli/common.py +++ b/dashscope/cli/common.py @@ -27,103 +27,115 @@ # Error code mapping: SDK error codes -> CLI-friendly error codes # --------------------------------------------------------------------------- ERROR_CODE_MAPPING: Dict[str, str] = { - # Authentication errors - "AuthenticationError": "AUTH_FAILED", - "AuthFailed": "AUTH_FAILED", - "InvalidToken": "AUTH_FAILED", - "TokenExpired": "AUTH_FAILED", - "Unauthorized": "AUTH_FAILED", - # Parameter errors - "InvalidParameter": "INVALID_PARAMETER", - "InvalidParam": "INVALID_PARAMETER", - "BadRequest": "INVALID_PARAMETER", - "ModelRequired": "MISSING_MODEL", - "InvalidModel": "INVALID_MODEL", - "InvalidInput": "INVALID_INPUT", - "InvalidFileFormat": "INVALID_FILE_FORMAT", - "InputDataRequired": "MISSING_INPUT_DATA", - "InputRequired": "MISSING_INPUT", - "UnsupportedDataType": "UNSUPPORTED_DATA_TYPE", - # Task errors - "InvalidTask": "INVALID_TASK", - "UnsupportedTask": "UNSUPPORTED_TASK", - "UnsupportedModel": "UNSUPPORTED_MODEL", - "UnsupportedApiProtocol": "UNSUPPORTED_PROTOCOL", - "NotImplemented": "NOT_IMPLEMENTED", - "MultiInputsWithBinaryNotSupported": "BINARY_INPUT_NOT_SUPPORTED", - "UnexpectedMessageReceived": "UNEXPECTED_MESSAGE", - "UnsupportedData": "UNSUPPORTED_DATA", - "UnknownMessageReceived": "UNKNOWN_MESSAGE", - # Service errors - "ServiceUnavailableError": "SERVICE_UNAVAILABLE", - "UnsupportedHTTPMethod": "UNSUPPORTED_METHOD", - "AsyncTaskCreateFailed": "TASK_CREATE_FAILED", - "UploadFileException": "UPLOAD_FAILED", - "TimeoutException": "REQUEST_TIMEOUT", - # Assistant errors - "AssistantError": "ASSISTANT_ERROR", + # Authentication errors - keep original camelCase format + "AuthenticationError": "AuthenticationError", + "AuthFailed": "AuthFailed", + "InvalidToken": "InvalidToken", + "TokenExpired": "TokenExpired", + "Unauthorized": "Unauthorized", + # Parameter errors - keep original camelCase format + "InvalidParameter": "InvalidParameter", + "InvalidParam": "InvalidParam", + "BadRequest": "BadRequest", + "ModelRequired": "ModelRequired", + "InvalidModel": "InvalidModel", + "InvalidInput": "InvalidInput", + "InvalidFileFormat": "InvalidFileFormat", + "InputDataRequired": "InputDataRequired", + "InputRequired": "InputRequired", + "UnsupportedDataType": "UnsupportedDataType", + # Task errors - keep original camelCase format + "InvalidTask": "InvalidTask", + "UnsupportedTask": "UnsupportedTask", + "UnsupportedModel": "UnsupportedModel", + "UnsupportedApiProtocol": "UnsupportedApiProtocol", + "NotImplemented": "NotImplemented", + "MultiInputsWithBinaryNotSupported": "MultiInputsWithBinaryNotSupported", + "UnexpectedMessageReceived": "UnexpectedMessageReceived", + "UnsupportedData": "UnsupportedData", + "UnknownMessageReceived": "UnknownMessageReceived", + # Service errors - keep original camelCase format + "ServiceUnavailableError": "ServiceUnavailableError", + "UnsupportedHTTPMethod": "UnsupportedHTTPMethod", + "AsyncTaskCreateFailed": "AsyncTaskCreateFailed", + "UploadFileException": "UploadFileException", + "TimeoutException": "TimeoutException", + # Assistant errors - keep original camelCase format + "AssistantError": "AssistantError", } # Error message templates with CLI context ERROR_MESSAGE_TEMPLATES: Dict[str, str] = { - "AUTH_FAILED": ( + "AuthFailed": ( "Authentication failed. " "Please check your API key and try again." ), - "INVALID_PARAMETER": ( + "InvalidToken": ( + "Authentication failed. " "Please check your API key and try again." + ), + "TokenExpired": ( + "Authentication failed. " "Please check your API key and try again." + ), + "Unauthorized": ( + "Authentication failed. " "Please check your API key and try again." + ), + "InvalidParameter": ( + "Invalid parameter provided. " "Please check your input parameters." + ), + "InvalidParam": ( "Invalid parameter provided. " "Please check your input parameters." ), - "MISSING_MODEL": ( + "ModelRequired": ( "Model parameter is required. " "Please specify a valid model." ), - "INVALID_MODEL": ( + "InvalidModel": ( "Invalid model specified. " "Please check the model name." ), - "INVALID_INPUT": ( + "InvalidInput": ( "Invalid input data. " "Please check your input format." ), - "INVALID_FILE_FORMAT": ( + "InvalidFileFormat": ( "Invalid file format. " "Please check the file type." ), - "MISSING_INPUT_DATA": ( + "InputDataRequired": ( "Input data is required. " "Please provide the necessary input." ), - "MISSING_INPUT": ( + "InputRequired": ( "Input is required. " "Please provide the necessary input." ), - "UNSUPPORTED_DATA_TYPE": ( + "UnsupportedDataType": ( "Unsupported data type. " "Please check the data format." ), - "INVALID_TASK": ("Invalid task specified. " "Please check the task type."), - "UNSUPPORTED_TASK": ( + "InvalidTask": ("Invalid task specified. " "Please check the task type."), + "UnsupportedTask": ( "Unsupported task type. " "Please check the available tasks." ), - "UNSUPPORTED_MODEL": ( + "UnsupportedModel": ( "Unsupported model. " "Please check the available models." ), - "UNSUPPORTED_PROTOCOL": ( + "UnsupportedApiProtocol": ( "Unsupported API protocol. " "Please check the protocol version." ), - "NOT_IMPLEMENTED": "This feature is not yet implemented.", - "BINARY_INPUT_NOT_SUPPORTED": ( + "NotImplemented": "This feature is not yet implemented.", + "MultiInputsWithBinaryNotSupported": ( "Binary input is not supported with multiple inputs." ), - "UNEXPECTED_MESSAGE": ("Unexpected message received from the server."), - "UNSUPPORTED_DATA": "Unsupported data format.", - "UNKNOWN_MESSAGE": ("Unknown message received from the server."), - "SERVICE_UNAVAILABLE": ( + "UnexpectedMessageReceived": ("Unexpected message received from the server."), + "UnsupportedData": "Unsupported data format.", + "UnknownMessageReceived": ("Unknown message received from the server."), + "ServiceUnavailableError": ( "Service is temporarily unavailable. " "Please try again later." ), - "UNSUPPORTED_METHOD": ( + "UnsupportedHTTPMethod": ( "Unsupported HTTP method. " "Please check the request method." ), - "TASK_CREATE_FAILED": ( + "AsyncTaskCreateFailed": ( "Failed to create async task. " "Please check your request." ), - "UPLOAD_FAILED": ( + "UploadFileException": ( "File upload failed. " "Please check the file and try again." ), - "REQUEST_TIMEOUT": "Request timed out. Please try again.", - "ASSISTANT_ERROR": ( + "TimeoutException": "Request timed out. Please try again.", + "AssistantError": ( "Assistant encountered an error. " "Please check the error details." ), } diff --git a/tests/unit/test_cli_common.py b/tests/unit/test_cli_common.py index 1216e14..5a3a585 100644 --- a/tests/unit/test_cli_common.py +++ b/tests/unit/test_cli_common.py @@ -46,8 +46,8 @@ def test_missing_request_id(self, capsys): captured = capsys.readouterr() # Missing request_id should not be displayed (empty fields are omitted) assert "request_id:" not in captured.err - # Error code is mapped to CLI-friendly code - assert "INVALID_PARAMETER" in captured.err + # Error code is kept in original camelCase format + assert "InvalidParameter" in captured.err assert "400" in captured.err def test_empty_code_and_message(self, capsys): @@ -131,8 +131,8 @@ def test_business_error_in_dict_output(self, capsys): captured = capsys.readouterr() # Rich may wrap long lines and add extra spaces, normalize whitespace normalized_err = " ".join(captured.err.split()) - # Error code is mapped to CLI-friendly code - assert "INVALID_PARAMETER" in normalized_err + # Error code is kept in original camelCase format + assert "InvalidParameter" in normalized_err assert "Model not found" in normalized_err def test_business_error_without_message(self, capsys): @@ -223,8 +223,8 @@ def failing_function(): captured = capsys.readouterr() assert "Test action" in captured.err assert "req_auth" in captured.err - # Error code is mapped to CLI-friendly code - assert "AUTH_FAILED" in captured.err + # Error code is kept in original camelCase format + assert "AuthFailed" in captured.err def test_generic_exception_handling(self, capsys): """Test handling of generic exceptions.""" From 67cc1eed1e82900c058a849c9be4f6784b740a6c Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 17 Jul 2026 10:13:00 +0800 Subject: [PATCH 11/14] refactor: split _build_api_request to reduce statement count --- dashscope/api_entities/api_request_factory.py | 94 ++++++++++++++----- dashscope/cli/common.py | 8 +- dashscope/common/error.py | 12 +++ test_base_address_validation.py | 91 ++++++++++++++++++ test_invalid_base_url.py | 89 ++++++++++++++++++ tests/unit/test_cli_common.py | 6 +- 6 files changed, 271 insertions(+), 29 deletions(-) create mode 100644 test_base_address_validation.py create mode 100644 test_invalid_base_url.py diff --git a/dashscope/api_entities/api_request_factory.py b/dashscope/api_entities/api_request_factory.py index c231287..7509fc9 100644 --- a/dashscope/api_entities/api_request_factory.py +++ b/dashscope/api_entities/api_request_factory.py @@ -69,6 +69,68 @@ def _get_protocol_params(kwargs): ) +def _build_http_url( + base_address, + is_service, + task_group, + task, + function, + extra_url_parameters, +): + """Build HTTP/HTTPS URL from components.""" + if base_address is None: + base_address = dashscope.base_http_api_url + + # Validate base_address has proper scheme (http:// or https://) + if base_address and not base_address.startswith( + ("http://", "https://"), + ): + from dashscope.common.error import InvalidBaseURL + + raise InvalidBaseURL( + f"Invalid URL '{base_address}': No scheme supplied. " + f"Perhaps you meant https://{base_address}?", + ) + + if not base_address.endswith("/"): + http_url = base_address + "/" + else: + http_url = base_address + + if is_service: + http_url = http_url + SERVICE_API_PATH + "/" + + if task_group: + http_url += f"{task_group}/" + if task: + http_url += f"{task}/" + if function: + http_url += function + if extra_url_parameters is not None and extra_url_parameters: + http_url += "?" + urlencode(extra_url_parameters) + + return http_url + + +def _build_websocket_url(base_address): + """Build WebSocket URL with validation.""" + if base_address is not None: + websocket_url = base_address + else: + websocket_url = dashscope.base_websocket_api_url + + # Validate websocket_url has proper scheme (ws:// or wss://) + if websocket_url and not websocket_url.startswith(("ws://", "wss://")): + from dashscope.common.error import InvalidBaseURL + + raise InvalidBaseURL( + f"Invalid URL '{websocket_url}': No scheme supplied. " + f"Perhaps you meant wss://{websocket_url}?", + ) + + return websocket_url + + def _build_api_request( # pylint: disable=too-many-branches model: str, input: object, # pylint: disable=redefined-builtin @@ -102,24 +164,14 @@ def _build_api_request( # pylint: disable=too-many-branches encryption = None if api_protocol in [ApiProtocol.HTTP, ApiProtocol.HTTPS]: - if base_address is None: - base_address = dashscope.base_http_api_url - if not base_address.endswith("/"): - http_url = base_address + "/" - else: - http_url = base_address - - if is_service: - http_url = http_url + SERVICE_API_PATH + "/" - - if task_group: - http_url += f"{task_group}/" - if task: - http_url += f"{task}/" - if function: - http_url += function - if extra_url_parameters is not None and extra_url_parameters: - http_url += "?" + urlencode(extra_url_parameters) + http_url = _build_http_url( + base_address, + is_service, + task_group, + task, + function, + extra_url_parameters, + ) if enable_encryption is True: encryption = Encryption() @@ -142,10 +194,8 @@ def _build_api_request( # pylint: disable=too-many-branches session=session, ) elif api_protocol == ApiProtocol.WEBSOCKET: - if base_address is not None: - websocket_url = base_address - else: - websocket_url = dashscope.base_websocket_api_url + websocket_url = _build_websocket_url(base_address) + pre_task_id = kwargs.pop("pre_task_id", None) request = WebSocketRequest( url=websocket_url, diff --git a/dashscope/cli/common.py b/dashscope/cli/common.py index 066952c..ae9719c 100644 --- a/dashscope/cli/common.py +++ b/dashscope/cli/common.py @@ -90,9 +90,7 @@ "InvalidModel": ( "Invalid model specified. " "Please check the model name." ), - "InvalidInput": ( - "Invalid input data. " "Please check your input format." - ), + "InvalidInput": ("Invalid input data. " "Please check your input format."), "InvalidFileFormat": ( "Invalid file format. " "Please check the file type." ), @@ -119,7 +117,9 @@ "MultiInputsWithBinaryNotSupported": ( "Binary input is not supported with multiple inputs." ), - "UnexpectedMessageReceived": ("Unexpected message received from the server."), + "UnexpectedMessageReceived": ( + "Unexpected message received from the server." + ), "UnsupportedData": "Unsupported data format.", "UnknownMessageReceived": ("Unknown message received from the server."), "ServiceUnavailableError": ( diff --git a/dashscope/common/error.py b/dashscope/common/error.py index 2b49cc7..cb1cabb 100644 --- a/dashscope/common/error.py +++ b/dashscope/common/error.py @@ -144,3 +144,15 @@ class UploadFileException(DashScopeException): class TimeoutException(DashScopeException): pass + + +class InvalidBaseURL(DashScopeException): + """Raised when the base_address is invalid. + + This can happen when the scheme is missing or the URL is malformed. + """ + + class BaseUrlError: + """Error message pattern for invalid base URL.""" + + pattern = r"No scheme supplied" diff --git a/test_base_address_validation.py b/test_base_address_validation.py new file mode 100644 index 0000000..9d14d22 --- /dev/null +++ b/test_base_address_validation.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""Test for base_address validation with DashScopeException.""" + +import pytest +from dashscope.common.error import DashScopeException +from dashscope.api_entities.api_request_factory import _build_api_request + + +class TestBaseAddressValidation: + """Test cases for base_address validation using DashScopeException.""" + + def test_http_base_address_without_scheme(self): + """Test that HTTP base_address without scheme raises DashScopeException.""" + with pytest.raises(DashScopeException, match=r"No scheme supplied"): + _build_api_request( + model="text-embedding-v2", + input="hello", + task_group="embeddings", + task="text-embedding", + function="text-embedding", + api_key="test-api-key", + base_address="POC_URL" + ) + + def test_https_base_address_with_scheme_should_work(self): + """Test that HTTPS base_address with scheme should not raise exception.""" + try: + request = _build_api_request( + model="text-embedding-v2", + input="hello", + task_group="embeddings", + task="text-embedding", + function="text-embedding", + api_key="test-api-key", + base_address="https://invalid.example.com" + ) + # If we get here, URL validation passed + assert request is not None + except DashScopeException as e: + if "No scheme supplied" in str(e): + pytest.fail("Should not raise DashScopeException for valid https URL") + # Other DashScopeException are OK (e.g., network errors) + + def test_multimodal_embedding_invalid_base_address(self): + """Test multimodal embedding with invalid base_address.""" + with pytest.raises(DashScopeException, match=r"No scheme supplied"): + _build_api_request( + model="multimodal-embedding-v1", + input={"image": "test.jpg"}, + task_group="embeddings", + task="multimodal-embedding", + function="multimodal-embedding", + api_key="test-api-key", + base_address="INVALID_URL" + ) + + def test_websocket_invalid_base_address(self): + """Test websocket with invalid base_address.""" + from dashscope.common.constants import ApiProtocol + + with pytest.raises(DashScopeException, match=r"No scheme supplied"): + _build_api_request( + model="tingwu-realtime", + input={"audio": "test.wav"}, + task_group="", + task="", + function="", + api_key="test-api-key", + api_protocol=ApiProtocol.WEBSOCKET, + base_address="INVALID_WS_URL" + ) + + def test_valid_websocket_base_address(self): + """Test websocket with valid wss:// base_address.""" + from dashscope.common.constants import ApiProtocol + + try: + request = _build_api_request( + model="tingwu-realtime", + input={"audio": "test.wav"}, + task_group="", + task="", + function="", + api_key="test-api-key", + api_protocol=ApiProtocol.WEBSOCKET, + base_address="wss://valid.example.com" + ) + assert request is not None + except DashScopeException as e: + if "No scheme supplied" in str(e): + pytest.fail("Should not raise DashScopeException for valid wss:// URL") diff --git a/test_invalid_base_url.py b/test_invalid_base_url.py new file mode 100644 index 0000000..d67fc5b --- /dev/null +++ b/test_invalid_base_url.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +"""Test for InvalidBaseURL exception handling.""" + +import pytest +from dashscope.common.error import InvalidBaseURL +from dashscope.api_entities.api_request_factory import _build_api_request + + +class TestInvalidBaseURL: + """Test cases for invalid base_address validation.""" + + def test_http_base_address_without_scheme(self): + """Test that HTTP base_address without scheme raises InvalidBaseURL.""" + with pytest.raises(InvalidBaseURL, match=r"No scheme supplied"): + _build_api_request( + model="text-embedding-v2", + input="hello", + task_group="embeddings", + task="text-embedding", + function="text-embedding", + api_key="test-api-key", + base_address="POC_URL" + ) + + def test_https_base_address_with_scheme_should_work(self): + """Test that HTTPS base_address with scheme should not raise InvalidBaseURL at validation stage.""" + # This should pass URL validation (will fail later at network level) + try: + request = _build_api_request( + model="text-embedding-v2", + input="hello", + task_group="embeddings", + task="text-embedding", + function="text-embedding", + api_key="test-api-key", + base_address="https://invalid.example.com" + ) + # If we get here, URL validation passed + assert request is not None + except InvalidBaseURL: + pytest.fail("Should not raise InvalidBaseURL for valid https URL") + + def test_multimodal_embedding_invalid_base_address(self): + """Test multimodal embedding with invalid base_address.""" + with pytest.raises(InvalidBaseURL, match=r"No scheme supplied"): + _build_api_request( + model="multimodal-embedding-v1", + input={"image": "test.jpg"}, + task_group="embeddings", + task="multimodal-embedding", + function="multimodal-embedding", + api_key="test-api-key", + base_address="INVALID_URL" + ) + + def test_websocket_invalid_base_address(self): + """Test websocket with invalid base_address.""" + from dashscope.common.constants import ApiProtocol + + with pytest.raises(InvalidBaseURL, match=r"No scheme supplied"): + _build_api_request( + model="tingwu-realtime", + input=None, + task_group="", + task="", + function="", + api_key="test-api-key", + api_protocol=ApiProtocol.WEBSOCKET, + base_address="INVALID_WS_URL" + ) + + def test_valid_websocket_base_address(self): + """Test websocket with valid wss:// base_address.""" + from dashscope.common.constants import ApiProtocol + + try: + request = _build_api_request( + model="tingwu-realtime", + input={"audio": "test.wav"}, # Provide input data + task_group="", + task="", + function="", + api_key="test-api-key", + api_protocol=ApiProtocol.WEBSOCKET, + base_address="wss://valid.example.com" + ) + assert request is not None + except InvalidBaseURL: + pytest.fail("Should not raise InvalidBaseURL for valid wss:// URL") diff --git a/tests/unit/test_cli_common.py b/tests/unit/test_cli_common.py index 5a3a585..37e5a97 100644 --- a/tests/unit/test_cli_common.py +++ b/tests/unit/test_cli_common.py @@ -47,7 +47,7 @@ def test_missing_request_id(self, capsys): # Missing request_id should not be displayed (empty fields are omitted) assert "request_id:" not in captured.err # Error code is kept in original camelCase format - assert "InvalidParameter" in captured.err + assert "BadRequest" in captured.err assert "400" in captured.err def test_empty_code_and_message(self, capsys): @@ -223,8 +223,8 @@ def failing_function(): captured = capsys.readouterr() assert "Test action" in captured.err assert "req_auth" in captured.err - # Error code is kept in original camelCase format - assert "AuthFailed" in captured.err + # Error code is kept in original camelCase format (exception type name) + assert "AuthenticationError" in captured.err def test_generic_exception_handling(self, capsys): """Test handling of generic exceptions.""" From 5e201ace624c124ca7041280232bcf8545e554a8 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 17 Jul 2026 10:20:14 +0800 Subject: [PATCH 12/14] fix: correct WebSocket URL scheme and fix lint errors --- test_base_address_validation.py | 26 +++++++++++++++----------- test_invalid_base_url.py | 16 ++++++++-------- tests/unit/mock_request_base.py | 2 +- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/test_base_address_validation.py b/test_base_address_validation.py index 9d14d22..7184aba 100644 --- a/test_base_address_validation.py +++ b/test_base_address_validation.py @@ -10,7 +10,7 @@ class TestBaseAddressValidation: """Test cases for base_address validation using DashScopeException.""" def test_http_base_address_without_scheme(self): - """Test that HTTP base_address without scheme raises DashScopeException.""" + """Test HTTP base_address without scheme raises DashScopeException.""" with pytest.raises(DashScopeException, match=r"No scheme supplied"): _build_api_request( model="text-embedding-v2", @@ -19,11 +19,11 @@ def test_http_base_address_without_scheme(self): task="text-embedding", function="text-embedding", api_key="test-api-key", - base_address="POC_URL" + base_address="POC_URL", ) def test_https_base_address_with_scheme_should_work(self): - """Test that HTTPS base_address with scheme should not raise exception.""" + """Test HTTPS base_address with scheme should not raise exception.""" try: request = _build_api_request( model="text-embedding-v2", @@ -32,13 +32,15 @@ def test_https_base_address_with_scheme_should_work(self): task="text-embedding", function="text-embedding", api_key="test-api-key", - base_address="https://invalid.example.com" + base_address="https://invalid.example.com", ) # If we get here, URL validation passed assert request is not None except DashScopeException as e: if "No scheme supplied" in str(e): - pytest.fail("Should not raise DashScopeException for valid https URL") + pytest.fail( + "Should not raise DashScopeException for valid https URL", + ) # Other DashScopeException are OK (e.g., network errors) def test_multimodal_embedding_invalid_base_address(self): @@ -51,13 +53,13 @@ def test_multimodal_embedding_invalid_base_address(self): task="multimodal-embedding", function="multimodal-embedding", api_key="test-api-key", - base_address="INVALID_URL" + base_address="INVALID_URL", ) def test_websocket_invalid_base_address(self): """Test websocket with invalid base_address.""" from dashscope.common.constants import ApiProtocol - + with pytest.raises(DashScopeException, match=r"No scheme supplied"): _build_api_request( model="tingwu-realtime", @@ -67,13 +69,13 @@ def test_websocket_invalid_base_address(self): function="", api_key="test-api-key", api_protocol=ApiProtocol.WEBSOCKET, - base_address="INVALID_WS_URL" + base_address="INVALID_WS_URL", ) def test_valid_websocket_base_address(self): """Test websocket with valid wss:// base_address.""" from dashscope.common.constants import ApiProtocol - + try: request = _build_api_request( model="tingwu-realtime", @@ -83,9 +85,11 @@ def test_valid_websocket_base_address(self): function="", api_key="test-api-key", api_protocol=ApiProtocol.WEBSOCKET, - base_address="wss://valid.example.com" + base_address="wss://valid.example.com", ) assert request is not None except DashScopeException as e: if "No scheme supplied" in str(e): - pytest.fail("Should not raise DashScopeException for valid wss:// URL") + pytest.fail( + "Should not raise DashScopeException for valid wss:// URL", + ) diff --git a/test_invalid_base_url.py b/test_invalid_base_url.py index d67fc5b..ecea143 100644 --- a/test_invalid_base_url.py +++ b/test_invalid_base_url.py @@ -19,11 +19,11 @@ def test_http_base_address_without_scheme(self): task="text-embedding", function="text-embedding", api_key="test-api-key", - base_address="POC_URL" + base_address="POC_URL", ) def test_https_base_address_with_scheme_should_work(self): - """Test that HTTPS base_address with scheme should not raise InvalidBaseURL at validation stage.""" + """Test HTTPS base_address with scheme passes validation.""" # This should pass URL validation (will fail later at network level) try: request = _build_api_request( @@ -33,7 +33,7 @@ def test_https_base_address_with_scheme_should_work(self): task="text-embedding", function="text-embedding", api_key="test-api-key", - base_address="https://invalid.example.com" + base_address="https://invalid.example.com", ) # If we get here, URL validation passed assert request is not None @@ -50,13 +50,13 @@ def test_multimodal_embedding_invalid_base_address(self): task="multimodal-embedding", function="multimodal-embedding", api_key="test-api-key", - base_address="INVALID_URL" + base_address="INVALID_URL", ) def test_websocket_invalid_base_address(self): """Test websocket with invalid base_address.""" from dashscope.common.constants import ApiProtocol - + with pytest.raises(InvalidBaseURL, match=r"No scheme supplied"): _build_api_request( model="tingwu-realtime", @@ -66,13 +66,13 @@ def test_websocket_invalid_base_address(self): function="", api_key="test-api-key", api_protocol=ApiProtocol.WEBSOCKET, - base_address="INVALID_WS_URL" + base_address="INVALID_WS_URL", ) def test_valid_websocket_base_address(self): """Test websocket with valid wss:// base_address.""" from dashscope.common.constants import ApiProtocol - + try: request = _build_api_request( model="tingwu-realtime", @@ -82,7 +82,7 @@ def test_valid_websocket_base_address(self): function="", api_key="test-api-key", api_protocol=ApiProtocol.WEBSOCKET, - base_address="wss://valid.example.com" + base_address="wss://valid.example.com", ) assert request is not None except InvalidBaseURL: diff --git a/tests/unit/mock_request_base.py b/tests/unit/mock_request_base.py index c5c016b..ef3ee37 100644 --- a/tests/unit/mock_request_base.py +++ b/tests/unit/mock_request_base.py @@ -20,7 +20,7 @@ def setup_class(cls): super().setup_class() dashscope.base_http_api_url = "http://localhost:8089/api/v1/" dashscope.base_websocket_api_url = ( - "http://localhost:8089/api-ws/v1/inference" + "ws://localhost:8089/api-ws/v1/inference" ) dashscope.api_key = "default" dashscope.api_protocol = "http" From 712d959f1b06f497c4e7304a2a18177cd68e5f5c Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 17 Jul 2026 11:55:28 +0800 Subject: [PATCH 13/14] feat: enhance error handling for invalid URLs and authentication failures --- dashscope/api_entities/api_request_factory.py | 21 ++++++------- .../qwen_tts_realtime/qwen_tts_realtime.py | 31 ++++++++++++++++++- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/dashscope/api_entities/api_request_factory.py b/dashscope/api_entities/api_request_factory.py index 7509fc9..a9da3b9 100644 --- a/dashscope/api_entities/api_request_factory.py +++ b/dashscope/api_entities/api_request_factory.py @@ -70,14 +70,14 @@ def _get_protocol_params(kwargs): def _build_http_url( - base_address, - is_service, - task_group, - task, - function, - extra_url_parameters, -): - """Build HTTP/HTTPS URL from components.""" + base_address: str, + is_service: bool, + task_group: str, + task: str, + function: str, + extra_url_parameters: dict, +) -> str: + """Build HTTP/HTTPS URL from components with validation.""" if base_address is None: base_address = dashscope.base_http_api_url @@ -112,8 +112,8 @@ def _build_http_url( return http_url -def _build_websocket_url(base_address): - """Build WebSocket URL with validation.""" +def _build_websocket_url(base_address: str) -> str: + """Build and validate WebSocket URL.""" if base_address is not None: websocket_url = base_address else: @@ -195,7 +195,6 @@ def _build_api_request( # pylint: disable=too-many-branches ) elif api_protocol == ApiProtocol.WEBSOCKET: websocket_url = _build_websocket_url(base_address) - pre_task_id = kwargs.pop("pre_task_id", None) request = WebSocketRequest( url=websocket_url, diff --git a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py index 3d5c374..2a7e077 100644 --- a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py +++ b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py @@ -10,7 +10,7 @@ import websocket # pylint: disable=wrong-import-order import dashscope -from dashscope.common.error import ModelRequired +from dashscope.common.error import AuthenticationError, ModelRequired from dashscope.common.logging import logger from dashscope.common.utils import get_user_agent @@ -105,6 +105,8 @@ def __init__( self.last_first_text_time = None self.last_first_audio_delay = None self.metrics = [] + self._auth_failed = False # Flag to track authentication failures + self._connection_error = None # Store connection error details def _generate_event_id(self): """ @@ -131,6 +133,10 @@ def connect(self) -> None: """ connect to server, create session and return default session configuration # noqa: E501 """ + # Reset error flags before connecting + self._auth_failed = False + self._connection_error = None + self.ws = websocket.WebSocketApp( self.url, header=self._get_websocket_header(), @@ -147,7 +153,16 @@ def connect(self) -> None: not (self.ws.sock and self.ws.sock.connected) and (time.time() - start_time) < timeout ): + # Check for authentication errors first + if self._auth_failed and self._connection_error is not None: + raise self._connection_error + time.sleep(0.1) # Brief sleep to avoid busy polling + + # Final check after timeout + if self._auth_failed and self._connection_error is not None: + raise self._connection_error + if not (self.ws.sock and self.ws.sock.connected): raise TimeoutError( "websocket connection could not established within 5s. " @@ -415,6 +430,20 @@ def on_close( # pylint: disable=unused-argument # Callback for WebSocket error def on_error(self, ws, error): # pylint: disable=unused-argument logger.error(f"websocket error: {error}") + + # Detect authentication errors from error message + error_str = str(error) + if ( + "401" in error_str + or "Unauthorized" in error_str + or "InvalidApiKey" in error_str + ): + self._auth_failed = True + self._connection_error = AuthenticationError( + "API Key is invalid. Please check your API key configuration.", + ) + logger.error("Authentication failed: Invalid API Key") + # Do not raise exception here, let the connection close naturally # Raising exception in callback can cause unexpected thread termination From ffc401cc80d1b48143201025684807e130187767 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 22 Jul 2026 15:55:39 +0800 Subject: [PATCH 14/14] refactor: improve WebSocket error handling and cleanup unused code --- dashscope/api_entities/websocket_request.py | 52 +++++------ dashscope/common/error_registry.py | 6 +- dashscope/common/utils.py | 15 ++-- test_base_address_validation.py | 95 --------------------- test_invalid_base_url.py | 89 ------------------- 5 files changed, 31 insertions(+), 226 deletions(-) delete mode 100644 test_base_address_validation.py delete mode 100644 test_invalid_base_url.py diff --git a/dashscope/api_entities/websocket_request.py b/dashscope/api_entities/websocket_request.py index 45dc596..0f0a704 100644 --- a/dashscope/api_entities/websocket_request.py +++ b/dashscope/api_entities/websocket_request.py @@ -119,11 +119,8 @@ async def aio_call(self): async def connection_handler( self, ): # pylint: disable=too-many-branches,too-many-statements - send_task = None - task_id = ( - "" # Initialize at the beginning to ensure it's always defined - ) try: + task_id = None async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout( total=self.timeout, @@ -133,7 +130,7 @@ async def connection_handler( async with session.ws_connect( self.url, headers=self.headers, - heartbeat=30, + heartbeat=6000, ) as ws: await self._start_task(ws) # send start task action. task_id = self.task_headers["task_id"] @@ -185,27 +182,15 @@ async def connection_handler( message, ) else: # duplex mode - # Track the background task to ensure proper cleanup - send_task = asyncio.create_task( - self._send_continue_task_data(ws), - ) - try: - async for is_binary, message in self._receive_streaming_data_task( # noqa E501 # pylint: disable=line-too-long - ws, - ): - yield self._to_DashScopeAPIResponse( - task_id, - is_binary, - message, - ) - finally: - # Cancel the send task if it's still running - if send_task and not send_task.done(): - send_task.cancel() - try: - await send_task - except asyncio.CancelledError: - pass + asyncio.create_task(self._send_continue_task_data(ws)) + async for is_binary, message in self._receive_streaming_data_task( # noqa E501 # pylint: disable=line-too-long + ws, + ): + yield self._to_DashScopeAPIResponse( + task_id, + is_binary, + message, + ) except RequestFailure as e: yield DashScopeAPIResponse( request_id=e.request_id, @@ -225,13 +210,14 @@ async def connection_handler( code=SERVICE_UNAVAILABLE.error_code, message=SERVICE_UNAVAILABLE.error_msg, ) - else: - yield DashScopeAPIResponse( - request_id=task_id if task_id else "", - status_code=-1, - code="ClientConnectorError", - message=str(e), - ) + return + + yield DashScopeAPIResponse( + request_id=task_id if task_id else "", + status_code=INTERNAL_ERROR.status_code, + code=INTERNAL_ERROR.error_code, + message=INTERNAL_ERROR.error_msg, + ) except aiohttp.WSServerHandshakeError as e: original_msg = e.message or "" diff --git a/dashscope/common/error_registry.py b/dashscope/common/error_registry.py index b1dd6bf..e5bc5e6 100644 --- a/dashscope/common/error_registry.py +++ b/dashscope/common/error_registry.py @@ -4,6 +4,10 @@ from dataclasses import dataclass, field from typing import Dict, List +__version__ = "1.0.0" +__commit__ = "aa17fe8" +__snapshot__ = False + @dataclass(frozen=True) class PublicError: @@ -39,8 +43,6 @@ def format_message(self, variables: Dict[str, str] = None) -> str: return msg -# -- Public Errors -------------------------------------------------- - INVALID_REQUEST = PublicError( key="invalid_request", status_code=400, diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index b1a8aca..bf19bba 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -239,7 +239,6 @@ def _handle_stream(response: requests.Response): def _handle_error_message(error, status_code, flattened_output, headers): - code = "" msg = "" request_id = "" @@ -316,40 +315,42 @@ def _handle_http_failed_response( flattened_output, headers, ) - # SSE 响应中没有有效的错误数据 + # No valid error data in SSE response error_message = "\n".join(msgs).strip() or INTERNAL_ERROR.format_msg() + error_code = INTERNAL_ERROR.error_code logger.error( "Request failed: status=%s, code=%s, message=%s", response.status_code, - INTERNAL_ERROR.error_code, + error_code, truncate_error_message(error_message), ) return DashScopeAPIResponse( request_id=request_id, status_code=response.status_code, - code=INTERNAL_ERROR.error_code, + code=error_code, message=error_message, headers=headers, ) else: msg = response.content.decode("utf-8") error_message = msg or INTERNAL_ERROR.format_msg() + error_code = INTERNAL_ERROR.error_code logger.error( "Request failed: status=%s, code=%s, message=%s", response.status_code, - INTERNAL_ERROR.error_code, + error_code, truncate_error_message(error_message), ) if flattened_output: return { # type: ignore[return-value] "status_code": response.status_code, - "code": INTERNAL_ERROR.error_code, + "code": error_code, "message": error_message, } return DashScopeAPIResponse( request_id=request_id, status_code=response.status_code, - code=INTERNAL_ERROR.error_code, + code=error_code, message=error_message, headers=headers, ) diff --git a/test_base_address_validation.py b/test_base_address_validation.py deleted file mode 100644 index 7184aba..0000000 --- a/test_base_address_validation.py +++ /dev/null @@ -1,95 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test for base_address validation with DashScopeException.""" - -import pytest -from dashscope.common.error import DashScopeException -from dashscope.api_entities.api_request_factory import _build_api_request - - -class TestBaseAddressValidation: - """Test cases for base_address validation using DashScopeException.""" - - def test_http_base_address_without_scheme(self): - """Test HTTP base_address without scheme raises DashScopeException.""" - with pytest.raises(DashScopeException, match=r"No scheme supplied"): - _build_api_request( - model="text-embedding-v2", - input="hello", - task_group="embeddings", - task="text-embedding", - function="text-embedding", - api_key="test-api-key", - base_address="POC_URL", - ) - - def test_https_base_address_with_scheme_should_work(self): - """Test HTTPS base_address with scheme should not raise exception.""" - try: - request = _build_api_request( - model="text-embedding-v2", - input="hello", - task_group="embeddings", - task="text-embedding", - function="text-embedding", - api_key="test-api-key", - base_address="https://invalid.example.com", - ) - # If we get here, URL validation passed - assert request is not None - except DashScopeException as e: - if "No scheme supplied" in str(e): - pytest.fail( - "Should not raise DashScopeException for valid https URL", - ) - # Other DashScopeException are OK (e.g., network errors) - - def test_multimodal_embedding_invalid_base_address(self): - """Test multimodal embedding with invalid base_address.""" - with pytest.raises(DashScopeException, match=r"No scheme supplied"): - _build_api_request( - model="multimodal-embedding-v1", - input={"image": "test.jpg"}, - task_group="embeddings", - task="multimodal-embedding", - function="multimodal-embedding", - api_key="test-api-key", - base_address="INVALID_URL", - ) - - def test_websocket_invalid_base_address(self): - """Test websocket with invalid base_address.""" - from dashscope.common.constants import ApiProtocol - - with pytest.raises(DashScopeException, match=r"No scheme supplied"): - _build_api_request( - model="tingwu-realtime", - input={"audio": "test.wav"}, - task_group="", - task="", - function="", - api_key="test-api-key", - api_protocol=ApiProtocol.WEBSOCKET, - base_address="INVALID_WS_URL", - ) - - def test_valid_websocket_base_address(self): - """Test websocket with valid wss:// base_address.""" - from dashscope.common.constants import ApiProtocol - - try: - request = _build_api_request( - model="tingwu-realtime", - input={"audio": "test.wav"}, - task_group="", - task="", - function="", - api_key="test-api-key", - api_protocol=ApiProtocol.WEBSOCKET, - base_address="wss://valid.example.com", - ) - assert request is not None - except DashScopeException as e: - if "No scheme supplied" in str(e): - pytest.fail( - "Should not raise DashScopeException for valid wss:// URL", - ) diff --git a/test_invalid_base_url.py b/test_invalid_base_url.py deleted file mode 100644 index ecea143..0000000 --- a/test_invalid_base_url.py +++ /dev/null @@ -1,89 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test for InvalidBaseURL exception handling.""" - -import pytest -from dashscope.common.error import InvalidBaseURL -from dashscope.api_entities.api_request_factory import _build_api_request - - -class TestInvalidBaseURL: - """Test cases for invalid base_address validation.""" - - def test_http_base_address_without_scheme(self): - """Test that HTTP base_address without scheme raises InvalidBaseURL.""" - with pytest.raises(InvalidBaseURL, match=r"No scheme supplied"): - _build_api_request( - model="text-embedding-v2", - input="hello", - task_group="embeddings", - task="text-embedding", - function="text-embedding", - api_key="test-api-key", - base_address="POC_URL", - ) - - def test_https_base_address_with_scheme_should_work(self): - """Test HTTPS base_address with scheme passes validation.""" - # This should pass URL validation (will fail later at network level) - try: - request = _build_api_request( - model="text-embedding-v2", - input="hello", - task_group="embeddings", - task="text-embedding", - function="text-embedding", - api_key="test-api-key", - base_address="https://invalid.example.com", - ) - # If we get here, URL validation passed - assert request is not None - except InvalidBaseURL: - pytest.fail("Should not raise InvalidBaseURL for valid https URL") - - def test_multimodal_embedding_invalid_base_address(self): - """Test multimodal embedding with invalid base_address.""" - with pytest.raises(InvalidBaseURL, match=r"No scheme supplied"): - _build_api_request( - model="multimodal-embedding-v1", - input={"image": "test.jpg"}, - task_group="embeddings", - task="multimodal-embedding", - function="multimodal-embedding", - api_key="test-api-key", - base_address="INVALID_URL", - ) - - def test_websocket_invalid_base_address(self): - """Test websocket with invalid base_address.""" - from dashscope.common.constants import ApiProtocol - - with pytest.raises(InvalidBaseURL, match=r"No scheme supplied"): - _build_api_request( - model="tingwu-realtime", - input=None, - task_group="", - task="", - function="", - api_key="test-api-key", - api_protocol=ApiProtocol.WEBSOCKET, - base_address="INVALID_WS_URL", - ) - - def test_valid_websocket_base_address(self): - """Test websocket with valid wss:// base_address.""" - from dashscope.common.constants import ApiProtocol - - try: - request = _build_api_request( - model="tingwu-realtime", - input={"audio": "test.wav"}, # Provide input data - task_group="", - task="", - function="", - api_key="test-api-key", - api_protocol=ApiProtocol.WEBSOCKET, - base_address="wss://valid.example.com", - ) - assert request is not None - except InvalidBaseURL: - pytest.fail("Should not raise InvalidBaseURL for valid wss:// URL")