4762358f54
CI / ai-gates (push) Failing after 29s
`api_key=volcengine.api_key` 这类**依赖注入传参**被 KEY-ASSIGN 判为疑似凭证。 这是规则的真缺口而非代码问题:行里没有任何秘密,而做 DI 的代码到处都是这种 写法——绕过一次就得在每个调用点绕过,那等于把规则废掉。故改规则。 新增 ALLOW_REFERENCE,刻意收得很紧以免开后门: - 值必须是**未加引号**的小写起首标识符引用(真密钥要么带引号,要么不是标识符形状) - 只允许**恰好一个点且随即行尾** - 连字符不在字符集内 对抗性夹具验证(三真三假): - 拦下 `api_key = "sk-T3Blbk…"`(OpenAI 形态) - 拦下 `secret_key: "AKID…"`(腾讯云形态) - 拦下 `auth_token=eyJ….eyJ….Sfl…`(**三段式 JWT——两个点,故不落进放行**) - 放行 `api_key=lowercase.reference` / `api_key = settings.api_key` / `api_token: config.authToken` 原 5 形态泄漏夹具复验仍 8 条命中,一条不少。 另更正口径:脚本头与 git-workflow.md 仍写「三仓同构」,实际已是四仓—— patbond-ai 是唯一持有 AI 服务商凭证的仓,漏同步它风险最大。四仓 md5 已一致 (4945940093b4b8413b7d3eec8bf6a5ec),四仓 --all 全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
6.5 KiB
Python
162 lines
6.5 KiB
Python
"""火山方舟错误码 → 领域 `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)
|