Skip to content
Merged
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
30 changes: 26 additions & 4 deletions .github/workflows/unit_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,19 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-latest ]
python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ]
# ubuntu-latest (26.04) ships no CPython 3.9 build, so pin the
# still-supported 3.9 leg to 24.04 and keep the rest on latest.
include:
- os: ubuntu-24.04
python-version: '3.9'
- os: ubuntu-latest
python-version: '3.10'
- os: ubuntu-latest
python-version: '3.11'
- os: ubuntu-latest
python-version: '3.12'
- os: ubuntu-latest
python-version: '3.13'

steps:
- name: Checkout repository
Expand Down Expand Up @@ -68,8 +79,19 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-latest ]
python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ]
# ubuntu-latest (26.04) ships no CPython 3.9 build, so pin the
# still-supported 3.9 leg to 24.04 and keep the rest on latest.
include:
- os: ubuntu-24.04
python-version: '3.9'
- os: ubuntu-latest
python-version: '3.10'
- os: ubuntu-latest
python-version: '3.11'
- os: ubuntu-latest
python-version: '3.12'
- os: ubuntu-latest
python-version: '3.13'

steps:
- name: Checkout repository
Expand Down
23 changes: 23 additions & 0 deletions dashscope/_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@
REDACTED = "[REDACTED]"


def request_id_from_headers(
headers: Optional[Mapping[str, Any]],
) -> Optional[str]:
"""Find a gateway request ID without assuming header casing."""
names = {"x-request-id", "x-dashscope-requestid", "x-dashscope-request-id"}
for name, value in (headers or {}).items():
if not isinstance(name, str) or name.lower() not in names:
continue
values = value if isinstance(value, (list, tuple)) else (value,)
for item in values:
if isinstance(item, str) and item.strip():
return item
return None


def request_id_for_log(*request_ids: Any) -> str:
"""Use real ID strings only; never stringify arbitrary response objects."""
for request_id in request_ids:
if isinstance(request_id, str) and request_id.strip():
return request_id
return "unknown"


def redact_headers(
headers: Optional[Mapping[str, Any]],
) -> Optional[dict]:
Expand Down
36 changes: 30 additions & 6 deletions dashscope/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,15 @@

from dashscope import _error_registry as _registry
from dashscope._error_registry import PublicError, SdkErrorDef
from dashscope._logging import (
get_logger,
request_id_for_log,
request_id_from_headers,
)
from dashscope._shared._errors import DashScopeException

logger = get_logger("response")


class ErrorCode(str, Enum):
"""Server-side error codes returned in ``response.code``."""
Expand Down Expand Up @@ -369,7 +376,7 @@ def _request_id(
body: Dict[str, Any],
headers: Mapping[str, str],
) -> Optional[str]:
return body.get("request_id") or headers.get("x-request-id")
return body.get("request_id") or request_id_from_headers(headers)


def _catalog_message(
Expand Down Expand Up @@ -540,13 +547,20 @@ def internal_response_error(
"""Build the catalog ``internal_error`` for an unusable response body."""
entry = _entry("internal_error")
data = body if isinstance(body, dict) else {}
request_id = _request_id(data, response.headers)
logger.error(
"Failed to parse HTTP response, status=%s, request_id=%s",
response.status_code,
request_id_for_log(
request_id,
request_id_from_headers(response.headers),
),
)
return InternalServerError(
entry.error_msg,
code=entry.error_code,
status_code=entry.status_code,
request_id=(
data.get("request_id") or response.headers.get("x-request-id")
),
request_id=request_id,
body=body,
headers=response.headers,
)
Expand Down Expand Up @@ -609,5 +623,15 @@ def check_response(
raise_on_error: bool = True,
) -> None:
error = response_error(response)
if error is not None and raise_on_error:
raise error
if error is not None:
logger.error(
"HTTP service error: status=%s, code=%s, request_id=%s",
response.status_code,
error.code,
request_id_for_log(
error.request_id,
request_id_from_headers(response.headers),
),
)
if raise_on_error:
raise error
7 changes: 5 additions & 2 deletions dashscope/resources/_oss_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

import httpx

from dashscope._logging import get_logger
from dashscope._logging import get_logger, request_id_from_headers
from dashscope.errors import (
check_response,
error_from_status,
Expand Down Expand Up @@ -182,9 +182,12 @@ def upload_local_file(
)
if not response.is_success:
logger.error(
"uploading file %s to oss failed, status %s",
"uploading file %s to oss failed, status %s, request_id=%s",
file_path,
response.status_code,
request_id_from_headers(response.headers)
or response.headers.get("x-oss-request-id")
or "unknown",
)
# OSS answers with XML, so there is no business code to read and the
# error is mapped from the status code alone.
Expand Down
98 changes: 72 additions & 26 deletions dashscope/resources/_sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@
from __future__ import annotations

import json
from typing import Any, AsyncIterator, Iterator, Optional
from typing import Any, AsyncIterator, Iterator, Mapping, Optional

from dashscope._logging import (
get_logger,
request_id_for_log,
request_id_from_headers,
)
from dashscope.errors import (
DashScopeError,
error_from_business_code,
Expand All @@ -29,7 +34,30 @@ def _try_json(data: str) -> Any:
return data


def _stream_error(status_code: Optional[int], data: str) -> DashScopeError:
def _log_stream_error(
data: str,
headers: Optional[Mapping[str, str]],
) -> None:
if headers is None:
return
body = _try_json(data)
body = body if isinstance(body, dict) else {}
code = body.get("code")
get_logger("transport.sse").error(
"SSE business error: code=%s, request_id=%s",
code if isinstance(code, str) else "unknown",
request_id_for_log(
body.get("request_id"),
request_id_from_headers(headers),
),
)


def _stream_error(
status_code: Optional[int],
data: str,
headers: Optional[Mapping[str, str]] = None,
) -> DashScopeError:
"""Build the error for one ``event:error`` frame.

``status:`` is optional on the wire. Absent, the frame is a business
Expand All @@ -41,38 +69,56 @@ def _stream_error(status_code: Optional[int], data: str) -> DashScopeError:
if isinstance(body, dict):
message = body.get("message") or data
if status_code is None:
return error_from_business_code(message=message, body=body)
return error_from_status(status_code, message=message, body=body)
error = error_from_business_code(message=message, body=body)
else:
error = error_from_status(status_code, message=message, body=body)
# Preserve the historical body-only ID contract on job-event exceptions.
_log_stream_error(data, headers)
return error


def _iter_sse(response) -> Iterator[Any]:
is_error = False
status_code: Optional[int] = None
for line in response.iter_lines():
if not line:
continue
if line.startswith("event:error"):
is_error = True
elif line.startswith("status:"):
status_code = int(line[len("status:") :].strip())
elif line.startswith("data:"):
yield is_error, status_code, line[len("data:") :].strip()
if is_error:
break
try:
for line in response.iter_lines():
if not line:
continue
if line.startswith("event:error"):
is_error = True
elif line.startswith("status:"):
status_code = int(line[len("status:") :].strip())
elif line.startswith("data:"):
yield is_error, status_code, line[len("data:") :].strip()
if is_error:
break
except Exception:
get_logger("transport.sse").error(
"Failed to read or parse job SSE stream, request_id=%s",
request_id_for_log(request_id_from_headers(response.headers)),
)
raise


async def _aiter_sse(response) -> AsyncIterator[Any]:
# Async twin of ``_iter_sse``; the framing rules are identical.
is_error = False
status_code: Optional[int] = None
async for line in response.aiter_lines():
if not line:
continue
if line.startswith("event:error"):
is_error = True
elif line.startswith("status:"):
status_code = int(line[len("status:") :].strip())
elif line.startswith("data:"):
yield is_error, status_code, line[len("data:") :].strip()
if is_error:
break
try:
async for line in response.aiter_lines():
if not line:
continue
if line.startswith("event:error"):
is_error = True
elif line.startswith("status:"):
status_code = int(line[len("status:") :].strip())
elif line.startswith("data:"):
yield is_error, status_code, line[len("data:") :].strip()
if is_error:
break
except Exception:
get_logger("transport.sse").error(
"Failed to read or parse job SSE stream, request_id=%s",
request_id_for_log(request_id_from_headers(response.headers)),
)
raise
Loading
Loading