"""火山方舟(Ark)的 HTTP 传输层。 **本模块不认识「视频」这个概念。** 它只管鉴权、超时、请求发送、以及把厂商的错误 体解包成带分类的异常。以后接火山的图片生成、语音或对话接口时,那些模块复用本 client,而不是各自再抄一遍鉴权与错误解包——这是把它单独拆出来的全部理由。 判断代码该进这里还是进 `video.py`:问「换成火山的另一个接口,这段还一样吗」。 鉴权头、错误体形状、超时语义都一样,故在此;`content` 数组怎么拼、`ratio` 怎么 取值是视频接口特有的,故不在此。 """ from __future__ import annotations import logging from typing import Any 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) -> ArkClient: return self def __exit__(self, *exc_info: object) -> None: self.close()