Skip to content

Commit ddc70d9

Browse files
author
zhansheng.lzs
committed
refactor(errors): unify SDK error reporting on a central registry
Every failure path used to surface `code="Unknown"` with `status_code=-1`, leaving callers unable to tell a bad request from a server fault. This routes all of them through a single registry that separates the public code the caller sees (`BadRequestError`, `RateLimitError`, ...) from the internal one used for diagnosis (`sdk.agentic_rl.FunctionTestTimeout`, ...). Public/internal split: - Add `dashscope/common/error_registry.py` holding the public errors and the internal `sdk.*` definitions, each with a message, a solution and its substitution variables. The file is generator output; all fixes here land on the consumer side. - AgentStudio and AgenticRL classify by server code only, so a status can no longer be contradicted by a message substring. Classification fixes: - `verify_function` no longer flattens `AgenticRLError` subclasses and timeouts into `ValidationError`, which made a failed instance query or a request timeout look like a 400. It also stopped discarding the reason the deployed function itself reported. - Add `TimeoutErrorWithCode` deriving from the builtin `TimeoutError`, so a wrapped `asyncio.TimeoutError` still reaches the 504 branch on every supported Python version. Previously it became a 500 and the `sdk.agentic_rl.FunctionTestTimeout` code was unreachable. - `AgenticRL.init()` wrapped nothing, so a bad `config_path` leaked a raw `IOErrorWithCode` with no `status_code` at all. A missing or malformed config is the caller's to fix and now reports 400. - A WebSocket handshake 403 was reported as 401, sending callers to rotate an API key that was in fact valid. - `AgenticRL.run()` logged only after passing `DashScopeException` straight through, which left most workflow failures unrecorded. - The service-unavailable probe matched the bare substring "503", so an address such as `10.50.30.1:5030` was misread as an outage. Detail preservation: - Transport, WebSocket and `iter_over_async` failures keep the originating exception instead of replacing it with a generic 500 message. `httpx` timeouts usually carry no message at all, so those fall back to the type name. - Unparsable SSE bodies report 500 and retain the payload. - Passing a `requests.Session` to an async call silently ignored it; it now warns, since only an `aiohttp.ClientSession` can be reused there. The log-and-classify sequence duplicated across five `AgenticRL` methods is extracted into `_log_internal_error` / `_public_exception`, and the resulting record is attributed to the calling handler rather than to the helper. Tests: 1132 passed, 6 skipped.
1 parent 9b2fb54 commit ddc70d9

41 files changed

Lines changed: 5127 additions & 655 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,21 @@ with client.sessions.events.stream(session.id) as stream:
6767
| Error code/exception | HTTP status | Meaning | Handling |
6868
| --- | --- | --- | --- |
6969
| `InputRequired` / `ModelRequired` / `InvalidInput` | raised locally | missing required params such as app_id/prompt/model | add params per the exception message; Application failures do not raise — check status_code |
70-
| `invalid_request_error` (InvalidRequestError) | 400 | invalid agentstudio request parameters | check parameter names/types, e.g. empty events raises a local ValueError |
70+
| `invalid_request_error` / `request_too_large` (InvalidRequestError) | 400/413 | invalid agentstudio request parameters, or payload too large | check parameter names/types, e.g. empty events raises a local ValueError |
7171
| `authentication_error` (AuthenticationError) | 401 | api_key missing or invalid | check api_key or DASHSCOPE_API_KEY |
72-
| `permission_denied_error` (PermissionDeniedError) | 403 | no access to the resource | check workspace/uid and resource ownership |
72+
| `permission_denied_error` / `permission_error` (PermissionDeniedError) | 403 | no access to the resource | check workspace/uid and resource ownership |
7373
| `not_found_error` (NotFoundError) | 404 | agent/session and other resources do not exist | verify the id and region/workspace are correct |
7474
| `conflict_error` (ConflictError) | 409 | version in agents.update does not match the server | retrieve the latest version first, then update |
75-
| `rate_limit_error` (RateLimitError) | 429 | rate limited | retry with backoff (client default max_retries=2) |
76-
| `api_error` (InternalServerError) / `overloaded_error` (OverloadedError) | 500/502/504 / 503 | server error/overloaded | troubleshoot with `request_id` or retry |
75+
| `rate_limit_error` / `billing_error` (RateLimitError) | 429 | rate limited or out of quota | retry with backoff (client default max_retries=2) |
76+
| `api_error` / `timeout_error` (InternalServerError), `overloaded_error` (OverloadedError) | 500/502/504 / 503 | server error/overloaded | troubleshoot with `request_id` or retry |
7777
| `APITimeoutError` / `APIConnectionError` | no response | connection/read/write timeout | increase timeout, check network, then retry |
7878
| `StreamError` / `StreamClosedError` | mid-stream | SSE protocol error/stream closed | call `events.stream` again to reopen the stream |
7979

80+
Match on the exception class, not on `.code`: `.code` echoes the server's raw
81+
string, so a 403 may arrive as either `permission_denied_error` or
82+
`permission_error`, and it is normalised to `api_error` when the server omits
83+
it. The class is always the one listed above.
84+
8085
## Java SDK
8186

8287
Java SDK v2.22.23 entry points:

‎dashscope/agentstudio/exceptions.py‎

Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,34 @@
1010
"request_id": "req_..."
1111
}
1212
13-
The pre-release backend currently emits ``error_code``/``error_message``
14-
instead of nested ``error.{code,message}``. We accept both shapes and
15-
normalize to the documented form. The compatibility branch is marked
16-
with ``# TODO(bma-fix)`` so we can remove it once the backend aligns.
13+
Codes come from the server response and are preserved as-is on ``.code``.
14+
When no code is present, :func:`from_response` reports the generic
15+
``api_error`` rather than inventing a public code from the status number.
16+
The raw payload stays on ``.raw``.
17+
18+
The exception *type* is resolved separately: a recognized server code wins,
19+
otherwise the HTTP status picks the class. Callers can therefore keep using
20+
``except NotFoundError`` while ``.code`` still carries whatever the server
21+
actually said (including service-specific codes such as
22+
``bma_invalid_event``).
23+
24+
The pre-release backend emits ``error_code``/``error_message`` instead of
25+
nested ``error.{code,message}``. Both shapes are accepted; the compatibility
26+
branches are marked ``# TODO(bma-fix)`` for removal once the backend aligns.
1727
"""
1828

1929
from __future__ import annotations
2030

2131
from typing import Any, Dict, Mapping, Optional
2232

2333
from dashscope.common.error import DashScopeException
34+
from dashscope.common.error_registry import (
35+
SDK_AGENTSTUDIO_API_CONNECTION_ERROR,
36+
SDK_AGENTSTUDIO_API_TIMEOUT_ERROR,
37+
SDK_AGENTSTUDIO_STREAM_CLOSED_ERROR,
38+
SDK_AGENTSTUDIO_STREAM_ERROR,
39+
INTERNAL_ERROR,
40+
)
2441

2542

2643
class AgentStudioError(DashScopeException):
@@ -75,13 +92,13 @@ def __repr__(self) -> str: # pragma: no cover - debug helper
7592
class APIConnectionError(AgentStudioError):
7693
"""Raised when the HTTP request fails before a response is read."""
7794

78-
code = "api_connection_error"
95+
code = SDK_AGENTSTUDIO_API_CONNECTION_ERROR.name
7996

8097

8198
class APITimeoutError(APIConnectionError):
8299
"""Raised on connect / read timeouts."""
83100

84-
code = "api_timeout_error"
101+
code = SDK_AGENTSTUDIO_API_TIMEOUT_ERROR.name
85102

86103

87104
# ---------------------------------------------------------------------------
@@ -90,7 +107,12 @@ class APITimeoutError(APIConnectionError):
90107

91108

92109
class APIStatusError(AgentStudioError):
93-
"""Raised when the server returns a non-2xx status."""
110+
"""Raised when the server returns a non-2xx status.
111+
112+
Subclasses below give callers a stable type to catch. ``.code`` is always
113+
the server's own value, so the class carries no information that the code
114+
attribute does not -- it exists for ``except`` clauses.
115+
"""
94116

95117
code = "api_status_error"
96118

@@ -124,7 +146,7 @@ class OverloadedError(APIStatusError):
124146

125147

126148
class InternalServerError(APIStatusError):
127-
code = "api_error"
149+
code = INTERNAL_ERROR.anthropic_error_code
128150

129151

130152
# ---------------------------------------------------------------------------
@@ -135,42 +157,49 @@ class InternalServerError(APIStatusError):
135157
class StreamError(AgentStudioError):
136158
"""Raised when an SSE stream encounters a fatal protocol error."""
137159

138-
code = "stream_error"
160+
code = SDK_AGENTSTUDIO_STREAM_ERROR.name
139161

140162

141163
class StreamClosedError(StreamError):
142164
"""Raised when consumers attempt I/O on an already-closed stream."""
143165

144-
code = "stream_closed"
166+
code = SDK_AGENTSTUDIO_STREAM_CLOSED_ERROR.name
145167

146168

147169
# ---------------------------------------------------------------------------
148170
# Dispatcher
149171
# ---------------------------------------------------------------------------
150172

151-
152173
_STATUS_TO_DEFAULT: Dict[int, type] = {
153174
400: InvalidRequestError,
154175
401: AuthenticationError,
155176
403: PermissionDeniedError,
156177
404: NotFoundError,
157178
409: ConflictError,
179+
413: InvalidRequestError,
158180
429: RateLimitError,
159181
500: InternalServerError,
160182
502: InternalServerError,
161183
503: OverloadedError,
162184
504: InternalServerError,
163185
}
164186

187+
# Covers every anthropic_error_code the error registry can emit, plus the
188+
# legacy permission_denied_error / conflict_error spellings. Each entry agrees
189+
# with _STATUS_TO_DEFAULT for the status the registry pairs it with.
165190
_CODE_TO_CLASS: Dict[str, type] = {
166191
"invalid_request_error": InvalidRequestError,
167192
"authentication_error": AuthenticationError,
193+
"permission_error": PermissionDeniedError,
168194
"permission_denied_error": PermissionDeniedError,
169195
"not_found_error": NotFoundError,
170196
"conflict_error": ConflictError,
197+
"request_too_large": InvalidRequestError,
171198
"rate_limit_error": RateLimitError,
199+
"billing_error": RateLimitError,
172200
"overloaded_error": OverloadedError,
173201
"api_error": InternalServerError,
202+
"timeout_error": InternalServerError,
174203
}
175204

176205

@@ -179,16 +208,21 @@ def from_response(
179208
status_code: int,
180209
body: Any,
181210
headers: Optional[Mapping[str, str]] = None,
182-
) -> AgentStudioError:
183-
"""Build an :class:`AgentStudioError` instance from a HTTP response.
211+
) -> APIStatusError:
212+
"""Build an :class:`APIStatusError` instance from a HTTP response.
184213
185-
Accepts both the documented ``{type, error:{code,message}, request_id}``
186-
shape and the pre-release ``{type, error:{error_code, error_message}}``
187-
shape. Falls back to a Spring default ``{timestamp,status,error,path}``
188-
when the body is not JSON-serializable.
214+
Accepts the documented ``{type, error:{code,message}, request_id}`` shape,
215+
the pre-release ``error:{error_code,error_message}`` shape, the classic
216+
flat DashScope ``{code, message, request_id}`` envelope, and falls back to
217+
a Spring default ``{timestamp,status,error,path}`` page.
189218
190219
The ``x-request-id`` response header is preferred over the body
191220
``request_id`` field (server-generated IDs are more reliable for tracing).
221+
222+
The server's code is preserved as-is on ``.code``; only when no code is
223+
present does it fall back to the generic ``api_error``. The exception
224+
class is resolved separately -- a recognized code wins, otherwise the
225+
HTTP status decides.
192226
"""
193227

194228
code: Optional[str] = None
@@ -211,30 +245,28 @@ def from_response(
211245
message = err.get("message") or err.get(
212246
"error_message",
213247
) # TODO(bma-fix)
214-
# Spring default fallback.
215-
if (
216-
message is None
217-
and "error" in body
218-
and isinstance(body["error"], str)
219-
):
248+
# Spring default fallback. Its ``error`` field is a human phrase
249+
# ("Not Found"), so it informs the message but never the code.
250+
if message is None and isinstance(body.get("error"), str):
220251
message = body["error"]
221-
code = body.get("error") or "api_error"
252+
# Flat DashScope envelope: code/message at the top level.
253+
if code is None:
254+
code = body.get("code")
222255
if message is None:
223256
message = body.get("message")
224257

258+
# Classify before normalizing: the synthesized generic code below must not
259+
# drive classification, or a bodiless 404 would report InternalServerError.
260+
cls = _CODE_TO_CLASS.get(code) if code else None
261+
if cls is None:
262+
cls = _STATUS_TO_DEFAULT.get(status_code, APIStatusError)
263+
264+
if not code:
265+
code = INTERNAL_ERROR.anthropic_error_code
266+
225267
if message is None:
226268
message = f"HTTP {status_code}"
227-
if code is None:
228-
code = _STATUS_TO_DEFAULT.get( # type: ignore[attr-defined]
229-
status_code,
230-
APIStatusError,
231-
).code
232-
233-
cls = (
234-
_CODE_TO_CLASS.get(code)
235-
or _STATUS_TO_DEFAULT.get(status_code)
236-
or APIStatusError
237-
)
269+
238270
return cls(
239271
message,
240272
code=code,

‎dashscope/agentstudio/transport.py‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,18 @@ def _should_retry_exception(err: BaseException) -> Tuple[bool, Optional[Any]]:
204204
return False, None
205205

206206

207+
def _describe_exception(err: BaseException) -> str:
208+
"""Render an exception into a message worth showing the caller.
209+
210+
``httpx`` timeout exceptions usually carry no message at all, so a bare
211+
``str(err)`` would surface an empty string. Fall back to the type name.
212+
"""
213+
detail = str(err).strip()
214+
if detail:
215+
return f"{type(err).__name__}: {detail}"
216+
return type(err).__name__
217+
218+
207219
# ---------------------------------------------------------------------------
208220
# Header builder (shared by sync + async transports)
209221
# ---------------------------------------------------------------------------
@@ -220,7 +232,7 @@ def build_headers(
220232
"""Compose the canonical AgentStudio request headers."""
221233

222234
if not api_key:
223-
raise exceptions.AuthenticationError(
235+
raise exceptions.APIStatusError(
224236
"api_key is required. Pass it via Client(api_key=...) or "
225237
"the DASHSCOPE_API_KEY environment variable.",
226238
code="authentication_error",
@@ -363,14 +375,18 @@ def request( # pylint: disable=too-many-branches
363375
except httpx.TimeoutException as exc:
364376
last_exc = exc
365377
if attempt >= self.max_retries:
366-
raise exceptions.APITimeoutError(str(exc)) from exc
378+
raise exceptions.APITimeoutError(
379+
_describe_exception(exc),
380+
) from exc
367381
except Exception as exc:
368382
if isinstance(exc, exceptions.AgentStudioError):
369383
raise
370384
should_retry, _ = _should_retry_exception(exc)
371385
last_exc = exc
372386
if attempt >= self.max_retries or not should_retry:
373-
raise exceptions.APIConnectionError(str(exc)) from exc
387+
raise exceptions.APIConnectionError(
388+
_describe_exception(exc),
389+
) from exc
374390
else:
375391
if stream:
376392
if resp.status_code >= 400:
@@ -587,14 +603,18 @@ async def request( # pylint: disable=too-many-branches
587603
except httpx.TimeoutException as exc:
588604
last_exc = exc
589605
if attempt >= self.max_retries:
590-
raise exceptions.APITimeoutError(str(exc)) from exc
606+
raise exceptions.APITimeoutError(
607+
_describe_exception(exc),
608+
) from exc
591609
except Exception as exc:
592610
if isinstance(exc, exceptions.AgentStudioError):
593611
raise
594612
should_retry, _ = _should_retry_exception(exc)
595613
last_exc = exc
596614
if attempt >= self.max_retries or not should_retry:
597-
raise exceptions.APIConnectionError(str(exc)) from exc
615+
raise exceptions.APIConnectionError(
616+
_describe_exception(exc),
617+
) from exc
598618
else:
599619
if stream:
600620
if resp.status_code >= 400:

0 commit comments

Comments
 (0)