541376e9be
CI / ai-gates (push) Successful in 55s
拿到 doc_temp/gpt错误码.md 后核对,**此前猜的六个键里错了五个**: rate_limit_exceeded / moderation_blocked / content_policy_violation / invalid_api_key / image_generation_user_error 在官方文档里都不存在。真实的是 type rate_limit_error + code slow_down、type service_unavailable_error + code server_is_overloaded、四个 billing 码。唯一对的 insufficient_quota 也是宽 type 而非 code。这段历史留在 errors.py 顶部,作为凭记忆列错误码表代价的记录。 官方表的 13 条全是基础设施与计费,**没有任何内容审核/侵权码**——但图片接口确有 moderation 参数,说明审核存在、只是错误形态未文档化。直接后果:一次侵权或违规 拒绝很可能以普通 400 到达,只带一句人类可读消息。若一律归「服务端错误」,用户会 看到「服务出错了」并反复重试一个注定被拒的提示词。 故用 error.param 的有无做结构化区分(带 param = 参数错,不带 = 按内容被拒), 三条依据:文档的 service_tier 那条明确说参数错会带 param;内容拒绝不指向任何 具体参数;我方已在调用前校验过尺寸/画质/格式,能被 400 拒掉的参数嫌疑本就不大。 这一条是推断,走到时记 WARNING——那是日后校正它的唯一素材。client.py 相应补上 param 的解析。 两处顺带收获: - 无 code 的 429 按限流处理,现在是**文档支持的默认**(六种 429 里只有「Rate limit reached for requests」没有文档化的 code),不再是猜测。 - 文档提醒视频过载已从 429 改判为 503。我们两者都归可重试,故不受影响;加了一条 测试钉住这个「无影响」。 同一张表按 code 与 type 两个字段分别查:文档把标识列在哪个字段下,不保证响应里 它就在那个字段,查错的代价是把配额耗尽降级成限流、白烧一轮重试预算。 未接 Retry-After 并记录理由:Java 侧退避基线 30 秒、上限 10 分钟,已长于服务商 通常给的值,忽略只会偏慢、不会加剧限流;接它是一次跨语言契约变更。 测试 271 → 284。四道门禁逐条读退出码,全绿。
231 lines
8.2 KiB
Python
231 lines
8.2 KiB
Python
"""OpenAI HTTP 传输层的测试:鉴权、两种载荷形式、错误解包。
|
|
|
|
全部离线(`httpx.MockTransport`),不触网。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from app.domain.generation import Retry, UserFacing
|
|
from app.providers.openai.client import (
|
|
DEFAULT_BASE_URL,
|
|
OpenAiClient,
|
|
OpenAiError,
|
|
)
|
|
|
|
FAKE_API_KEY = "dummy-api-key-for-tests"
|
|
|
|
|
|
def client_with(handler, **kwargs) -> OpenAiClient:
|
|
return OpenAiClient(
|
|
api_key=FAKE_API_KEY,
|
|
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
class TestCredentialDiscipline:
|
|
def test_an_empty_key_fails_loudly_at_construction(self):
|
|
"""缺密钥是配置问题,应当在装配时就说清楚,而不是等第一次调用。"""
|
|
with pytest.raises(ValueError, match="OPENAI_API_KEY 未配置"):
|
|
OpenAiClient(api_key="")
|
|
|
|
def test_the_key_is_sent_as_a_bearer_token(self):
|
|
seen: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["auth"] = request.headers.get("Authorization")
|
|
return httpx.Response(200, json={"ok": True})
|
|
|
|
client_with(handler).post_json("/images/generations", {})
|
|
assert seen["auth"] == f"Bearer {FAKE_API_KEY}"
|
|
|
|
def test_organization_header_is_sent_only_when_configured(self):
|
|
seen: list = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen.append(request.headers.get("OpenAI-Organization"))
|
|
return httpx.Response(200, json={})
|
|
|
|
client_with(handler).post_json("/x", {})
|
|
client_with(handler, organization="org-abc").post_json("/x", {})
|
|
assert seen == [None, "org-abc"]
|
|
|
|
|
|
class TestUrlComposition:
|
|
def test_default_base_url_is_the_official_one(self):
|
|
assert DEFAULT_BASE_URL == "https://api.openai.com/v1"
|
|
|
|
def test_a_trailing_slash_on_the_base_url_does_not_double_up(self):
|
|
seen: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["url"] = str(request.url)
|
|
return httpx.Response(200, json={})
|
|
|
|
client_with(handler, base_url="https://proxy.invalid/v1/").post_json("/img", {})
|
|
assert seen["url"] == "https://proxy.invalid/v1/img"
|
|
|
|
|
|
class TestPayloadForms:
|
|
def test_post_json_sends_json(self):
|
|
seen: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["content_type"] = request.headers.get("Content-Type")
|
|
seen["body"] = request.content
|
|
return httpx.Response(200, json={"echo": 1})
|
|
|
|
result = client_with(handler).post_json("/x", {"prompt": "hi"})
|
|
assert seen["content_type"] == "application/json"
|
|
assert b'"prompt"' in seen["body"]
|
|
assert result == {"echo": 1}
|
|
|
|
def test_post_multipart_repeats_a_field_name(self):
|
|
"""用 list 而非 dict 传 files 的全部理由:同名字段可以重复。
|
|
|
|
编辑端点支持多图,它们共用字段名 `image[]`——dict 表达不了。
|
|
"""
|
|
seen: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["content_type"] = request.headers.get("Content-Type", "")
|
|
seen["raw"] = request.content
|
|
return httpx.Response(200, json={})
|
|
|
|
client_with(handler).post_multipart(
|
|
"/images/edits",
|
|
data={"prompt": "merge"},
|
|
files=[
|
|
("image[]", ("a.png", b"first", "image/png")),
|
|
("image[]", ("b.png", b"second", "image/png")),
|
|
],
|
|
)
|
|
|
|
assert "multipart/form-data" in seen["content_type"]
|
|
assert seen["raw"].count(b'name="image[]"') == 2
|
|
assert b"first" in seen["raw"]
|
|
assert b"second" in seen["raw"]
|
|
|
|
def test_content_type_is_not_hand_written(self):
|
|
"""multipart 还需要 boundary,手写必错——交给 httpx 按载荷推断。"""
|
|
seen: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
seen["ct"] = request.headers.get("Content-Type", "")
|
|
return httpx.Response(200, json={})
|
|
|
|
client_with(handler).post_multipart(
|
|
"/x", data={}, files=[("image[]", ("a", b"x", "image/png"))]
|
|
)
|
|
assert "boundary=" in seen["ct"]
|
|
|
|
|
|
class TestErrorUnwrapping:
|
|
def test_the_vendor_error_envelope_is_parsed(self):
|
|
def handler(request):
|
|
return httpx.Response(
|
|
429,
|
|
json={
|
|
"error": {
|
|
"message": "Your request rate increased too quickly",
|
|
"type": "rate_limit_error",
|
|
"code": "slow_down",
|
|
"param": None,
|
|
}
|
|
},
|
|
)
|
|
|
|
with pytest.raises(OpenAiError) as caught:
|
|
client_with(handler).post_json("/x", {})
|
|
|
|
error = caught.value
|
|
assert error.http_status == 429
|
|
assert error.failure.retry is Retry.BACKOFF
|
|
assert error.failure.provider_code == "slow_down"
|
|
assert "increased too quickly" in error.failure.message
|
|
|
|
def test_the_param_field_is_extracted_because_classification_needs_it(self):
|
|
"""`param` 是区分「参数错」与「内容被拒」的唯一结构化线索。
|
|
|
|
漏了它,所有 400 都会被当成内容被拒——包括真正的参数错。
|
|
"""
|
|
|
|
def handler(request):
|
|
return httpx.Response(
|
|
400,
|
|
json={
|
|
"error": {
|
|
"message": "Invalid service_tier argument",
|
|
"type": "invalid_request_error",
|
|
"param": "service_tier",
|
|
}
|
|
},
|
|
)
|
|
|
|
with pytest.raises(OpenAiError) as caught:
|
|
client_with(handler).post_json("/x", {})
|
|
|
|
# 带 param ⇒ 参数错 ⇒ 服务端错误,而不是让用户去改提示词。
|
|
assert caught.value.failure.user_facing is UserFacing.SERVER_ERROR
|
|
|
|
def test_a_non_json_error_body_still_classifies_by_status(self):
|
|
"""错误体不一定是文档描述的那个形状(网关、代理会插自己的页面)。
|
|
|
|
解析不出时不能静默丢掉线索。
|
|
"""
|
|
|
|
def handler(request):
|
|
return httpx.Response(502, text="<html>bad gateway</html>")
|
|
|
|
with pytest.raises(OpenAiError) as caught:
|
|
client_with(handler).post_json("/x", {})
|
|
|
|
assert caught.value.failure.retry is Retry.BACKOFF
|
|
assert caught.value.failure.provider_code == "HTTP_502"
|
|
# 原始文本被带上,否则排查时只知道「502」。
|
|
assert "bad gateway" in caught.value.failure.message
|
|
|
|
def test_a_2xx_with_a_non_json_body_is_an_error_not_a_success(self):
|
|
def handler(request):
|
|
return httpx.Response(200, text="not json at all")
|
|
|
|
with pytest.raises(OpenAiError, match="不是合法 JSON"):
|
|
client_with(handler).post_json("/x", {})
|
|
|
|
def test_a_network_failure_is_classified_as_retryable(self):
|
|
"""⚠️ 同步接口的超时最危险:那次调用可能已经完成并计费。
|
|
|
|
图片接口没有任务 ID 可供事后查询,故这个窗口只能由 max_attempts 兜住。
|
|
"""
|
|
|
|
def handler(request):
|
|
raise httpx.ConnectTimeout("timed out")
|
|
|
|
with pytest.raises(OpenAiError) as caught:
|
|
client_with(handler).post_json("/x", {})
|
|
|
|
assert caught.value.http_status is None
|
|
assert caught.value.failure.retry is Retry.BACKOFF
|
|
assert caught.value.failure.provider_code == "NETWORK_ERROR"
|
|
|
|
|
|
class TestConnectionOwnership:
|
|
def test_an_injected_client_is_not_closed_by_us(self):
|
|
"""注入进来的连接池归调用方管理。
|
|
|
|
关掉它会让共享同一个 client 的其他适配器一起失效。
|
|
"""
|
|
injected = httpx.Client(
|
|
transport=httpx.MockTransport(lambda r: httpx.Response(200, json={}))
|
|
)
|
|
OpenAiClient(api_key=FAKE_API_KEY, client=injected).close()
|
|
assert not injected.is_closed
|
|
|
|
def test_our_own_client_is_closed_on_exit(self):
|
|
with OpenAiClient(api_key=FAKE_API_KEY) as client:
|
|
inner = client._client
|
|
assert inner.is_closed
|