"""火山方舟错误码 → 领域 `Failure` 的映射。 **本模块是唯一理解火山错误码的地方。** 上层只看 `Failure` 的 `retry` 与 `user_facing`,不接触厂商码——把厂商词汇挡在这一层,是「换供应商不动上层」 的前提。 三处如果搞错只会在生产暴露的地方: 1. **`QuotaExceeded` 一码两义。** 同一个 code 既表示「免费额度/周期限额耗尽」 (要等到明天或去充值),也表示「当前账号排队中任务数超限」(等几秒就好)。 官方错误码表里这两条是相邻两行、code 完全相同,只有 message 不同。按 code 一刀切,必然有一半场景的文案和出口是错的。 2. **内容安全类重试毫无意义**(同样的输入必然同样被拒),必须与限流类分开, 否则会白烧三次重试预算再失败。 3. **未知码必须偏向可重试**而不是判死:厂商在持续新增码。 映射的完整取证见 patbond-doc `docs/development/providers/volcengine-seedance.md`。 """ from __future__ import annotations import re from app.domain.generation import Failure, Retry, UserFacing #: 内容安全类:**重试无意义**。覆盖输入/输出的文本、图片、视频、音频,以及 #: 版权、真人、伪造证件等子类,另含风险识别产品(*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) -> Failure: """把厂商错误码分类。 未知码一律按**可退避重试的上游问题**处理而不是直接判死:厂商在持续新增错误 码,把未知码当终态失败会让一次可恢复的抖动变成用户看到的失败;而重试预算 本身有上限(`max_attempts`),最坏情况只是多试两次。 """ code = (provider_code or "").strip() text = message or "" if not code: return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, "", text) if code.startswith(_CONTENT_PREFIXES): return Failure(Retry.NEVER, UserFacing.CONTENT_REJECTED, code, text) if code == "ContentSecurityDetectionError": # 风险识别产品自身请求失败,不是内容真的违规 —— 可重试。 return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code, text) if code == "QuotaExceeded": # 一码两义,见模块 docstring。 if _QUEUE_BACKLOG_MESSAGE.search(text): return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, code, text) return Failure(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code, text) if code in _RATE_LIMIT_CODES: return Failure(Retry.BACKOFF, UserFacing.RATE_LIMITED, code, text) if code in _QUOTA_CODES: return Failure(Retry.QUOTA, UserFacing.QUOTA_EXHAUSTED, code, text) if code in _SERVER_FAULT_CODES: return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, code, text) if code in _UPSTREAM_CODES: return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code, text) # 参数类:我方拼错了请求体,重试同样的请求体不会变好。 # 含 InvalidParameter.TaskTypeConstraint / TaskTypeMismatch —— 那两个是 # Seedance 2.5 全模态任务的参数与模型判定的任务类型不兼容,属我方参数问题。 if code.startswith( ( "InvalidParameter", "MissingParameter", "InvalidArgumentError", "InvalidImageURL", "InvalidPayload", "RequestTooLarge", "RequestBodyTooLarge", "OutofContextError", ) ): return Failure(Retry.NEVER, UserFacing.SERVER_ERROR, code, text) return Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE, code, text)