diff --git a/dashscope/v2/__init__.py b/dashscope/v2/__init__.py index 7217e39b..4e794987 100644 --- a/dashscope/v2/__init__.py +++ b/dashscope/v2/__init__.py @@ -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 @@ -23,6 +27,7 @@ ) __all__ = [ + "APIResponseValidationError", "AsyncClient", "Client", "Config", diff --git a/dashscope/v2/errors.py b/dashscope/v2/errors.py index c2435535..b5700465 100644 --- a/dashscope/v2/errors.py +++ b/dashscope/v2/errors.py @@ -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 @@ -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 ( diff --git a/tests/v2/test_errors.py b/tests/v2/test_errors.py index f22b3ca5..6fdb462c 100644 --- a/tests/v2/test_errors.py +++ b/tests/v2/test_errors.py @@ -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, @@ -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 + )