8275a04903
CI / ai-gates (push) Successful in 38s
新增 app/providers/openai/:client 传输(JSON + multipart)、errors 错误映射、 models 厂商 DTO、capabilities 尺寸算术约束、tasks 同步→异步桥、image 图片业务。 装配与配置同步接入,缺 OPENAI_API_KEY 时跳过装配、服务仍可启动。 与火山视频的三处根本差异及其处置: - 接口同步(无任务 ID、无查询端点)→ SyncTaskRegistry 在本服务内部吸收, 对外仍是 ADR-023 §4 的两段式提交/轮询。代价:本服务从此持有状态,进程重启 丢在途任务,且横向扩容前须先解决任务 ID 只在签发进程内有效的问题。 - 产物是 base64 字节而非 URL → 无过期问题,但字节只在内存里。asset_url 放 data URI 仅为满足领域不变式,绝不能原样写进 media.assets.external_url。 - 图生图走 multipart → 先下载参考图再上传;下载失败与超限按参数错误同步拒绝, 不变成注定失败的后台任务。 两处需要记住的判断: - 尺寸上限「3840x2160」文档未说清是逐边还是按像素。逐边理解会禁掉所有高于 2160 的竖图,而 1024x1536 是文档明示的标准竖图,故必然过严。采用最长边 ≤3840 且总像素 ≤3840×2160,已在 capabilities.py 与取证页标为推断。 - 两份文档都没有错误码表,故 errors.py 以 HTTP 状态码为主、type/code 为辅, 顶部标注「未经文档证实」。429 无线索时按限流而非配额耗尽(猜成配额会把可 重试的限流判死);400 无线索时归服务端错误而非内容被拒(错报会让用户去改 一个其实没问题的提示词)。 一处死锁已修并回归钉住:SyncTaskRegistry 原用 add_done_callback 记录完成时刻, 而快速完成的任务在注册时已 done,回调便同步地在持有非可重入锁的线程里执行。 改为首次被观察到时惰性记录。 测试 166 → 271(+105)。四道门禁逐条读退出码,全绿。 产物转存仍阻塞于存储决策(ADR-023 §7),图片模型因此未播种进 V7 目录。
120 lines
4.9 KiB
Python
120 lines
4.9 KiB
Python
"""OpenAI 错误分类的逐条测试。
|
|
|
|
`errors.py` 顶部标了「映射未经官方文档证实」——正因为它是推断出来的,才更要把
|
|
每条分支钉住:日后拿到真实错误数据来校正时,改动的影响面一眼可见。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.domain.generation import Retry, UserFacing
|
|
from app.providers.openai.errors import classify
|
|
|
|
|
|
class TestHighConfidenceOverrides:
|
|
"""文档没给错误码表,故只对少数语义明确的取值加细。"""
|
|
|
|
@pytest.mark.parametrize("field", ["code", "type"])
|
|
def test_insufficient_quota_is_not_worth_retrying(self, field):
|
|
failure = classify(429, **{f"error_{field}": "insufficient_quota"})
|
|
assert failure.retry is Retry.QUOTA
|
|
assert failure.user_facing is UserFacing.QUOTA_EXHAUSTED
|
|
|
|
@pytest.mark.parametrize("field", ["code", "type"])
|
|
def test_rate_limit_backs_off(self, field):
|
|
failure = classify(429, **{f"error_{field}": "rate_limit_exceeded"})
|
|
assert failure.retry is Retry.BACKOFF
|
|
assert failure.user_facing is UserFacing.RATE_LIMITED
|
|
|
|
@pytest.mark.parametrize("code", ["moderation_blocked", "content_policy_violation"])
|
|
def test_content_rejection_is_terminal(self, code):
|
|
# 同样的输入必然同样被拒——重试只是白花一次配额。
|
|
failure = classify(400, error_code=code)
|
|
assert failure.retry is Retry.NEVER
|
|
assert failure.user_facing is UserFacing.CONTENT_REJECTED
|
|
|
|
def test_invalid_key_is_our_problem_not_the_users(self):
|
|
failure = classify(401, error_code="invalid_api_key")
|
|
assert failure.retry is Retry.NEVER
|
|
# 用户看到的是「服务端错误」,不是「你的内容有问题」。
|
|
assert failure.user_facing is UserFacing.SERVER_ERROR
|
|
|
|
def test_code_wins_over_type_when_both_are_present(self):
|
|
# code 比 type 更具体,故优先。
|
|
failure = classify(
|
|
429, error_type="rate_limit_exceeded", error_code="insufficient_quota"
|
|
)
|
|
assert failure.retry is Retry.QUOTA
|
|
|
|
|
|
class TestStatusCodeFallback:
|
|
"""拿不到 type/code 时只靠状态码——这是本模块的主依据。"""
|
|
|
|
def test_429_without_any_hint_is_treated_as_rate_limiting(self):
|
|
"""429 两义兼有;猜成配额耗尽会把一次可重试的限流判死,代价更大。"""
|
|
failure = classify(429)
|
|
assert failure.retry is Retry.BACKOFF
|
|
assert failure.user_facing is UserFacing.RATE_LIMITED
|
|
|
|
def test_400_without_any_hint_does_not_blame_the_prompt(self):
|
|
"""400 同时用于参数错与内容被拒。
|
|
|
|
错报「内容被拒」会让用户去改一个其实没问题的提示词,故归服务端错误。
|
|
"""
|
|
failure = classify(400)
|
|
assert failure.retry is Retry.NEVER
|
|
assert failure.user_facing is UserFacing.SERVER_ERROR
|
|
|
|
@pytest.mark.parametrize("status", [401, 403])
|
|
def test_auth_failures_are_terminal(self, status):
|
|
assert classify(status).retry is Retry.NEVER
|
|
|
|
@pytest.mark.parametrize("status", [404, 409, 422])
|
|
def test_other_4xx_are_terminal(self, status):
|
|
assert classify(status).retry is Retry.NEVER
|
|
|
|
@pytest.mark.parametrize("status", [500, 502, 503, 504])
|
|
def test_5xx_is_retryable(self, status):
|
|
failure = classify(status)
|
|
assert failure.retry is Retry.BACKOFF
|
|
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
|
|
|
|
def test_network_failure_is_retryable(self):
|
|
# http_status=None 表示连都没连上。
|
|
failure = classify(None, message="connect timeout")
|
|
assert failure.retry is Retry.BACKOFF
|
|
assert failure.user_facing is UserFacing.UPSTREAM_UNAVAILABLE
|
|
|
|
def test_an_unknown_status_leans_towards_retrying(self):
|
|
"""未知一律偏可重试:把可恢复的抖动判死是更糟的错误。
|
|
|
|
重试预算本身有上限(max_attempts),所以偏向重试是有界的。
|
|
"""
|
|
assert classify(599).retry is Retry.BACKOFF
|
|
|
|
|
|
class TestProviderCodeIsNeverEmpty:
|
|
"""它是排查时的第一线索,空字符串等于把线索丢了。"""
|
|
|
|
def test_code_is_preferred(self):
|
|
assert classify(400, error_type="t", error_code="c").provider_code == "c"
|
|
|
|
def test_type_is_used_when_code_is_absent(self):
|
|
assert classify(400, error_type="t").provider_code == "t"
|
|
|
|
def test_status_label_is_the_last_resort(self):
|
|
assert classify(503).provider_code == "HTTP_503"
|
|
|
|
def test_network_failures_get_a_named_label_too(self):
|
|
assert classify(None).provider_code == "NETWORK_ERROR"
|
|
|
|
|
|
class TestMessageIsCarriedThrough:
|
|
def test_message_is_kept(self):
|
|
assert classify(400, message="bad size").message == "bad size"
|
|
|
|
def test_missing_message_becomes_empty_not_none(self):
|
|
# 契约里 message 是字符串;None 会在序列化处变成 null 而非 ""。
|
|
assert classify(400).message == ""
|