db87d66403
CI / ai-gates (push) Successful in 31s
`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>
143 lines
5.3 KiB
Python
143 lines
5.3 KiB
Python
"""火山方舟(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()
|