feat(seedance): 火山方舟视频生成客户端与错误分类(58 测试,全离线)
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>
This commit is contained in:
2026-09-20 11:51:37 +08:00
parent 6990709124
commit c6e8dddc8d
9 changed files with 1091 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
"""火山方舟错误分类的测试。
每个用例对应官方错误码表里的一行或一类。重点在三处**如果搞错只会在生产暴露**
的地方:`QuotaExceeded` 的一码两义、内容安全类必须不可重试、未知码必须偏向
可重试而不是直接判死。
"""
from app.providers.errors import Retry, UserFacing, classify
class TestQuotaExceededIsTwoDifferentThings:
"""同一个 code,两种完全不同的处置。只看 code 必然错一半。"""
def test_queue_backlog_is_a_seconds_long_wait(self):
# 官方原文:「当前账号处于排队中状态的任务数已超过限制,请稍后重试。」
result = classify(
"QuotaExceeded", "The request has exceeded the quota. Request ID: x."
)
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.RATE_LIMITED
def test_queue_backlog_recognised_from_chinese_message(self):
result = classify("QuotaExceeded", "当前账号处于排队中状态的任务数已超过限制")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.RATE_LIMITED
def test_free_trial_exhausted_needs_billing_action(self):
# 官方原文:「免费试用额度已消耗完毕,请前往控制台开通对应模型服务。」
result = classify(
"QuotaExceeded",
"Your account [x] has exhausted its free trial quota for the [y] model.",
)
assert result.retry is Retry.QUOTA
assert result.user_facing is UserFacing.QUOTA_EXHAUSTED
def test_periodic_limit_exhausted_needs_to_wait_for_reset(self):
result = classify(
"QuotaExceeded",
"You have exceeded the 5-hour/weekly/monthly usage quota. It will reset at T.",
)
assert result.retry is Retry.QUOTA
assert result.user_facing is UserFacing.QUOTA_EXHAUSTED
class TestContentRejectionMustNotBeRetried:
"""同样的输入必然同样被拒。重试只会白烧额度再失败。"""
def test_input_image_sensitive(self):
result = classify("InputImageSensitiveContentDetected")
assert result.retry is Retry.NEVER
assert result.user_facing is UserFacing.CONTENT_REJECTED
def test_output_video_sensitive(self):
result = classify("OutputVideoSensitiveContentDetected")
assert result.retry is Retry.NEVER
assert result.user_facing is UserFacing.CONTENT_REJECTED
def test_policy_violation_subcode(self):
result = classify("InputVideoSensitiveContentDetected.PolicyViolation")
assert result.retry is Retry.NEVER
def test_real_person_subcode(self):
result = classify("InputImageSensitiveContentDetected.PrivacyInformation")
assert result.retry is Retry.NEVER
def test_risk_detection_product_codes(self):
assert classify("InputTextRiskDetection").retry is Retry.NEVER
assert classify("OutputImageRiskDetection").retry is Retry.NEVER
def test_but_the_detector_itself_failing_is_retryable(self):
# 风险识别产品请求失败 ≠ 内容真的违规。
result = classify("ContentSecurityDetectionError", "Internal error.")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
class TestRateLimitFamily:
def test_endpoint_rpm(self):
result = classify("RateLimitExceeded.EndpointRPMExceeded")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.RATE_LIMITED
def test_model_account_tpm(self):
assert classify("ModelAccountTpmRateLimitExceeded").retry is Retry.BACKOFF
def test_server_overloaded(self):
assert classify("ServerOverloaded").retry is Retry.BACKOFF
def test_request_burst_too_fast(self):
# doubao-seed-2.0 之后版本的突增流量保护码。
assert classify("RequestBurstTooFast").retry is Retry.BACKOFF
def test_images_per_minute(self):
assert classify("ModelAccountIpmRateLimitExceeded").retry is Retry.BACKOFF
class TestQuotaAndBillingFamily:
def test_inference_limit_set_by_account(self):
result = classify("SetLimitExceeded")
assert result.retry is Retry.QUOTA
assert result.user_facing is UserFacing.QUOTA_EXHAUSTED
def test_concurrency_cap_needs_topup(self):
assert classify("InflightBatchsizeExceeded").retry is Retry.QUOTA
def test_account_overdue_is_403_but_same_user_action(self):
# HTTP 状态码不同(403 而非 429),但用户要做的事一样:去充值。
result = classify("AccountOverdueError")
assert result.retry is Retry.QUOTA
assert result.user_facing is UserFacing.QUOTA_EXHAUSTED
def test_model_not_activated(self):
assert classify("ModelNotOpen").retry is Retry.QUOTA
class TestOurOwnFaults:
"""重试无意义,需要人改代码或改配置。"""
def test_bad_api_key(self):
result = classify("AuthenticationError")
assert result.retry is Retry.NEVER
assert result.user_facing is UserFacing.SERVER_ERROR
def test_unknown_model_id(self):
assert classify("InvalidEndpointOrModel.NotFound").retry is Retry.NEVER
def test_invalid_parameter(self):
assert classify("InvalidParameter").retry is Retry.NEVER
def test_task_type_constraint_is_our_parameter_problem(self):
# Seedance 2.5 全模态任务的参数与模型判定的任务类型不兼容。
assert classify("InvalidParameter.TaskTypeConstraint").retry is Retry.NEVER
def test_task_type_mismatch(self):
assert classify("InvalidParameter.TaskTypeMismatch").retry is Retry.NEVER
def test_missing_parameter(self):
assert classify("MissingParameter").retry is Retry.NEVER
def test_payload_too_large(self):
assert classify("RequestTooLarge").retry is Retry.NEVER
class TestUpstreamErrors:
def test_internal_service_error_is_retryable(self):
result = classify("InternalServiceError")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
class TestUnknownCodesLeanTowardsRetry:
"""厂商在持续新增错误码。把未知码判死会让一次抖动变成用户看到的失败;
重试预算本身有上限,最坏只是多试两次。"""
def test_unknown_code(self):
result = classify("SomeBrandNewCodeVolcengineAddedLastWeek")
assert result.retry is Retry.BACKOFF
assert result.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
def test_missing_code(self):
assert classify(None).retry is Retry.BACKOFF
assert classify("").retry is Retry.BACKOFF
class TestProviderCodeIsAlwaysPreserved:
"""原始码绝不丢弃——排查时它是唯一线索。"""
def test_known_code(self):
assert classify("ServerOverloaded").provider_code == "ServerOverloaded"
def test_unknown_code(self):
assert classify("WeirdCode").provider_code == "WeirdCode"
def test_whitespace_is_trimmed(self):
assert classify(" ServerOverloaded ").provider_code == "ServerOverloaded"