Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion dashscope/v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
from dashscope.v2._config import Config, Settings, settings
from dashscope.v2._quickstart import chat
from dashscope.v2.client import AsyncClient, Client
from dashscope.v2.errors import DashScopeError, ErrorCode
from dashscope.v2.errors import (
APIResponseValidationError,
DashScopeError,
ErrorCode,
)
from dashscope.v2.transport.ws_connection import WsConnection, WsTask

# Offline helpers delegated to the 1.x implementation, re-exported so the
Expand All @@ -23,6 +27,7 @@
)

__all__ = [
"APIResponseValidationError",
"AsyncClient",
"Client",
"Config",
Expand Down
67 changes: 67 additions & 0 deletions dashscope/v2/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
own side. Those come from the catalog's ``SDK_ERRORS`` table as
:class:`SdkError`, and the v2 mount layers over 1.x subsystems
translate with :func:`error_from_sdk_exception`.

:class:`APIResponseValidationError` sits between the two: the gateway
answered, but the body failed SDK-side validation. It is deliberately
not a catalog :class:`SdkError` -- that class means "no HTTP response
behind it" -- and it is not an :class:`APIStatusError` either, so a
broken response never masquerades as a gateway-declared failure.
"""

from __future__ import annotations
Expand Down Expand Up @@ -193,6 +199,67 @@ def __init__(
self.allow_retry = allow_retry


# Body preview cap for __str__: enough to recognise the payload, short
# enough that a large response does not flood logs.
_BODY_PREVIEW_CHARS = 200


class APIResponseValidationError(DashScopeError):
"""Response body failed SDK-side validation.

Distinct from both catalog families on purpose: the gateway did
answer, so the raw ``response`` is attached for inspection, yet the
failure is the SDK's inability to parse the body rather than an
error the gateway declared. ``cause`` carries the original
validation exception (e.g. ``pydantic.ValidationError``) when the
caller has one; raise sites should also use ``raise ... from exc``
so the traceback keeps the chain.

``status_code`` mirrors the HTTP status of ``response`` when there
is one. That is provenance, not a gateway verdict, which is why
this class stays outside ``APIStatusError``.
"""

def __init__(
self,
message: str,
*,
response: Optional[httpx.Response] = None,
body: Any = None,
cause: Optional[BaseException] = None,
) -> None:
headers: Optional[Mapping[str, str]] = None
status_code: Optional[int] = None
request_id: Optional[str] = None
if response is not None:
headers = response.headers
status_code = response.status_code
# Same precedence as _request_id(): body first, header second.
if isinstance(body, dict):
request_id = body.get("request_id")
if request_id is None and response is not None:
request_id = response.headers.get("x-request-id")
super().__init__(
message,
status_code=status_code,
request_id=request_id,
body=body,
headers=headers,
)
self.response = response
self.cause = cause

def __str__(self) -> str:
text = super().__str__()
try:
preview = json.dumps(self.body, ensure_ascii=False, default=repr)
except (TypeError, ValueError):
preview = repr(self.body)
if len(preview) > _BODY_PREVIEW_CHARS:
preview = preview[:_BODY_PREVIEW_CHARS] + "..."
return f"{text} | body={preview}"


_CATALOG_CLASSES: Dict[str, Type[APIStatusError]] = {
cls.__name__: cls
for cls in (
Expand Down
97 changes: 97 additions & 0 deletions tests/v2/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from dashscope.v2 import _error_registry as registry
from dashscope.v2 import errors
from dashscope.v2.errors import (
APIResponseValidationError,
APIStatusError,
AuthenticationError,
BadRequestError,
Expand Down Expand Up @@ -869,3 +870,99 @@ def test_sdk_error_translation_needs_a_family_fallback():
family="sdk.agentstudio",
)
assert "sdk.agentstudio.Error" in str(ei.value)


# -- response validation errors (SDK-side parse failure) --------------


def test_response_validation_error_keeps_response_provenance():
resp = _resp(
200,
{"request_id": "req-body-1", "output": "unexpected"},
headers={"x-request-id": "req-header-1"},
)
cause = ValueError("field 'choices' expected a list")
err = APIResponseValidationError(
"ChatCompletion validation failed",
response=resp,
body={"request_id": "req-body-1", "output": "unexpected"},
cause=cause,
)
assert isinstance(err, DashScopeError)
assert not isinstance(err, APIStatusError)
assert not isinstance(err, SdkError)
assert err.response is resp
assert err.cause is cause
assert err.status_code == 200
# Body wins over the header: it is the payload the SDK failed on.
assert err.request_id == "req-body-1"
assert err.headers["x-request-id"] == "req-header-1"


def test_response_validation_error_str_carries_request_id_and_body():
resp = _resp(200, None, headers={"x-request-id": "req-header-1"})
err = APIResponseValidationError(
"ChatCompletion validation failed",
response=resp,
body={"choices": "unexpected"},
)
text = str(err)
assert "APIResponseValidationError" in text
assert "req-header-1" in text
assert "unexpected" in text


def test_response_validation_error_str_truncates_large_bodies():
err = APIResponseValidationError(
"boom",
body={"payload": "x" * 5000},
)
assert len(str(err)) < 400
assert str(err).endswith("...")


def test_response_validation_error_without_response():
# Streaming parses may fail after the response is consumed; the
# class must stay constructible with SDK-side knowledge only.
err = APIResponseValidationError(
"chunk validation failed",
body={"frame": "not an envelope"},
)
assert err.response is None
assert err.status_code is None
assert err.request_id is None
assert isinstance(err, DashScopeError)
assert "not an envelope" in str(err)


def test_response_validation_error_chains_the_cause():
resp = _resp(200, {"output": {}})
try:
try:
raise ValueError("inner parse failure")
except ValueError as exc:
raise APIResponseValidationError(
"TingWuResult validation failed",
response=resp,
body={"output": {}},
cause=exc,
) from exc
except APIResponseValidationError as err:
assert isinstance(err.__cause__, ValueError)
assert err.cause is err.__cause__


def test_response_validation_error_is_not_in_the_catalog():
# The class is hand-written on purpose: _error_registry.py is
# generated (DO NOT EDIT), and the SdkError contract ("no HTTP
# response behind it") does not fit a failure that has one. If the
# catalog ever grows a matching entry, this pin forces the
# registration decision to be made explicitly.
assert not any(
"response_validation" in entry.name
for entry in registry.SDK_ERRORS
)
assert not any(
entry.error_code == "APIResponseValidationError"
for entry in registry.PUBLIC_ERRORS
)
Loading