"""火山方舟(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)