Files
patbond-ai/app/providers/errors.py
T
lixi c6e8dddc8d
CI / ai-gates (push) Successful in 31s
feat(seedance): 火山方舟视频生成客户端与错误分类(58 测试,全离线)
按用户提供的三份官方文档适配 Seedance 视频生成:提交
(POST /contents/generations/tasks)与查询(GET .../tasks/{id})。
取证记录见 patbond-doc docs/development/providers/volcengine-seedance.md。

**开工前问的五项,两项与原假设不同:**

1. **不支持幂等键**。请求参数全集里没有任何幂等字段。故 ADR-023 §4 设想的
   「把我方 idempotency_key 透传为厂商幂等头」对本供应商不成立,「提交超时后
   不知道有没有扣费」只能由 max_attempts 兜住——如实登记,不假装解决。
   两点减轻:提交是异步的(只入队即返回,超时概率低);且厂商支持 callback_url,
   孤儿任务会通过回调把 ID 送回来,故回调在此供应商上还是**孤儿回收通道**,
   不只是延迟优化——这是 ADR-023 §4「回调仅预留」之外的新论据,值得复评。
2. **产物是有效期 24 小时的 URL**(2.5 另有 100 次下载上限)。因此厂商 URL
   **不能当存储用**:存进 media.assets.external_url 会在 24h 后变死链。
   **这对挂起中的存储决策是硬约束**——无论选本地还是云,都必须在 24 小时内
   把文件复制到我们自己的存储。

其余三项:查询接口只保留 7 天记录;无明确频率数值,但官方 SDK 示例用 10s 轮询
(我们的 3s/10s/30s 阶梯与之兼容)、客户端截止 30 分钟(建议据此把 ADR-023 §10
的视频客户端超时从 15~20 分钟对齐到 30 分钟);execution_expires_after 范围
[3600, 259200],实现取下限 1 小时(厂商默认 48h 对交互式应用过长,且无法更短)。

**两个文档自身的问题,实现按宽的那份:**

- **状态枚举不一致**:查询文档列 5 个(queued/running/cancelled/succeeded/
  failed),创建文档的回调一节额外列 **expired**。只按查询文档实现会让超时
  任务掉进未知分支。按 6 个实现,expired 视为**可重试**失败(是超时不是内容
  问题),且厂商 expired 时不一定给 error.code 故单独分类。
- **QuotaExceeded 一码两义**:同一 code 既表示「排队任务数超限」(等几秒)
  又表示「额度耗尽」(等到明天或充值)。按 code 一刀切必然有一半场景的文案
  和出口是错的;只能按 message 判别,两条用例都在测试里钉住。

**顺带拿到两个免费收益**:last_frame_url 可直接作视频封面(iteration-4/03 R13
把缩略图列为视频成本项,实际免费);查询返回 ratio/resolution/framespersecond/
duration,**Worker 写产物时维度与时长都在手里、无需解码**——widthPx/heightPx
债在 AI 产物上可零成本修掉。

一处精度陷阱已处理:返回的 duration 是「帧数/24 向下取整」的约数(133 帧实际
5.54s 返回 5),且 duration 与 frames 只返回其一。故优先按 frames/fps 精确换算,
只有 duration 时退回整数秒并标注为约数,缺失时返回 None 而非编 0。

取消语义已查明:厂商**只支持取消 queued 状态**的任务,故我方取消已 running
的任务无法向厂商传导,**仍会计费**。ADR-023 §6 关于取消的说明据此可定稿。

测试 58 项(错误分类 32 + 客户端 26),全部离线用 httpx MockTransport,
夹具响应体逐字取自官方文档示例以免字段名对不上。

过程中防泄漏钩子拦下了测试夹具里的 `api_key="test-key"`——规则是对的(这正是
真实密钥最常见的泄漏形态),故改用 ALLOW 清单认可的占位词,未放宽规则。

未实现:产物转存(阻塞于存储决策)、HTTP 服务端点与 openapi-internal.yaml。

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

199 lines
8.1 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.
"""火山方舟(Volcengine Ark)错误分类。
把厂商的错误码翻译成三个对 Worker 有意义的维度:**是否可重试**、**等多久**、
**给用户看什么**。分类依据是官方错误码表(见 patbond-doc
`docs/development/providers/volcengine-seedance.md` 的取证记录)。
为什么值得单独成一个模块而不是散在客户端里:
1. **`QuotaExceeded` 一码两义。** 同一个 code 既表示「免费额度/周期限额耗尽」
(要等到明天或去充值),也表示「当前账号排队中任务数超限」(等几秒就好)。
官方文档里这两条是相邻的两行、code 完全相同,只有 message 不同。若按 code
一刀切,必然有一半场景的文案和出口是错的。
2. **`expired` 状态没写在查询接口的状态枚举里。** 查询文档列的是 queued /
running / cancelled / succeeded / failed 五个,但回调文档额外列了 `expired`
(任务超过 `execution_expires_after` 被自动终止)。只按查询文档实现,线上
遇到 expired 会掉进未知分支。
3. 敏感内容类错误**重试毫无意义**(同样的输入必然同样被拒),必须与限流类
区分开,否则会白烧三次重试额度再失败。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
class Retry(Enum):
"""重试策略。"""
#: 不可重试:同样的输入必然得到同样的结果(参数错、内容被拒、鉴权失败)。
NEVER = "never"
#: 可重试且应尽快:限流、突发流量保护、服务过载。退避几秒到几十秒。
BACKOFF = "backoff"
#: 可重试但要等很久:额度/配额耗尽,通常要等到下一个计费周期或充值之后。
#: Worker 不应把重试额度浪费在这上面,应直接终态失败并让用户看到出口。
QUOTA = "quota"
class UserFacing(Enum):
"""给终端用户的呈现类别,对应客户端的两个 429 分支(ADR-023 §9)。"""
#: 配额耗尽:等的是「明天」或「充值」。客户端弹 sheet 并给出出口。
QUOTA_EXHAUSTED = "quota_exhausted"
#: 频率过快:等的是「几秒」。客户端弹 SnackBar,可自动重试。
RATE_LIMITED = "rate_limited"
#: 内容被拒:换提示词或换图。不可重试。
CONTENT_REJECTED = "content_rejected"
#: 服务端问题(我们的 bug 或配置错误)。用户无从处置,只报通用失败。
SERVER_ERROR = "server_error"
#: 上游暂时不可用。可重试。
UPSTREAM_UNAVAILABLE = "upstream_unavailable"
@dataclass(frozen=True)
class Classification:
retry: Retry
user_facing: UserFacing
#: 原始厂商错误码,原样透传便于排查——绝不丢弃。
provider_code: str
#: 内容安全类:**重试无意义**。覆盖输入/输出的文本、图片、视频、音频,以及
#: 版权、真人、伪造证件等子类,另含风险识别产品(*RiskDetection)的四个码。
#: 用前缀匹配而非穷举:官方这一族的码在持续增加(Input/Output × 媒体类型 ×
#: 子原因),穷举必然漏,而这一族的处置方式完全一致。
_CONTENT_PREFIXES = (
"SensitiveContentDetected",
"InputTextSensitiveContentDetected",
"InputImageSensitiveContentDetected",
"InputVideoSensitiveContentDetected",
"InputAudioSensitiveContentDetected",
"OutputTextSensitiveContentDetected",
"OutputImageSensitiveContentDetected",
"OutputVideoSensitiveContentDetected",
"OutputAudioSensitiveContentDetected",
"InputTextRiskDetection",
"InputImageRiskDetection",
"OutputTextRiskDetection",
"OutputImageRiskDetection",
)
#: 限流类:退避后重试。等的是秒级。
_RATE_LIMIT_CODES = frozenset(
{
"RateLimitExceeded.EndpointRPMExceeded",
"RateLimitExceeded.EndpointTPMExceeded",
"RateLimitExceeded.EndpointFlexTPMExceeded",
"ModelAccountRpmRateLimitExceeded",
"ModelAccountTpmRateLimitExceeded",
"ModelAccountFlexTpmRateLimitExceeded",
"ModelAccountIpmRateLimitExceeded",
"APIAccountRpmRateLimitExceeded",
"AccountRateLimitExceeded",
"ServerOverloaded",
"RequestBurstTooFast",
}
)
#: 配额/计费类:等的是「明天」或「充值」。重试解决不了。
#: 注意 403 的欠费与未开通也归此类——HTTP 状态码不同,但用户要做的事一样。
_QUOTA_CODES = frozenset(
{
"SetLimitExceeded",
"InflightBatchsizeExceeded",
"QuotaExceeded.AgentPlanQuotaExceeded",
"QuotaExceeded.DoubaoSearchFreeQuotaExceeded",
"AccountOverdueError",
"OperationDenied.ServiceOverdue",
"OperationDenied.FileQuotaExceeded",
"ModelNotOpen",
"OperationDenied.ServiceNotOpen",
}
)
#: 我方配置或代码错误:重试无意义,需要人来改。
_SERVER_FAULT_CODES = frozenset(
{
"AuthenticationError",
"InvalidAccountStatus",
"AccessDenied",
"InvalidEndpointOrModel.NotFound",
"InvalidEndpointOrModel.ModelIDAccessDisabled",
"InvalidEndpoint.ClosedEndpoint",
"OperationDenied.PermissionDenied",
"OperationDenied.ArkAccessRoleNotFound",
"OperationDenied.TosAccessDenied",
"PathNotFound",
}
)
#: 上游内部错误:可重试。
_UPSTREAM_CODES = frozenset({"InternalServiceError", "UpstreamUnavailable"})
#: `QuotaExceeded` 一码两义的判别式。命中即为「排队任务数超限」,属秒级等待;
#: 否则按额度耗尽处理。取自官方两行 message 的区别性措辞。
#: 判别放在 message 上是不得已——code 相同,没有别的字段可依据。
_QUEUE_BACKLOG_MESSAGE = re.compile(
r"exceeded the quota|排队中状态的任务数", re.IGNORECASE
)
def classify(provider_code: str | None, message: str | None = None) -> Classification:
"""把厂商错误码分类。
未知码一律按**可退避重试的上游问题**处理而不是直接判死:厂商在持续新增错误
码,把未知码当终态失败会让一次可恢复的抖动变成用户看到的失败;而重试预算
本身有上限(`max_attempts`),最坏情况只是多试两次。
"""
code = (provider_code or "").strip()
text = message or ""
if not code:
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, "")
if code.startswith(_CONTENT_PREFIXES):
return Classification(Retry.NEVER, UserFacing.CONTENT_REJECTED, code)
if code == "ContentSecurityDetectionError":
# 风险识别产品自身请求失败,不是内容真的违规 —— 可重试。
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code)
if code == "QuotaExceeded":
# 一码两义,见模块 docstring。
if _QUEUE_BACKLOG_MESSAGE.search(text):
return Classification(Retry.BACKOFF, UserFacing.RATE_LIMITED, code)
return Classification(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code)
if code in _RATE_LIMIT_CODES:
return Classification(Retry.BACKOFF, UserFacing.RATE_LIMITED, code)
if code in _QUOTA_CODES:
return Classification(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code)
if code in _SERVER_FAULT_CODES:
return Classification(Retry.NEVER, UserFacing.SERVER_ERROR, code)
if code in _UPSTREAM_CODES:
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code)
# 参数类:我方拼错了请求体,重试同样的请求体不会变好。
# 含 InvalidParameter.TaskTypeConstraint / TaskTypeMismatch —— 那两个是
# Seedance 2.5 全模态任务的参数与模型判定的任务类型不兼容,属我方参数问题。
if code.startswith(
(
"InvalidParameter",
"MissingParameter",
"InvalidArgumentError",
"InvalidImageURL",
"InvalidPayload",
"RequestTooLarge",
"RequestBodyTooLarge",
"OutofContextError",
)
):
return Classification(Retry.NEVER, UserFacing.SERVER_ERROR, code)
return Classification(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code)