c6e8dddc8d
CI / ai-gates (push) Successful in 31s
按用户提供的三份官方文档适配 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>
199 lines
8.1 KiB
Python
199 lines
8.1 KiB
Python
"""火山方舟(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)
|