Files
patbond-ai/app/providers/volcengine/client.py
T
lixi db87d66403
CI / ai-gates (push) Successful in 31s
fix(ai): 修 ruff lint 三处错误——CI 由红转绿
`4762358` 与 `86d63ab` 两次 CI 均因 `ruff check` 失败而红。

**根因是我的门禁自查方法错了**:用 `ruff check . | tail -1; echo $?` 读到的是
`tail` 的退出码而不是 ruff 的,于是把一次真实的 lint 失败读成了通过——先前声称
的「四道门禁全绿」是基于这个坏掉的检查得出的。已在 ci.yml 的 Gates 步骤就地注明
此坑,并改为逐条分开跑读各自退出码。

三处都是实际问题,非误报:
- `client.py:138` PYI034:`__enter__` 的返回注解硬编码为类名,应为 `Self`
  (子类化后注解会失准)
- `test_video.py:58` C408:`dict()` 调用改字面量
- `test_layering.py:85` PIE810:两次 `endswith` 合并为一次元组调用

修复后在与 CI 同构的隔离 venv(同镜像源、同 requirements-dev.txt,实测 ruff
版本与本地一致 0.16.8)逐道复验:secret-scan / format / lint / pytest 四道
退出码均为 0,100 测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:36:17 +08:00

143 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""火山方舟(Ark)的 HTTP 传输层。
**本模块不认识「视频」这个概念。** 它只管鉴权、超时、请求发送、以及把厂商的错误
体解包成带分类的异常。以后接火山的图片生成、语音或对话接口时,那些模块复用本
client,而不是各自再抄一遍鉴权与错误解包——这是把它单独拆出来的全部理由。
判断代码该进这里还是进 `video.py`:问「换成火山的另一个接口,这段还一样吗」。
鉴权头、错误体形状、超时语义都一样,故在此;`content` 数组怎么拼、`ratio` 怎么
取值是视频接口特有的,故不在此。
"""
from __future__ import annotations
import logging
from typing import Any, Self
import httpx
from app.domain.generation import Failure
from .errors import classify
log = logging.getLogger(__name__)
class ArkError(RuntimeError):
"""火山方舟返回的错误,携带领域分类。
:param failure: 已分类的失败,调用方据此决定重试策略与用户呈现,**不必再去
看厂商错误码**——那是适配层的职责边界。
"""
def __init__(self, failure: Failure, http_status: int | None = None):
super().__init__(failure.message or failure.provider_code or "Ark 调用失败")
self.failure = failure
self.http_status = http_status
class ArkClient:
"""火山方舟 API 的同步 HTTP 客户端。
**超时语义**:`timeout_sec` 是单次 HTTP 往返的超时,**不是生成耗时**。方舟的
内容生成接口是异步的(提交只入队即返回),所以几秒足够;把它调大成「等出图」
会重新引入长连接占用与「不知道有没有扣费」的歧义(ADR-023 §4)。
"""
def __init__(
self,
api_key: str,
base_url: str,
timeout_sec: float = 10.0,
client: httpx.Client | None = None,
):
if not api_key:
# 缺密钥是服务端配置错误,不是上游故障。在构造点就说清楚,
# 免得表现为一串莫名的 401。
raise ValueError(
"ARK_API_KEY 未配置:请通过环境变量注入,切勿写入代码或样例文件"
)
self._api_key = api_key
self._base_url = base_url.rstrip("/")
self._owns_client = client is None
self._client = client or httpx.Client(timeout=timeout_sec)
def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
return self._send("POST", path, json=body)
def get(self, path: str) -> dict[str, Any]:
return self._send("GET", path)
# ---------------------------------------------------------------- 内部
def _send(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
url = f"{self._base_url}{path}"
try:
response = self._client.request(
method, url, headers=self._headers(), **kwargs
)
except httpx.HTTPError as exc:
# 网络层失败按可重试处理。注意**提交超时属于「不知道有没有扣费」的
# 窗口**:火山不支持幂等键,调用方必须依赖已落库的 provider_request_id
# 或回调来判断,不可盲目重发。
raise ArkError(classify(None, f"请求火山方舟失败:{exc}")) from exc
return self._unwrap(response)
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
@staticmethod
def _unwrap(response: httpx.Response) -> dict[str, Any]:
"""把响应转成 dict;非 2xx 转成带分类的 `ArkError`。
错误体形状取自官方错误码表:`{"error": {"code": ..., "message": ...}}`。
响应体解析失败时**不静默**——那通常意味着打到了错误的地址(例如网关返回
HTML 错误页),静默会让排查绕一大圈。
"""
if response.is_success:
try:
return response.json()
except ValueError as exc:
raise ArkError(
classify(
None,
f"响应不是合法 JSON(HTTP {response.status_code}):"
f"{response.text[:200]}",
),
response.status_code,
) from exc
code: str | None = None
message = response.text[:500]
try:
error = response.json().get("error") or {}
code = error.get("code")
message = error.get("message") or message
except ValueError:
pass
failure = classify(code, message)
log.warning(
"火山方舟返回错误 http=%s code=%s retry=%s",
response.status_code,
code,
failure.retry.value,
)
raise ArkError(failure, response.status_code)
def close(self) -> None:
# 只关自己创建的连接池:注入进来的 client 归调用方管理,
# 在这里关掉会让共享同一个 client 的其他适配器一起失效。
if self._owns_client:
self._client.close()
def __enter__(self) -> Self:
return self
def __exit__(self, *exc_info: object) -> None:
self.close()